Resourcesactionscommandsresourcesoperationscancellation

Resource or Action?

POST /cancelOrder, POST /orders/{id}/cancellations, PATCH {status: "cancelled"} — three shapes for one operation, each promising something different. Actions with their own data and lifecycle are domain concepts worth modeling; the rest can stay verbs or field updates.

Follow the failure

Frame the contract

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

Design question
Is this operation a state edit, a command, or a domain concept with its own data — and which shape tells consumers the truth about it?
Consumers
Clients performing domain operations that do not decompose into create/read/update/delete: cancel an order, approve a request, retry a job, merge two accounts — and needing to know afterwards what happened and why.
The promise
The operation's shape matches its semantics: consumers can tell what it does, whether it can be repeated, what record it leaves, and where to look for its outcome.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Three shapes, three different promises

Take "cancel an order". PATCH /orders/{id} with {"status": "cancelled"} says: this is a field edit, and the client owns the transition. POST /orders/{id}/cancel says: this is a command the server interprets — the client requests, the server decides. POST /orders/{id}/cancellations says: cancellation is a *thing* — it has data (reason, initiator, refund amount), it can be looked up later, there might someday be more than one attempt.

None of these is the correct answer; they answer different questions about the domain. The failure mode is choosing by aesthetics — "PATCH is more RESTful", "verbs are forbidden" — instead of by what cancellation actually *is* in this business. A dogmatic answer here is exactly the trap The "REST Purity" Anti-Pattern warns about, in both directions.

The discriminating questions: Does the operation carry its own data beyond the new state? Does anyone need to reference it later ("show me the cancellation, who did it, was the refund issued")? Can it fail halfway or take time? Can it happen more than once? Each "yes" pushes toward modeling the action as a resource; all "no" means a state edit or a simple command verb is honest and cheaper.

One operation, three contracts
ShapeWhat it promisesHonest whenLies when
PATCH /orders/{id} {"status":"cancelled"}A field edit the client controls; no extra semanticsThe transition is trivial, synchronous, carries no data, leaves no record worth addressingCancellation triggers refunds, emails and inventory release the shape never mentions
POST /orders/{id}/cancelA command; the server owns the decision and side effectsThe operation is atomic, unrepeatable, and nobody references it afterwardsConsumers later ask "why was it cancelled, by whom, where is the refund?" and there is nothing to point at
POST /orders/{id}/cancellationsCancellation is a domain entity with data, identity and possibly a lifecycle of its ownThe action has a reason, an actor, an outcome to track, or can be pending/failedUsed for trivial toggles — ceremony without a requirement behind it

When the action is a domain concept

Real cancellations are rarely field flips. They carry a reason code the support team reports on; they trigger a refund that can fail; they may need approval above a threshold; the customer emails three weeks later asking about "the cancellation". Every one of those is a consumer task pointing at the cancellation itself — which is the referential test from From Domain to Resources returning a "yes".

Modeling the action as a resource also solves problems the other shapes cannot. Retryability: POST /orders/42/cancellations with an idempotency key returns the same cancellation on retry (see Idempotency Keys: The Mechanism); a bare PATCH retried after a timeout gives no way to ask "did my cancellation go through, and was it *mine*?". Asynchrony: a cancellation that takes time is a resource with a status the client polls — the The Async Job Pattern falls out for free. History: "this order was cancelled, un-cancelled by support, cancelled again" is three rows, not one overwritten field.

The transition hides everything the domain cares about
1PATCH /orders/42
2{ "status": "cancelled" }
3200 OK
4
5# Where does the reason go? An overloaded field.
6# Refund failed at the processor — but the PATCH already said 200.
7# Support asks "who cancelled this?" — no record exists.
8# Client retries after timeout — did it apply once? Twice? No way to ask.
Cancellation as a first-class domain entity
1POST /orders/42/cancellations
2Idempotency-Key: 7f9c
3{ "reason": "customer_request", "restock": true }
4201 Created
5{
6 "id": "can_81x",
7 "status": "refund_pending",
8 "initiated_by": "usr_7",
9 "refund": { "amount": 1999, "status": "processing" }
10}
11
12GET /orders/42/cancellations/can_81x # support, three weeks later

The resource shape did not add complexity — it gave the complexity that already existed a place to live. Reason, actor, refund state and retryability were all requirements; the PATCH shape just refused to acknowledge them.

Keeping both ditches in view

The failure on one side is verb explosion: /cancelOrder, /uncancelOrder, /cancelOrderWithRefund — operations minted ad hoc, nothing addressable, every variation a new endpoint (the phrasebook problem from From Domain to Resources). The failure on the other side is CRUD flattening: every domain operation disguised as a field update, semantics smuggled through status values, side effects undocumented (the The "Everything Is CRUD" Trap trap).

Between them sits a defensible middle: model actions as resources when they carry data or need addressing; use a command-style sub-path (POST /orders/{id}/ship — see Designing State Transitions) when the operation is atomic but server-owned; allow plain field edits for attributes that really are just attributes. What keeps the middle honest is writing down *which question each operation answered* — the next designer extends a reasoned pattern instead of guessing which of your three shapes was the precedent.

One pragmatic note on naming: POST /orders/{id}/cancellations reads oddly to teams expecting verbs, and POST /orders/{id}/cancel offends noun purists. Both objections are aesthetic. The operational differences — addressability, retry story, history — are real. Spend the review time there.

  • Carries data beyond the new state (reason, amount, actor) → action-as-resource.
  • Referenced later by support, audits, webhooks → action-as-resource.
  • Asynchronous or can fail midway → action-as-resource with a status (see Long-Running Operations: 202 and the Job Resource).
  • Atomic, server-owned, unaddressed afterwards → command verb on a sub-path.
  • A genuine attribute edit (rename, toggle a flag) → PATCH and stop there.

Key points

  • PATCH-a-status, command verb, and action-as-resource are three different promises — choose by the operation's semantics, not by REST aesthetics.
  • Actions that carry data, need referencing, take time, or can repeat are domain concepts deserving resource modeling.
  • Action resources give you retry semantics, async status and history for free; field flips give you none of them.
  • Verb explosion and CRUD flattening are the two ditches; the middle is held by writing down which question each shape answered.
  • The naming debate (verb vs noun) is aesthetic; the addressability and retry differences are operational. Review the operational ones.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: models cancellation as PATCH {status} because "we already have update".
  2. 2
    Product → requirement: cancellations need a reason and trigger refunds; reason is stuffed into a nullable column, refund into a side effect.
  3. 3
    Client → API: PATCH times out; client retries; no way exists to ask whether the first attempt applied or whose cancellation won.
  4. 4
    Refund processor → order: refund fails after the PATCH returned 200; the order says cancelled, the customer has no money back, no resource records the discrepancy.
  5. 5
    Support → team: "who cancelled order 42 and where is the refund?" becomes a log-diving exercise instead of a GET.
What breaks
  • Retried transitions double-fire side effects (refunds, emails, restocks) because nothing deduplicates an anonymous field edit.
  • Outcome tracking is impossible: the operation succeeded as an HTTP call but failed as a business process, and the contract has no place to say so.
  • Audit and support workflows fall back to log archaeology because the domain event was never addressable.

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
  • • Run the four discriminating questions (own data? referenced later? async/failable? repeatable?) in review before choosing the shape.
  • • Model qualifying actions as sub-resources with ids, status and idempotency-key support.
  • • Reserve `PATCH` on status fields for transitions with no data, no side effects and no audit need — and say so in the field docs.
  • • Keep one convention per API for command verbs vs action resources, recorded where reviewers look (see [[naming-and-consistency]]).
Observe in production
  • • Overloaded `status` fields accumulating adjacent columns (`cancel_reason`, `cancelled_by`, `refund_state`) show an action wanting to be a resource.
  • • Duplicate side effects after client retries (two refund attempts, two emails) reveal an unaddressable, non-idempotent transition.
  • • Support tooling screen-scraping logs for "who did X" is the missing-resource smell at the org level.
Evolve without breaking
  • • A `PATCH`-shaped transition can be upgraded additively: introduce the action resource, keep accepting the field edit as a deprecated alias, migrate consumers with telemetry (see [[api-migration]]).
  • • Action resources absorb new requirements as fields (approval, partial refunds, scheduled cancellation) where a command verb would need new endpoints.
  • • If an action resource never grows past a bare `POST` with empty body, it can be honestly simplified to a command verb in the next version — ceremony is also a cost to walk back.
What it costs
  • • Action resources are more surface: ids to mint, GETs to serve, retention to decide. A trivial toggle does not earn that.
  • • Server-owned commands centralize logic but hide it: consumers cannot predict side effects from the shape alone and must read docs.
  • • Supporting a deprecated field-edit alias during migration means two write paths to keep consistent for the whole window.

Misconceptions

Claim
“Verbs in URLs are wrong; everything must be a noun.”
Reality
The noun rule is a heuristic for addressability, not a law. POST /orders/{id}/cancel is a fine contract for an atomic, server-owned, never-referenced operation. What is wrong is choosing the shape by grammar instead of by the operation's data, retry and audit needs.
Claim
“Modeling actions as resources is over-engineering.”
Reality
It is exactly as much engineering as the requirement demands. If cancellation has a reason, an actor and a refund, the complexity exists either way — the resource shape gives it an address; the PATCH shape scatters it across columns and logs.
Claim
“PATCH on a status field is simpler for clients.”
Reality
Simpler to call, harder to live with: no retry answer, no outcome to poll, no record to reference. Client simplicity that disappears at the first timeout is not simplicity.