Designing State Transitions
PATCH {status: "shipped"} makes the client the owner of the machine; POST /orders/{id}/ship makes the server own it. Command-style transitions carry data, enforce guards, and answer retries — at the cost of one endpoint per transition.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Who owns the machine?
A PATCH {"status": "shipped"} contract quietly hands the state machine to the client: the server can validate the edit, but the *vocabulary* of the API says "status is a field you write". Every rule — legal transitions, required accompanying data, triggered side effects — must then be expressed as validation errors on a generic write, which is where semantics go to die. POST /orders/{id}/ship inverts the ownership: the client requests a domain event; the server decides, enforces, and records.
The practical difference shows up in the inputs. Shipping is not a value change — it needs a carrier, a tracking number, and it should fail if the warehouse never confirmed stock. In the PATCH shape those inputs have nowhere natural to live: they become top-level fields that are meaningless except during one particular status write (tracking_number on a created order is noise). In the command shape they are the request body of /ship, required exactly when they are meaningful.
This is Resource or Action? applied to lifecycle: transitions that carry data or trigger side effects want explicit operations. The generic status edit survives only for machines so simple that no transition needs anything — and even then, the first race between two writers exposes what the shape cannot say.
1PATCH /orders/422{3 "status": "shipped",4 "tracking_number": "1Z999…", # meaningful only for this one write5 "carrier": "ups"6}7→ 200 OK8 9# What if stock was never reserved? A validation error on… which field?10# Retried after timeout: did it ship once? Is the tracking number mine?11# Two writers race: last write wins, silently.1POST /orders/42/ship2Idempotency-Key: 3ac8…3If-Match: "v7"4{5 "carrier": "ups",6 "tracking_number": "1Z999…"7}8→ 200 OK { "status": "shipped", "shipped_at": "…" }9→ 409 invalid_transition (not yet processing)10→ 412 precondition_failed (someone changed it first)11→ replayed 200 on retry (same idempotency key)The command shape is not verbier for its own sake — every line answers a question the PATCH shape leaves open: what shipping requires, what guards apply, what a retry does, and what happens when writers race. Those questions all get asked in production either way.
Transitions race, and the shape must answer
Lifecycle transitions are where concurrent writers collide by design: the customer cancels while the warehouse ships; the webhook confirms payment while a timeout job expires the order. A generic status edit resolves these races by last-write-wins, which is to say: silently, wrongly, and differently each time. The transition shape needs a concurrency answer as part of its contract.
Two composable mechanisms cover it. Preconditions (If-Match on a version — see Optimistic Concurrency: Versions and If-Match) let a caller say "ship this only if it is still the order I looked at"; the loser of the race gets 412 and refetches instead of overwriting (the The Lost Update, Step by Step failure, prevented at the contract level). Idempotency keys make retries safe: the warehouse's timeout-and-retry returns the recorded outcome of the first attempt instead of double-shipping (see Idempotency Keys: The Mechanism). Commands accommodate both naturally; a PATCH can carry If-Match too, but cannot distinguish "retry of my write" from "new conflicting write".
Decide also who wins each *legitimate* race, and write it down. If cancel and ship arrive together, the business — not the thread scheduler — should decide the winner. Often the answer is a guard ("cancellation is legal until processing") plus honest conflict reporting for the loser. The contract clause "one of these two callers will receive 409 with the winning state" is unglamorous and priceless.
The cost, and where the generic edit is honest
Command-style transitions cost surface: one route per transition, each with docs, auth rules and tests. An order machine with six transitions is six endpoints where PATCH was one. For machines with many symmetric transitions, a middle shape keeps the semantics without the route explosion: a single transition endpoint (POST /orders/{id}/transitions with {"to": "shipped", …}) that still centralizes guards, still takes per-transition data, still supports idempotency — at the cost of a less discoverable, less individually documentable surface.
The generic status edit remains honest at the bottom of the ladder: two or three states, no transition data, no side effects, one writer. A document's draft/published toggle edited only by its author does not need a command. The review question is the same as everywhere in this module: did the shape get *chosen* for this machine, or did it default? A default PATCH on a six-state, three-writer machine is a decision someone will make later, during an incident.
| Shape | Guards & inputs | Retry / race story | Choose when |
|---|---|---|---|
PATCH {status} | Validation on a generic write; inputs orphaned | None natural; last write wins | 2–3 states, single writer, no transition data or side effects |
POST /…/transitions {to} | Centralized; per-transition body validated by target | Idempotency key + If-Match supported | Many transitions, uniform machinery, surface economy matters |
POST /…/ship, /cancel, … | Explicit per operation; inputs required exactly where meaningful | Full: per-operation idempotency, preconditions, distinct errors | Few high-stakes transitions with distinct data, guards and side effects |
Key points
- The shape assigns ownership: PATCH makes status a client-writable field; commands make transitions server-owned domain events.
- Transition inputs (carrier, tracking number) belong to the operation, not as sometimes-meaningful fields on the resource.
- Transitions race by design; the contract needs preconditions (If-Match → 412) and idempotency (key → replay) as explicit clauses.
- Decide business-level race winners (cancel vs ship) in the contract, not in the thread scheduler.
- A single generic transitions endpoint is the middle shape when per-transition routes would explode; bare PATCH is honest only for trivial single-writer machines.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: exposes
statusas a writable field because update already exists; transition rules live in validation code. - 2Warehouse system → API: writes
shippedwith tracking data stuffed into resource-level fields. - 3Network → warehouse: the write times out; the retry writes
shippedagain, firing the tracking email twice. - 4Customer → API: cancels concurrently; last write wins and a shipped order becomes
cancelledwith no refund logic triggered. - 5Team → incident review: adds ad-hoc guards to the PATCH handler; the contract still says "status is a field", and the next consumer trips the same wire.
- Races resolve by write order, so business invariants (no cancelling shipped orders) hold only by luck.
- Retries double-fire side effects because a repeated field write is indistinguishable from a new one.
- Transition-specific data pollutes the resource schema, confusing every consumer that reads fields outside their meaningful window.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Give every non-trivial transition an explicit operation carrying its inputs, guards and documented errors (409, 412).
- • Support idempotency keys on transitions with side effects, and If-Match preconditions where callers act on observed state.
- • Specify the winner of each legitimate race as a contract clause; give the loser a conflict payload naming the winning state.
- • Collapse to a single transitions endpoint when route count hurts — but never back to a bare writable status on multi-writer machines.
- • Duplicate side effects (double tracking emails, double refunds) trace back to retried anonymous status writes.
- • Track 409/412 rates per transition and caller: healthy machines show low, explainable conflict rates; silence plus incident reports means last-write-wins is eating conflicts.
- • Field-level write telemetry showing `tracking_number` written alongside every conceivable status is the orphaned-input smell.
- • New transitions ship as new operations without touching existing ones — command surfaces grow additively where a PATCH's validation matrix grows combinatorially.
- • Migrating from writable status to commands: accept both during a window, log writable-status callers, move them, then reject direct writes with a pointer to the operations (see [[api-migration]]).
- • Guards can tighten within a version only with notice — a transition that starts requiring stock confirmation breaks warehouses that never sent it (see [[backward-compatibility]]).
- • One route per transition multiplies surface: auth, docs, tests and SDK methods for each. The transitions-endpoint middle shape trades discoverability for economy.
- • Server-owned transitions concentrate logic behind opaque operations; consumers must trust docs for side effects they can no longer infer from a field write.
- • Precondition-and-key machinery asks more of every client (version tracking, key generation) — trivial writers pay it too unless you tier the requirements.