PatternsPARADIGM-SPECIFICDOMAIN-SPECIFICLIFETIME-SPECIFIC

Command

An action turned into a value. Pointless if you only intend to call it — and genuinely load-bearing the moment you need to queue, retry, audit, schedule or undo it.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

When is it worth representing "do this" as data rather than just doing it?

The requirement

Bulk edits in the admin tool must be previewable before they run, undoable for ten minutes after, retried if the worker dies mid-batch, and recorded for compliance with who asked for what.

The obvious build

Call the function. bulkUpdatePrices(ids, newPrice) does the work; logging can be added inside it and undo can be a second function that reverses it.

Why it breaks

Preview requires knowing what *would* happen, which means either running it or duplicating its logic in a second function that immediately diverges (Duplicate Knowledge).

How it breaks as requirements change
  • Preview requires knowing what *would* happen, which means either running it or duplicating its logic in a second function that immediately diverges (Duplicate Knowledge).
  • Undo written as a reverse function has to reconstruct the previous state from nowhere. Without the request as data, "what did they ask for" is only in a log line.
  • Resuming a half-applied batch is impossible: the worker died holding the only description of what it was doing (Partial Failure).
  • The audit record is written by the function that does the work, so a failure before the write leaves an action with no trace — the one case compliance actually cares about.
  • Every new capability — scheduling, approval workflow, rate limiting bulk operations — is another parameter on the same function, because there is no thing to attach them to (Long Parameter List).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Compliance requires an immutable record of the intended action, separate from its outcome (Agent Audit Logs).
  • A worker can die at any point, so a half-applied batch must be resumable (Idempotency by Design).
  • Undo has to work after the request that started it has ended, so it cannot rely on anything in memory.
Invariants

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The command owns the *intent*: what was asked, by whom, with which parameters, under which idempotency key. It owns no execution.
  • A handler owns executing one command kind, and is the only thing that knows how (Polymorphism).
  • Infrastructure — queue, audit, retry, scheduler — owns commands generically and knows nothing about any particular kind (Job Queues).
Boundaries
  • The boundary is serialisability. A command must survive a process restart, which forces it to contain everything needed and to reference nothing live (Unsafe Deserialization).
  • Intent and outcome are separate records on purpose: the request is immutable, the result is appended. Merging them is how audit trails become unreliable (Explicit State).
  • The handler boundary keeps generic infrastructure ignorant of domain kinds, which is what lets one queue carry twelve command types (Interface Versus Implementation).

The action as a value

The whole pattern is the first type below. Everything else — queueing, preview, undo, audit — follows from the fact that the request is data that outlives the process that made it.

The discipline that makes it work is that the command holds no references to anything live. If it cannot be written to a table and read back tomorrow, none of the capabilities are actually available, whatever the class diagram says.

Intent, separate from execution
1type BulkPriceUpdate = {
2 kind: 'catalog.bulkPriceUpdate'
3 version: 1
4 attemptId: Uuid // idempotency key travels with the intent
5 actor: UserId // compliance needs this, not a log line
6 issuedAt: Instant
7 productIds: ProductId[]
8 newPrice: Money
9}
10
11// One handler per kind; infrastructure knows none of them.
12const handlers: Record<CommandKind, Handler> = {
13 'catalog.bulkPriceUpdate': applyBulkPriceUpdate,
14}
15
16// persist -> preview -> execute -> record outcome
17await commands.store(cmd) // now it survives a crash
18const preview = await handlers[cmd.kind].preview(cmd)
19if (approved) await dispatcher.run(cmd) // retried by attemptId

The command carries no service references and no open transaction. That single constraint is what turns it from an object into something a queue, an auditor and a scheduler can all use without knowing what it means.

What the envelope actually buys

Read this as a checklist rather than a description. If none of the left column is a requirement, the pattern buys nothing and the honest answer is to call the function.

Note the last row. Preview is the capability people forget they wanted, and it is the one that is nearly impossible to retrofit without duplicating the logic you are previewing.

CapabilityWhy a plain call cannot do itWhat it costs
Queue / deferA function call cannot outlive its process; a value can be stored and picked up by a workerA persisted schema with migration obligations (Job Queues)
Retry after crashResuming needs a description of what was being done, which died with the stackEvery handler must be idempotent per attempt id (Idempotency in Backends)
AuditA parallel log call can drift from what actually ran; the command *is* what ranStorage and retention policy for intent records (Sensitive State)
UndoReversal needs the original request, not just the resulting stateCompensation is a new action and can itself fail (Saga Pattern)
Schedule / approveA pending action must exist somewhere between request and executionA lifecycle to model, with its own states and timeouts (State Machines)
PreviewShowing what would happen means running the handler in a mode where it does not commitHandlers must separate decision from effect, which is a real design constraint (Functional Core, Imperative Shell)

Priced against just calling the function

The comparison is deliberately unkind to the pattern in the first change and decisive in the second. That is the shape of the decision: commands cost you on every ordinary operation and pay you back only where a capability from the matrix above is genuinely required.

Two requests, six weeks apart
The change

First: "bulk price updates need an audit record". Second: "and they must be previewable, undoable for ten minutes, and resume if the worker dies".

A function that does the work
catalog/bulkUpdate (audit call added inline)
testsbulk_update_test
1 module · 1 test file

The first change is genuinely cheap here — one line, one file. This is the case where commands would have been over-engineering, and it is the most common case.

Commands persisted, handlers registered, generic dispatcher
commands/envelopecommands/dispatchercatalog/handlers/bulkPriceUpdateadmin/previewadmin/undo
testsdispatcher_testhandler_testroundtrip_testidempotency_test
5 modules · 4 test files

The second change costs almost nothing on top: preview, undo and resume are all reads and writes of something that already exists. Under the plain function, the second change is a rewrite of the first.

what it cost Every bulk operation now goes through an envelope, a table and a dispatcher, including the twelve that will never be previewed or undone — a permanent tax on the ordinary path for a capability only some operations need. The persisted command shape is also a schema with migration obligations that outlive the feature: a command type deleted from the code must keep deserialising while any stored instance remains. And undo is compensation rather than true reversal, so it can fail, leaving a state that neither the original request nor the undo describes (The Lost Update, Step by Step).

How to build it

Most important first.

  • Make the command a plain immutable value: kind, parameters, actor, issued-at, idempotency key. No behaviour on it at all in most languages (Value Objects).
  • Give each kind exactly one handler and resolve them from a table, so adding a command kind is a new type and one registration (Strategy).
  • Persist before executing. A command that exists only in memory cannot be resumed, audited or retried — persistence is what buys every capability in the requirement.
  • Model undo as a compensating command rather than a reversal function, so it is itself queued, audited and retryable like everything else (Saga Pattern).
  • Carry the idempotency key inside the command, so a retry of the same command is provably the same attempt (Idempotency Keys: The Mechanism).
  • Do not do this for actions that are only ever called. If nothing queues, audits, retries or undoes it, a command object is a function with worse ergonomics (Pattern Overuse).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Named change — "add an approval step for bulk edits over a thousand rows": with commands, one state on the envelope and a check in the dispatcher; the handlers are untouched. Without, every bulk operation needs the check threaded through it.
  • Named change — "make bulk edits schedulable for overnight": with commands, the scheduler already has something to store; without, it needs a bespoke table describing the intended action, which is a command with a worse name.
  • Named change — "record who did what for compliance": with commands, the record is the command; without, it is a parallel logging call that can drift from what actually ran (Duplicate Knowledge).
  • Named change — "rename a parameter": more expensive, because queued and stored commands exist in the old shape and must keep deserialising (Expand and Contract).
  • If none of queue, audit, retry, undo or schedule is in the requirement, the pattern makes *nothing* cheaper and adds an envelope, a handler table and a dispatch hop.
What the recommended approach costs
  • Calling a function becomes constructing, dispatching and resolving. That indirection is charged on every operation, including the ones that never needed it.
  • Persisted commands are a schema you now own, with migration obligations, for as long as any are stored.
  • Undo via compensation is not undo. It is a new action that attempts to restore an old state, and it can fail or produce a third state.

What can go wrong

Failure modes
  • A command references live objects — an open transaction, a request-scoped service — and cannot be serialised, so the queue silently becomes in-memory only (Hidden Global State).
  • Handler and command drift: the command's shape changes, and queued commands from the old shape fail to deserialize on deploy. Versioning a persisted command is a schema migration (Data Migration).
  • Undo assumes the world has not moved. Compensating a price change after someone else edited the same product produces a state nobody asked for (Optimistic Concurrency: Versions and If-Match).
  • The mitigation fails too: making every action a command produces a codebase where calling a function requires constructing an envelope, and simple synchronous operations get slower and harder to read (Pattern Overuse).
Dependencies, and their direction
  • The command depends on nothing — that is what makes it storable. A command holding a service reference is not a command, it is a closure with paperwork.
  • Handlers depend on the domain; infrastructure depends only on the command envelope. The dependency graph is deliberately shallow at the generic layer (Dependency Direction).
  • Callers depend on the command type and the dispatcher, not on any handler, which is what lets the same command be executed now, later or never.
Misreads
  • "Command means CQRS." CQRS is a read/write model split at the architectural level. A command object is a value; you can have either without the other (CQRS).
  • "Commands make code more decoupled." They make actions *storable*. Decoupling is a side effect, and if storability is not needed then neither is the pattern (Patterns as Vocabulary).
  • "With first-class functions this is just a closure." Almost — and the difference is decisive: a closure cannot be serialised, inspected, audited or shown in a preview. The moment those matter, data beats a function (Strategy).
  • "Undo is easy once you have commands." Commands make undo *expressible*. Whether it is correct depends on whether the world moved underneath you, which is a concurrency question the pattern does not answer (The Lost Update, Step by Step).
Smells this explains
  • long-parameter-list
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Round-trip every command type through serialisation, because storability is the property the whole design rests on (Property-Based Testing).
  • Test handlers as pure-ish functions of command plus dependencies; the dispatcher is tested once, generically.
  • Test idempotency directly: apply the same command twice with the same key and assert one effect (Idempotency in Backends).
  • Test that a compensating command actually restores the prior state under concurrent modification, since that is where undo really fails.
How this design ages
  • Command sets grow and old command types must keep deserialising for as long as anything is stored — this is the property that turns a code decision into a data-compatibility obligation (Backward Compatibility as a Constraint).
  • The dispatcher accretes generic concerns: retry, then rate limiting, then approval, then tenancy. That accretion is healthy up to the point where it needs to know about specific kinds (God Object).
  • The natural end state is a small internal workflow engine, at which point it is worth asking whether an existing one would do (Build, Library, SaaS or Managed Service).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • PARADIGM-SPECIFICWith first-class functions the *behavioural* half of Command is a closure and disappears entirely. What does not disappear is the data half: a closure cannot be persisted, previewed or audited, so in a functional language this pattern survives as a serialisable value plus an interpreter, which is how Elm and Redux express it and why they call it a message or an action.
  • DOMAIN-SPECIFICLoad-bearing in admin tools, editors, financial operations and job systems, where undo, audit and scheduling are explicit requirements. In a request-response CRUD service where every action is executed immediately and never replayed, it is pure overhead and should not be reached for.
  • LIFETIME-SPECIFICPersisted commands are a data format with a compatibility obligation that outlives the code. For a system running months this is a minor burden; for one running a decade, old command shapes must keep deserialising long after the feature was removed, and that obligation should be priced at adoption (Deprecation).

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — durable command logs are the basis of workflow engines and event-sourced systems, where the stored intent becomes the source of truth and the current state becomes a projection of it.