Build CoreOrdered learning track

Resource APIs vs Action APIs

Learn Java Microservices Communication - Part 030

Resource APIs vs action APIs for Java microservices: how to choose between CRUD-shaped resources and explicit domain actions without abusing HTTP semantics.

13 min read2496 words
PrevNext
Lesson 3096 lesson track18–52 Build Core
#java#microservices#communication#http-api+4 more

Part 030 — Resource APIs vs Action APIs

This part answers a deceptively simple design question:

Should this HTTP API be modeled as a resource operation or an action?

Many teams turn this into a religious debate:

  • "Everything must be RESTful resources."
  • "REST is too rigid; just use RPC over HTTP."
  • "POST everything and move on."
  • "Never use verbs in URLs."

All four positions are too shallow for production microservices.

The useful answer is:

Model stable things as resources. Model meaningful domain operations as actions. Do not hide business commands behind generic CRUD, and do not create arbitrary verbs for every internal method.

A mature HTTP API can contain both resource APIs and action APIs.

The key is to make the semantics explicit.


1. Resource API Mental Model

A resource API exposes identifiable things.

A resource has:

  • identity,
  • representation,
  • lifecycle,
  • ownership,
  • stable URI,
  • operations that fit HTTP method semantics.

Examples:

/cases/{caseId}
/cases/{caseId}/documents/{documentId}
/officers/{officerId}
/review-decisions/{decisionId}
/report-generation-jobs/{jobId}

Typical operations:

GET    /cases/{caseId}
GET    /cases
POST   /cases
PUT    /cases/{caseId}
PATCH  /cases/{caseId}
DELETE /cases/{caseId}

Resource APIs are excellent when the domain concept has durable identity and consumers care about its representation.


2. Action API Mental Model

An action API exposes an operation.

An action has:

  • intent,
  • side effect,
  • preconditions,
  • business meaning,
  • audit meaning,
  • failure semantics,
  • idempotency semantics.

Examples:

POST /cases/{caseId}:assign
POST /cases/{caseId}:submit-for-review
POST /cases/{caseId}:approve-review
POST /payments/{paymentId}:capture
POST /documents/{documentId}:verify

Action APIs are excellent when the consumer wants to request a business operation, not mutate a representation directly.


3. The Wrong Question

The wrong question is:

"Is this RESTful?"

A better question:

"What contract best preserves the service's invariants while giving consumers a stable capability?"

For example, this looks resource-oriented:

PATCH /cases/{caseId}

{
  "status": "APPROVED"
}

But it may be bad because it lets the caller control lifecycle state directly.

This looks action-oriented:

POST /cases/{caseId}:approve-review

{
  "decisionReason": "All checks passed",
  "expectedVersion": 8
}

It may be better because the service owns the transition and validates the invariant.

The second API is more honest.


4. The Core Decision Rule

Use a resource API when the primary concept is a durable thing the client reads or manages.

Use an action API when the primary concept is a domain operation whose rules belong inside the service.

This rule is simple, but it prevents many mistakes.


5. Standard Resource Operations

Resource APIs are strongest when they use standard HTTP method semantics clearly.

Read one resource

GET /cases/C-100

Response:

{
  "caseId": "C-100",
  "status": "UNDER_REVIEW",
  "riskLevel": "HIGH",
  "assignedOfficerId": "O-10",
  "updatedAt": "2026-07-05T04:00:00Z"
}

List resources

GET /cases?status=UNDER_REVIEW&pageSize=50&pageToken=eyJwYWdlIjoyfQ

Response:

{
  "items": [
    {
      "caseId": "C-100",
      "status": "UNDER_REVIEW"
    }
  ],
  "nextPageToken": "eyJwYWdlIjozfQ"
}

Create resource

POST /cases
Content-Type: application/json

{
  "type": "REGULATORY_ENFORCEMENT",
  "subjectId": "S-900",
  "initialRiskLevel": "MEDIUM"
}

Response:

201 Created
Location: /cases/C-100

Replace resource

PUT /case-drafts/D-100

Use carefully. Full replacement is less common for complex domain aggregates.

Partial update

PATCH /case-drafts/D-100

Use carefully. Patch is best for simple mutable resources or drafts, not invariant-heavy lifecycle transitions.

Delete resource

DELETE /case-drafts/D-100

Use carefully. Many business resources are closed, cancelled, archived, or revoked rather than physically deleted.


6. Where Resource APIs Work Well

Resource APIs work well for:

Reference data
Configuration objects
Drafts
Documents
Jobs/operations
Search resources
Saved filters
Reports
Attachments
Comments
Review decisions
Audit event reads

Example: document upload.

POST /cases/C-100/documents
Content-Type: application/json

{
  "documentType": "EVIDENCE",
  "fileName": "inspection-report.pdf",
  "contentRef": "blob://upload/abc"
}

Response:

201 Created
Location: /cases/C-100/documents/D-200

This is a natural resource operation.

The client creates a document resource under a case.


7. Where Resource APIs Become Dangerous

Resource APIs become dangerous when they let clients mutate invariant-heavy domain state directly.

Example:

PATCH /cases/C-100

{
  "status": "CLOSED",
  "closedAt": "2026-07-05T05:00:00Z",
  "closureReason": "RESOLVED"
}

This looks simple, but the service must ask:

Is the case allowed to close?
Were all required reviews completed?
Are there pending enforcement actions?
Is the caller allowed to close this case?
Should audit events be produced?
Should notification events be emitted?
Should downstream reports update?
What if this request is retried?
What if expected version is stale?

A generic patch hides a business command.

A better operation:

POST /cases/C-100:close
Content-Type: application/json
Idempotency-Key: 1ea6cc4f-2f5f-49e0-b9af-8e32789b95a8

{
  "reasonCode": "RESOLVED",
  "comment": "All required checks completed.",
  "expectedVersion": 12
}

Now the command is visible.


8. Action APIs Are Not a Failure

Some engineers treat action endpoints as design failure.

That is too simplistic.

Domain systems contain operations:

  • approve,
  • reject,
  • assign,
  • escalate,
  • submit,
  • cancel,
  • capture,
  • refund,
  • verify,
  • recalculate,
  • publish,
  • archive,
  • reopen.

Forcing these into CRUD can create worse APIs.

Bad resource disguise:

PATCH /payments/P-100

{
  "status": "CAPTURED"
}

Better action:

POST /payments/P-100:capture

{
  "amount": "150.00",
  "currency": "USD",
  "idempotencyKey": "..."
}

The action exposes business intent.


9. Action APIs Must Be Disciplined

Action APIs are powerful, but they can devolve into RPC soup.

Bad:

POST /cases/{caseId}:process
POST /cases/{caseId}:handle
POST /cases/{caseId}:execute
POST /cases/{caseId}:doNext
POST /cases/{caseId}:change
POST /cases/{caseId}:run

Good:

POST /cases/{caseId}:assign
POST /cases/{caseId}:submit-for-review
POST /cases/{caseId}:approve-review
POST /cases/{caseId}:request-more-information
POST /cases/{caseId}:close

An action name should be:

  • domain-specific,
  • auditable,
  • permission-mappable,
  • operationally observable,
  • understandable without reading code.

If the action name is generic, the endpoint is probably hiding too much.


10. Colon Actions vs Subresource Actions

There are two common shapes:

POST /cases/{caseId}:assign

or:

POST /cases/{caseId}/assignments

Both can be valid.

Choose based on whether the operation produces a durable resource.

Use colon-style action when the operation is a command

POST /cases/{caseId}:submit-for-review

The main result is a state transition.

Use subresource when the operation creates a durable thing

POST /cases/{caseId}/assignments

This is good if assignments are first-class resources with identity:

GET /cases/{caseId}/assignments/A-100

Decision rule:

If consumers later need to read, list, cancel, audit, or reference the thing created by the operation, model it as a resource.
Otherwise, model the operation as an action.

11. Action Result Shapes

An action response should not be arbitrary.

Common result patterns:

Return updated resource representation

200 OK

{
  "caseId": "C-100",
  "status": "ASSIGNED",
  "assignedOfficerId": "O-10",
  "version": 9
}

Good when the action completes synchronously and the consumer needs current state.

Return command result

200 OK

{
  "caseId": "C-100",
  "assignmentId": "A-900",
  "status": "APPLIED",
  "version": 9
}

Good when the result itself matters.

Return accepted operation

202 Accepted
Location: /cases/C-100/operations/OP-900

Good when the operation is long-running.

Return no content

204 No Content

Good only when the consumer does not need result details and observability/audit exists elsewhere.

In business systems, 204 after commands is often under-informative.


12. Resource Creation vs Action Command

Consider case assignment.

Option A:

POST /cases/C-100:assign

Option B:

POST /cases/C-100/assignments

Option A is better if there is only one current assignment and consumers care about the case state.

Option B is better if assignment history is a first-class domain concept:

GET /cases/C-100/assignments
GET /cases/C-100/assignments/A-777
POST /cases/C-100/assignments/A-777:cancel

Do not choose based on taste. Choose based on domain identity.


13. State Transition Design

Most action APIs are state transitions.

A robust transition command should include:

target resource ID
command intent
actor/context from auth/session
reason/comment if auditable
expected version or precondition
idempotency key if retryable
request timestamp if relevant
client request ID for diagnostics

Example:

POST /cases/C-100:submit-for-review
Idempotency-Key: 1efbe3cc-e076-4611-9786-4d58188dba7c
Content-Type: application/json

{
  "expectedVersion": 7,
  "submissionNote": "Ready for supervisor review."
}

The server decides:

current state valid?
required fields complete?
caller allowed?
version current?
side effects needed?
events to emit?
audit entry required?

The client requests intent. The service owns the transition.


14. Use Preconditions for Resource Updates

For resource updates, avoid lost updates.

Option 1: entity tag.

GET /case-drafts/D-100

Response:

ETag: "v7"

Then:

PUT /case-drafts/D-100
If-Match: "v7"

Option 2: expected version in body.

{
  "expectedVersion": 7,
  "title": "Updated draft title"
}

For service-to-service internal APIs, either can work.

The deeper invariant is:

Writes must not silently overwrite concurrent changes unless the domain explicitly allows it.


15. Resource API Example: Case Draft

A draft is often a good resource.

It has identity, mutable representation, and simple lifecycle.

POST /case-drafts

Request:

{
  "subjectId": "S-900",
  "caseType": "REGULATORY_ENFORCEMENT"
}

Response:

201 Created
Location: /case-drafts/D-100

Patch draft:

PATCH /case-drafts/D-100
If-Match: "v2"
Content-Type: application/json

{
  "summary": "Updated inspection summary",
  "priority": "HIGH"
}

Submit draft:

POST /case-drafts/D-100:submit
Idempotency-Key: 8c0f6316-f68e-4c10-8d61-fdbb5bcb733e

The draft itself is a resource. Submission is an action.

This hybrid is clean.


16. Action API Example: Review Decision

A review decision may be modeled two ways.

As action

POST /cases/C-100:approve-review

Good if the only important result is the case transition.

As resource

POST /cases/C-100/review-decisions

Request:

{
  "decision": "APPROVED",
  "reasonCode": "CHECKS_PASSED",
  "comment": "No outstanding issues."
}

Response:

201 Created
Location: /cases/C-100/review-decisions/RD-700

Good if review decisions are auditable first-class records.

Which is better?

In regulated systems, the second is often stronger because the decision itself has evidentiary value.

But the resource creation should still trigger domain transition inside the service.


17. The Command Resource Pattern

Sometimes a command deserves a resource of its own.

Example:

POST /case-assignment-requests

Request:

{
  "caseId": "C-100",
  "assigneeId": "O-10",
  "reason": "Workload balancing"
}

Response:

201 Created
Location: /case-assignment-requests/AR-900

Then:

GET /case-assignment-requests/AR-900

This is useful when:

  • the request has lifecycle,
  • approval may be needed,
  • processing is asynchronous,
  • the command can be cancelled,
  • the command must be audited independently,
  • multiple systems coordinate around it.

Do not overuse it. It adds complexity.


18. Long-Running Operations as Resources

A long-running action should usually return an operation/job resource.

Bad:

POST /cases/C-100:generate-report

Waits 60 seconds.

Better:

POST /cases/C-100/report-generation-jobs

Response:

202 Accepted
Location: /cases/C-100/report-generation-jobs/J-900
Retry-After: 5

Poll:

GET /cases/C-100/report-generation-jobs/J-900

Response:

{
  "jobId": "J-900",
  "status": "RUNNING",
  "progress": 0.35
}

When complete:

{
  "jobId": "J-900",
  "status": "SUCCEEDED",
  "reportId": "R-100"
}

Here, the operation is a resource because consumers need to observe its lifecycle.


19. Search: Resource, Query, or Action?

Search is often mishandled.

Simple list:

GET /cases?status=OPEN&assigneeId=O-10

Good for bounded filter sets.

Complex search with large body:

POST /case-searches

Request:

{
  "filters": [
    { "field": "riskLevel", "op": "IN", "values": ["HIGH", "CRITICAL"] },
    { "field": "createdAt", "op": "GTE", "value": "2026-01-01T00:00:00Z" }
  ],
  "sort": [
    { "field": "createdAt", "direction": "DESC" }
  ]
}

Response:

201 Created
Location: /case-searches/S-900

Then:

GET /case-searches/S-900/results?pageSize=50

Use this when search is heavy, auditable, asynchronous, or reused.

Avoid:

POST /cases/search

with unbounded semantics and no lifecycle.

That is usually a query kitchen sink.


20. Recalculation APIs

Recalculation is often an implementation smell.

Bad:

POST /cases/C-100:recalculate

What is being recalculated? Why? Is the result observable? Is it safe to retry? Does it change state?

Better names:

POST /cases/C-100:rerun-risk-assessment
POST /cases/C-100:refresh-eligibility-decision
POST /cases/C-100/recalculation-jobs

Even better, ask whether the recalculation should be event-driven instead:

DocumentUploaded event -> Risk projection refreshes automatically.
OfficerChanged event -> Assignment eligibility refreshes automatically.
PolicyChanged event -> impacted cases scheduled for reassessment.

An HTTP action is appropriate when a consumer explicitly requests the operation.

It is not appropriate as a backdoor fix for missing reactive propagation.


21. Available Actions Endpoint

For stateful domains, exposing available actions can reduce coupling.

GET /cases/C-100/available-actions

Response:

{
  "caseId": "C-100",
  "version": 8,
  "actions": [
    {
      "name": "APPROVE_REVIEW",
      "method": "POST",
      "href": "/cases/C-100:approve-review",
      "requiresReason": true
    },
    {
      "name": "REQUEST_MORE_INFORMATION",
      "method": "POST",
      "href": "/cases/C-100:request-more-information",
      "requiresComment": true
    }
  ]
}

This is useful when:

  • lifecycle is complex,
  • UI/BFF needs action enablement,
  • authorization and state jointly determine allowed operations,
  • consumers should not encode state transition rules.

But do not make every API hypermedia-heavy if the consumers do not benefit.

Use it where it removes duplicated decision logic.


22. Action APIs and Idempotency

Actions with side effects need idempotency design.

Example:

POST /payments/P-100:capture
Idempotency-Key: 0d64f6e1-9274-4217-83dc-1f82c1e13ae2

Server rule:

same key + same semantic request -> same result
same key + different semantic request -> conflict
key expired -> documented behavior

This applies to:

  • assign,
  • close,
  • submit,
  • approve,
  • capture,
  • refund,
  • create once commands,
  • external side-effect triggers.

An action API without idempotency may force clients into unsafe retry behavior.


23. Action APIs and Status Codes

Do not return 200 OK for everything.

Useful mappings:

CaseStatus
Action completed and returns result200 OK
Action completed and no response body needed204 No Content
Action created resource201 Created
Action accepted for async processing202 Accepted
Malformed request400 Bad Request
Authentication missing/invalid401 Unauthorized
Authenticated but not allowed403 Forbidden
Target resource missing404 Not Found
Version conflict / invalid state transition409 Conflict
Semantically invalid request422 Unprocessable Content
Too many requests429 Too Many Requests
Dependency/temporary unavailable503 Service Unavailable

Status code is a control signal for clients and gateways.

It should align with retry and error handling policy.


24. Action APIs and Error Body

An action error should be machine-readable.

Example:

{
  "type": "https://api.example.test/problems/invalid-case-transition",
  "title": "Invalid case transition",
  "status": 409,
  "detail": "Case C-100 cannot be approved while required documents are missing.",
  "instance": "/cases/C-100:approve-review/requests/REQ-900",
  "code": "CASE_TRANSITION_NOT_ALLOWED",
  "retriable": false,
  "currentStatus": "UNDER_REVIEW",
  "missingRequirements": [
    "INSPECTION_REPORT"
  ]
}

The body should answer:

What failed?
Is it retriable?
Is the failure due to state, validation, auth, dependency, or overload?
What can the client do next?
How can support trace the request?

25. Do Not Expose Internal Commands

Bad:

POST /cases/C-100:sendKafkaEvent
POST /cases/C-100:updateWorkflowVariable
POST /cases/C-100:flushCache
POST /cases/C-100:runRuleEngine

These are implementation actions, not domain actions.

Better:

POST /cases/C-100:submit-for-review
POST /cases/C-100:reopen
POST /cases/C-100:request-more-information

The service may send Kafka events, update workflow variables, flush cache, or run rules internally.

The API should expose why, not how.


26. Do Not Abuse PATCH for Commands

PATCH is not wrong. It is wrong when used to hide commands.

Good PATCH example:

PATCH /case-drafts/D-100

{
  "priority": "HIGH",
  "summary": "Updated summary"
}

Bad PATCH example:

PATCH /cases/C-100

{
  "status": "APPROVED",
  "reviewedBy": "O-10",
  "reviewedAt": "2026-07-05T04:00:00Z"
}

Approval is not a field update. It is a command with rules.

Use:

POST /cases/C-100:approve-review

27. Do Not Abuse POST for Reads

Sometimes POST for read is valid:

  • request body too complex for query string,
  • search is expensive and creates a search resource,
  • query needs privacy constraints,
  • query is asynchronous.

But avoid:

POST /cases/get
POST /cases/list
POST /cases/find

If it is a simple safe read, use GET.

Good:

GET /cases/C-100
GET /cases?status=OPEN

This keeps read semantics clear for clients, proxies, observability, and operations.


28. Domain Command DTO Design

Action request DTOs should express command intent.

public record ApproveReviewRequest(
        String reasonCode,
        String comment,
        long expectedVersion
) {}

Not:

public record UpdateCaseStatusRequest(
        String status,
        String reviewedBy,
        Instant reviewedAt,
        String workflowTaskId,
        boolean sendNotification
) {}

The second DTO lets the consumer control internals.

A good command DTO:

  • contains business inputs,
  • avoids server-derived fields,
  • avoids implementation flags,
  • includes precondition/version when needed,
  • is small enough to validate clearly,
  • maps cleanly to an application command.

29. Java Controller Example: Resource API

Example resource read controller:

@RestController
@RequestMapping("/cases")
final class CaseQueryController {

    private final GetCaseSummaryUseCase getCaseSummary;
    private final ListCasesUseCase listCases;

    @GetMapping("/{caseId}")
    ResponseEntity<CaseSummaryResponse> getCase(
            @PathVariable String caseId
    ) {
        var summary = getCaseSummary.handle(new GetCaseSummaryQuery(caseId));
        return ResponseEntity.ok(CaseSummaryResponse.from(summary));
    }

    @GetMapping
    ResponseEntity<ListCasesResponse> listCases(
            @RequestParam(required = false) String status,
            @RequestParam(defaultValue = "50") int pageSize,
            @RequestParam(required = false) String pageToken
    ) {
        var result = listCases.handle(new ListCasesQuery(status, pageSize, pageToken));
        return ResponseEntity.ok(ListCasesResponse.from(result));
    }
}

The controller exposes resource reads.

No state transition rules are encoded here.


30. Java Controller Example: Action API

Example action controller:

@RestController
@RequestMapping("/cases")
final class CaseCommandController {

    private final ApproveReviewUseCase approveReview;

    @PostMapping("/{caseId}:approve-review")
    ResponseEntity<ApproveReviewResponse> approveReview(
            @PathVariable String caseId,
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @Valid @RequestBody ApproveReviewRequest request
    ) {
        var command = new ApproveReviewCommand(
                caseId,
                request.reasonCode(),
                request.comment(),
                request.expectedVersion(),
                idempotencyKey
        );

        var result = approveReview.handle(command);

        return ResponseEntity.ok(ApproveReviewResponse.from(result));
    }
}

The action endpoint maps to an application command.

The use case enforces the invariant.


31. Application Command Example

public record ApproveReviewCommand(
        String caseId,
        String reasonCode,
        String comment,
        long expectedVersion,
        String idempotencyKey
) {}
@Service
final class ApproveReviewUseCase {

    private final CaseRepository caseRepository;
    private final IdempotencyStore idempotencyStore;
    private final DomainEventPublisher eventPublisher;

    @Transactional
    ApproveReviewResult handle(ApproveReviewCommand command) {
        return idempotencyStore.execute(
                command.idempotencyKey(),
                command,
                () -> approve(command)
        );
    }

    private ApproveReviewResult approve(ApproveReviewCommand command) {
        var caseAggregate = caseRepository.getForUpdate(command.caseId());

        caseAggregate.assertVersion(command.expectedVersion());
        caseAggregate.approveReview(command.reasonCode(), command.comment());

        caseRepository.save(caseAggregate);
        eventPublisher.publish(caseAggregate.pullEvents());

        return ApproveReviewResult.from(caseAggregate);
    }
}

The HTTP action is thin. The domain operation is protected.


32. Mapping Domain Failures to HTTP

Action APIs need clear failure mapping.

@RestControllerAdvice
final class ApiExceptionHandler {

    @ExceptionHandler(CaseNotFoundException.class)
    ResponseEntity<ProblemDetail> notFound(CaseNotFoundException ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problem.setTitle("Case not found");
        problem.setDetail(ex.getMessage());
        problem.setProperty("code", "CASE_NOT_FOUND");
        problem.setProperty("retriable", false);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }

    @ExceptionHandler(InvalidTransitionException.class)
    ResponseEntity<ProblemDetail> invalidTransition(InvalidTransitionException ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        problem.setTitle("Invalid case transition");
        problem.setDetail(ex.getMessage());
        problem.setProperty("code", "CASE_TRANSITION_NOT_ALLOWED");
        problem.setProperty("retriable", false);
        return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
    }
}

The client should not parse random exception messages.

It should receive a stable problem type/code.


33. OpenAPI Example: Resource + Action

paths:
  /cases/{caseId}:
    get:
      operationId: getCase
      summary: Get case summary
      parameters:
        - name: caseId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Case summary
        '404':
          description: Case not found

  /cases/{caseId}:approve-review:
    post:
      operationId: approveCaseReview
      summary: Approve review for a case
      x-idempotency-required: true
      parameters:
        - name: caseId
          in: path
          required: true
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
      responses:
        '200':
          description: Review approved
        '409':
          description: Invalid transition or version conflict
        '422':
          description: Semantic validation failed

Resource and action can coexist cleanly in one API.


34. Security and Audit Difference

Resource read:

GET /cases/C-100

Permission:

case.read

Audit:

Maybe access audit depending on sensitivity.

Action command:

POST /cases/C-100:approve-review

Permission:

case.review.approve

Audit:

Required: who approved, when, reason, previous state, new state, request ID.

Action APIs usually have stronger audit requirements.

That is another reason not to hide commands behind generic updates.


35. Metrics Difference

Resource endpoint metrics:

http.server.request.duration{http.route="/cases/{caseId}", method="GET"}

Action endpoint metrics:

http.server.request.duration{http.route="/cases/{caseId}:approve-review", method="POST"}
case.review.approved.count
case.review.approval.failed.count{reason="missing_document"}

Business action metrics matter.

If all commands are hidden behind PATCH /cases/{caseId}, you lose domain-level operational visibility.


36. Consumer SDK Shape

Generated clients for resource APIs often look fine:

CaseSummaryResponse getCase(String caseId);
ListCasesResponse listCases(ListCasesRequest request);

For action APIs, avoid generic method names:

ApproveReviewResponse approveReview(String caseId, ApproveReviewRequest request);
AssignCaseResponse assignCase(String caseId, AssignCaseRequest request);

Do not generate:

Object postCaseAction(String caseId, String action, Object body);

That destroys type safety and hides the contract.


37. Resource vs Action Decision Table

RequirementPrefer
Read current representation of an identifiable thingResource API
List/search finite collection with simple filtersResource API
Create a durable objectResource API
Replace or patch a simple mutable objectResource API
Request domain state transitionAction API
Enforce invariant-heavy commandAction API
Operation produces first-class durable recordResource creation
Operation is long-runningJob/operation resource
Command needs approval/cancellation/lifecycleCommand resource
UI-specific data compositionBFF/composition API
Raw internal method exposureNeither; redesign
Raw table mutationNeither; redesign

38. Anti-Patterns

Anti-pattern 1 — CRUD over aggregate root for everything

PATCH /cases/{caseId}

Used for:

  • assign,
  • approve,
  • close,
  • escalate,
  • reopen,
  • request information.

This hides commands and weakens invariants.

Anti-pattern 2 — RPC soup

POST /cases/doAssignment
POST /cases/doApproval
POST /cases/doClose
POST /cases/runCaseStuff

This loses resource context and consistent naming.

Anti-pattern 3 — Status field mutation

PATCH /orders/O-100

{
  "status": "CANCELLED"
}

Should often be:

POST /orders/O-100:cancel

Anti-pattern 4 — Hidden long-running operation

POST /reports/generate

No operation resource, no progress, no retry story.

Anti-pattern 5 — Verb-only endpoint

POST /approve

Approve what? Under which resource? With what permission? With what audit trail?


39. The Practical Naming Convention

A good internal convention:

Resource collection:
/cases

Resource item:
/cases/{caseId}

Subresource collection:
/cases/{caseId}/documents

Subresource item:
/cases/{caseId}/documents/{documentId}

Action on resource:
/cases/{caseId}:submit-for-review

Action creates durable resource:
/cases/{caseId}/review-decisions

Long-running operation:
/cases/{caseId}/report-generation-jobs/{jobId}

This convention is not the only possible one.

The important thing is consistency.


40. Testing Resource APIs

Resource API tests should verify:

GET does not mutate state.
List pagination is stable.
Unknown filters are rejected or ignored consistently.
ETag/version behavior works.
PATCH cannot change forbidden fields.
Representation does not expose internal fields.
404 vs 403 behavior follows security policy.
Response schema remains backward-compatible.

Example test:

@Test
void getCaseDoesNotExposeWorkflowInternals() {
    webTestClient.get()
            .uri("/cases/C-100")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.workflowExecutionId").doesNotExist()
            .jsonPath("$.taskDefinitionKey").doesNotExist();
}

41. Testing Action APIs

Action API tests should verify:

valid transition succeeds.
invalid transition returns 409.
stale expectedVersion returns 409.
missing idempotency key is rejected.
same idempotency key returns same result.
same idempotency key with different body returns conflict.
side effects happen once.
audit event is written.
domain event is emitted once.
permission failure is mapped correctly.
unknown outcome can be retried safely.

Example test:

@Test
void approveReviewIsIdempotent() {
    var key = UUID.randomUUID().toString();

    var first = approveReview("C-100", key);
    var second = approveReview("C-100", key);

    assertThat(second.body()).isEqualTo(first.body());
    assertThat(auditRepository.countByCaseIdAndType("C-100", "REVIEW_APPROVED"))
            .isEqualTo(1);
}

42. Migration Example: Bad CRUD to Explicit Commands

Original API:

PATCH /cases/{caseId}

{
  "status": "APPROVED"
}

Problems:

Caller controls state.
No reason code.
No idempotency key.
No domain-specific metric.
Hard to distinguish approval from other updates.
Hard to audit.

Migration:

POST /cases/{caseId}:approve-review

Request:

{
  "reasonCode": "CHECKS_PASSED",
  "comment": "All checks completed.",
  "expectedVersion": 8
}

Plan:

1. Add new action endpoint.
2. Mark status PATCH path deprecated for lifecycle transitions.
3. Log warnings when PATCH changes status.
4. Migrate consumers.
5. Reject status changes via PATCH after sunset.
6. Keep PATCH only for safe mutable draft fields, if still needed.

This is how API shape improves without breaking everything immediately.


43. When to Reject Both Resource and Action

Sometimes the requested API should not exist.

Examples:

GET /cases/{id}/raw-database-row
POST /cases/{id}:skipValidation
POST /cases/{id}:forceWorkflowState
GET /cases/{id}/allInternalVariables
POST /cases/{id}:publishKafkaEvent

These requests usually indicate:

  • missing domain capability,
  • missing operational runbook,
  • poor ownership boundary,
  • consumer trying to bypass service invariants,
  • data/reporting requirement better served by read model,
  • integration trying to compensate for missing events.

Rejecting an endpoint is sometimes the best architecture work.


44. Resource + Action Reference Architecture

The architecture separates:

  • reads,
  • simple resource mutation,
  • domain commands,
  • long-running operations.

Do not force all four into one controller method style.


45. Production Checklist

For a resource API:

[ ] Resource has stable identity.
[ ] Representation hides internal model.
[ ] HTTP method semantics are correct.
[ ] Collection endpoints are paginated.
[ ] Updates protect against lost updates.
[ ] Partial updates cannot bypass invariants.
[ ] Delete semantics are explicit.
[ ] Response schema is backward-compatible.

For an action API:

[ ] Action name is domain-specific.
[ ] Target resource is clear.
[ ] Side effects are documented.
[ ] Preconditions are explicit.
[ ] Idempotency is defined.
[ ] Retry behavior is defined.
[ ] Domain failures map to stable error codes.
[ ] Audit behavior is defined.
[ ] Metrics identify the action.
[ ] Action does not expose implementation mechanics.

For both:

[ ] Owner is known.
[ ] Consumers are known or observable.
[ ] Contract is in OpenAPI.
[ ] Operational runbook exists for failures.
[ ] Deprecation strategy exists.

46. Summary

Resource APIs and action APIs are both valid.

The mistake is using the wrong shape for the wrong semantic.

Use resource APIs when the consumer interacts with identifiable things and their representations.

Use action APIs when the consumer requests a domain operation with rules, side effects, preconditions, audit meaning, and retry semantics.

Do not hide commands behind generic patch/update.

Do not turn every Java method into a remote action.

Do not expose database tables, workflow engine commands, or implementation mechanics.

The best API shape is the one that keeps business invariants inside the service while giving consumers a stable, observable, evolvable capability.

The next part will split HTTP APIs into command and query APIs, showing how different read/write semantics affect latency, consistency, retries, caching, and service ownership.

Lesson Recap

You just completed lesson 30 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.