Action-oriented APIs

Action-oriented APIs

In a typical REST API, the HTTP method carries all the meaning: POST creates, PUT updates, DELETE removes. The request body is an anonymous JSON payload shaped like the resource.

With commands, the request body is the action — a named, typed object that describes what the user intends to do.

Two URL styles, same command model

Plain CRUD operations stay on standard REST verbs:

POST   /invoices            CreateInvoiceCommand
PUT    /invoices/{id}       UpdateInvoiceCommand
DELETE /invoices/{id}       DeleteInvoiceCommand

Anything beyond create / update / delete is a command verb — the term from ADR-007 — and uses the /:action form on POST, PUT, or DELETE:

POST   /invoices/:approve            ApproveInvoiceCommand
POST   /invoices/:send               SendInvoiceCommand
POST   /reports/:generate            GenerateReportCommand
DELETE /invoices/:cancel             CancelInvoiceCommand

The colon prefix marks the segment as a verb, not an id, so the route reads as the action it performs. Command verbs are not available on GET, HEAD, or PATCH.

For nested resources, identifiers in the path can be skipped with - when not needed for the operation:

GET  /accounts/{accountNo}/transactions
GET  /accounts/-/transactions?amount=gt:1000
POST /accounts/-/transactions/:reconcile

See ADR-007 — HTTP resource URIs for the full URL conventions, including casing rules (kebab-case URIs, camelCase query values and JSON bodies) and pagination.

The body is the command

The request body carries the action’s intent — the inputs that describe what should happen. Everything that the server already knows (who is calling, when "now" is, the tenant) does not belong in the body.

POST /invoices/:approve
Content-Type: application/json

{
  "cmdUuid": "9d6a1e2b-…",
  "id": "inv_01HXYZ…",
  "comment": "Approved after Q2 reconciliation"
}

approvedAt is set by the server to the request time. approvedBy is resolved from the authenticated principal. Putting either in the body would let the caller lie about them — and would clutter the command’s contract with values that aren’t really inputs.

What this gives you

Named operations

The command log says ApproveInvoiceCommand, not "POST to /invoices/:approve". You can query, monitor, and analyze by operation name.

Consistent contract

Every command carries cmdUuid and cmdSourceRef. The structure is flat and form-friendly — it doesn’t need to mirror the domain model.

Works beyond HTTP

The same command class can be executed from a controller, a background job, a message handler, or a retry mechanism.

Composable

One command can trigger others. cmdSourceRef links them — you can trace how a single user action propagated through the system.

Decoupled from the read representation

A command’s shape doesn’t have to mirror the GET shape of the same resource. Read representations can change — different shapes for different contexts, different domains, different API versions — without the command (write) contract changing. See Decoupled writes and reads for the longer argument.

When to use which

Use plain REST verbs for the basic lifecycle: create, update, delete. Reach for a /:action route as soon as the operation has a name beyond "save" — approve, send, credit, post, reverse, generate, recalculate. A good test: if you’d describe the operation to a colleague with a verb other than "update", it’s a command verb and deserves its own route.