Resourcescrudanti-patternbehaviordomain operations

The "Everything Is CRUD" Trap

CRUD describes storage, not behavior. Payments, approvals, workflows and agent runs have states, guards and side effects that create/read/update/delete cannot say — flattening them into updates hides exactly the semantics consumers must know.

Follow the failure

Frame the contract

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

Design question
Does create/read/update/delete actually describe what this domain does — or is UPDATE about to become a trapdoor for every behavior the contract refuses to name?
Consumers
Clients of behavioral domains: the checkout calling a payment API, the approval tool advancing a workflow, the orchestrator driving an agent run — each needing to know what an operation *does*, not which row it touches.
The promise
Operations are named for their domain meaning, with their guards, side effects and failure modes stated — instead of a uniform update whose consequences depend on which fields changed.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

What UPDATE cannot say

CRUD is a storage vocabulary, and for storage-shaped domains it is honest: a contact book, a tag list, a settings page really are collections of records that get created, read, edited and removed. The trap is inheriting the vocabulary for domains that are *processes*. A payment is not a row you edit; it is a thing that gets authorized, captured, refunded, disputed — each step with different preconditions, different side effects, different failure modes, different permissions.

Flatten those into PATCH /payments/{id} and every distinction collapses into "which fields did you write". Writing {"status": "captured"} moves money; writing {"status": "refunded"} moves it back; writing {"amount": 500} — is that legal after capture? The contract cannot say, because its only verb is "update". Validation code can enforce the rules, but consumers cannot *see* them: the shape claims all writes are alike when almost none are (this is the flattening path from Resource or Action? applied domain-wide).

The tell is in the requirements language. Product never says "update the payment" — it says capture, refund, void, dispute. When the API's vocabulary is poorer than the requirement's, every conversation between consumer and provider runs through a translation layer, and the translation is where the bugs live.

  • Preconditions — refund requires a captured payment; UPDATE has no place to say so except a generic 400.
  • Side effects — capture moves money, sends receipts, triggers webhooks; a field write announces none of this.
  • Distinct permissions — support may refund but not capture; field-level authorization on a PATCH is a policy engine nobody can read.
  • Distinct failure modes — a refund can fail at the processor days later; a row update has nowhere to be asynchronous.
  • Auditability — "who refunded this and why" needs the refund to be a thing, not a diff between two row versions.

A worked flattening, and its repair

The comparison below is the same domain twice. The CRUD shape has three endpoints and looks admirably small; the behavioral shape has more routes and *is* smaller — smaller to understand, because each operation carries its own contract, and smaller to operate, because retries, permissions and audit fall out of the shape (each refund is an addressable resource with an idempotency story — see Idempotency Keys: The Mechanism).

Note what the repair did not do: it did not abandon resources or invent RPC soup. The payment is still a resource you GET; refunds are still resources you list. The change is that behavior got names. This is the middle path between the verb-explosion and CRUD-flattening ditches — the same middle From Domain to Resources and Resource or Action? aim for.

The whole payments domain as one writable row
1POST /payments # create… meaning authorize? or capture?
2GET /payments/{id}
3PATCH /payments/{id} # { "status": "captured" } → moves money
4 # { "status": "refunded" } → moves it back
5 # { "amount": 500 } → legal when?
6
7# permissions, preconditions and side effects all
8# depend on WHICH fields are in the diff
Behavior named; storage still resource-shaped
1POST /payments # authorize (funds held)
2POST /payments/{id}/capture # move the money; idempotency key
3POST /payments/{id}/refunds # { "amount": 500, "reason": … } → 201 refund
4GET /payments/{id}/refunds/{rid} # its own status: pending → succeeded
5POST /payments/{id}/void # release the hold
6GET /payments/{id} # state machine visible: authorized → captured

Each operation now states what it requires, what it does and how it fails — and partial refunds, refund status tracking and per-operation permissions arrived without redesign. The CRUD shape would have absorbed each of those as another undocumented meaning of PATCH.

Where CRUD is right, and how to hold the line

The inverse mistake is real: ceremonial command endpoints for a rename (POST /projects/{id}/rename-operations) are cargo cult in the other direction. CRUD earns its keep wherever the domain truly is record-keeping: profile fields, labels, saved filters, address books. The test is behavioral: if an update has no preconditions beyond authorization, no side effects beyond persistence, and no failure modes beyond validation — it is an update, and PATCH tells the whole truth.

Holding the line takes a review habit, because CRUD flattening is the path of least resistance: the framework generates CRUD, the first demo needs only CRUD, and each behavioral requirement afterwards is one more field on the PATCH away. The cheap defense is asking, per resource, "what are this thing's verbs in the product spec?" — and requiring the contract's vocabulary to match. Workflows, approvals, payments and Resources Have State Machines-shaped lifecycles will fail the CRUD test immediately; let them.

The behavioral test, per domain
DomainProduct vocabularyCRUD honest?
User profile, settingsedit, change, setYes — updates with no guards or side effects
Paymentsauthorize, capture, refund, void, disputeNo — every verb has guards, money movement, distinct permissions
Approvals / workflowssubmit, approve, reject, escalate, recallNo — transitions with actors and notification side effects
Ordersplace, pay, ship, cancel, returnNo — a state machine wearing a noun
Agent runsstart, pause, resume, cancel, retryNo — long-running lifecycle with progress and partial results
Tags, labels, bookmarksadd, remove, renameYes — pure record-keeping

Key points

  • CRUD describes storage; behavioral domains (payments, approvals, workflows, agent runs) need their operations named, guarded and typed.
  • A generic UPDATE hides preconditions, side effects, permissions and failure modes behind "which fields changed" — enforced maybe, visible never.
  • When the API's vocabulary is poorer than the product's, every integration runs through a lossy translation layer.
  • The repair keeps resources and adds named operations — it is not a retreat to RPC soup.
  • CRUD remains the honest shape for genuine record-keeping; the behavioral test (guards? side effects? distinct permissions?) draws the line per resource.

Follow the failure

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

  1. 1
    Team → API: scaffolds CRUD for every entity because the framework generates it in an afternoon.
  2. 2
    Product → API: each new behavior ("support can refund", "capture on shipment") ships as another meaningful field combination on PATCH.
  3. 3
    Consumers → API: learn the field combinations from a wiki page and each other; the real contract is folklore.
  4. 4
    Client retry → payments: a timed-out PATCH {"status":"captured"} is retried; nothing distinguishes replay from new intent; money moves twice.
  5. 5
    Auditor → team: "list all refunds over $1000 with initiator" requires reconstructing intent from row diffs; the audit takes a quarter.
What breaks
  • Money and side effects fire on retried or racing field writes that no shape could make safe.
  • Permissions become field-diff policy that neither security review nor consumers can reason about.
  • The domain's actual rules live in validation code and folklore, so every new consumer re-learns them through production errors.

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
  • • Require the contract's verbs to match the product spec's verbs; reject PATCHes whose meaning depends on the field diff.
  • • Model behavioral operations as named actions or action-resources with preconditions, side effects and failure modes documented per operation.
  • • Give money-moving and side-effecting operations idempotency keys and their own audit-friendly identity (see [[idempotency-keys]]).
  • • Let genuine record-keeping stay CRUD — write "no guards, no side effects" in the resource docs as the justification.
Observe in production
  • • Validation code switching on which fields are present in a PATCH is the flattening measured in code.
  • • Support and audit queries that reconstruct intent from change history reveal operations that were never addressable.
  • • Per-field authorization rules accumulating on one endpoint show distinct permissions crammed into one verb.
Evolve without breaking
  • • Un-flattening is additive: introduce named operations beside the PATCH, deprecate the meaningful field combinations, migrate with telemetry (see [[api-migration]]).
  • • Named operations absorb new requirements as parameters (partial refund amount, refund reason) where the PATCH would grow new folklore.
  • • New behaviors arrive as new operations without disturbing existing ones — CRUD surfaces instead accrete meaning onto the same verb.
What it costs
  • • More routes: named operations multiply endpoints, docs, SDK methods and tests versus one generated PATCH.
  • • Framework friction: scaffolding assumes CRUD; behavioral surfaces are hand-built and reviewed.
  • • Judgment surface: "is this behavioral?" is a per-resource argument, where "everything is CRUD" required no thought — consistency needs an owner (see [[api-design-principles]]).

Misconceptions

Claim
“CRUD keeps the API simple and uniform.”
Reality
It keeps the *route table* small. The complexity of guards, side effects and permissions does not disappear — it moves into field-diff validation and wiki folklore, which is the most expensive place it can live.
Claim
“We can enforce the business rules server-side, so the shape does not matter.”
Reality
Enforcement protects the server; the shape informs the consumer. A contract whose visible semantics are "write any field" with invisible rules underneath fails consumers on every retry, race and integration — correctly enforced and unusable are compatible.
Claim
“Named operations are just RPC — we would lose REST's benefits.”
Reality
Operations on addressable resources keep cacheable reads, uniform errors and evolvable representations. What they add is truthful write semantics. The all-CRUD alternative loses more of REST's substance by making every write mean anything. See The "REST Purity" Anti-Pattern.

Apply it