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
cmdUuidandcmdSourceRef. 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.
cmdSourceReflinks 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
GETshape 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.