Resourcestransitionscommandspatchshiplifecycle

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Should clients edit the state field directly, or request transitions through operations the server owns?
Consumers
The systems that drive a resource through its lifecycle: warehouse software shipping orders, payment webhooks confirming payment, support tools issuing overrides — each needing to know what a transition requires and what happens when two of them race.
The promise
Each transition has one shape that states its inputs, its guards, its side effects and its retry behavior — instead of a generic status edit whose meaning depends on which value is written.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

Generic edit: inputs orphaned, guards invisible
1PATCH /orders/42
2{
3 "status": "shipped",
4 "tracking_number": "1Z999…", # meaningful only for this one write
5 "carrier": "ups"
6}
7200 OK
8
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.
Command: inputs, guards and retries have a home
1POST /orders/42/ship
2Idempotency-Key: 3ac8
3If-Match: "v7"
4{
5 "carrier": "ups",
6 "tracking_number": "1Z999…"
7}
8200 OK { "status": "shipped", "shipped_at": "…" }
9409 invalid_transition (not yet processing)
10412 precondition_failed (someone changed it first)
11replayed 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.

Three shapes for transitions, and what selects each
ShapeGuards & inputsRetry / race storyChoose when
PATCH {status}Validation on a generic write; inputs orphanedNone natural; last write wins2–3 states, single writer, no transition data or side effects
POST /…/transitions {to}Centralized; per-transition body validated by targetIdempotency key + If-Match supportedMany transitions, uniform machinery, surface economy matters
POST /…/ship, /cancel, …Explicit per operation; inputs required exactly where meaningfulFull: per-operation idempotency, preconditions, distinct errorsFew 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.

  1. 1
    Team → contract: exposes status as a writable field because update already exists; transition rules live in validation code.
  2. 2
    Warehouse system → API: writes shipped with tracking data stuffed into resource-level fields.
  3. 3
    Network → warehouse: the write times out; the retry writes shipped again, firing the tracking email twice.
  4. 4
    Customer → API: cancels concurrently; last write wins and a shipped order becomes cancelled with no refund logic triggered.
  5. 5
    Team → 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.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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.

Misconceptions

Claim
“PATCH on status plus server-side validation is equivalent to commands.”
Reality
Validation can enforce legality but cannot house per-transition inputs, distinguish retries from new writes, or express who wins a race. Equivalent enforcement is not equivalent contract — consumers can only rely on what the shape says.
Claim
“Command endpoints are RPC sneaking into REST.”
Reality
They are operations with explicit semantics on an addressable resource — the property that matters for retries, caching and evolution. The REST-purity objection trades those operational properties for a naming aesthetic. See The "REST Purity" Anti-Pattern.

Apply it