Skip to content
aipatterns.dev

Design patternBehavioral Patterns

Command

Wrap an action and its arguments in an object so you can queue, log, retry or undo it separately from the code that triggers it.

Complexity
Low
Seen in the wild
High
Also known as
Action, Transaction

Intent

Turn a request into an object that carries everything needed to perform it later: the receiver, the arguments, and any state needed to reverse it. The code that triggers the action (a button, a scheduler, an API handler) then only holds a command and calls execute, without knowing what the action does.

Forces

  • Trigger now vs run later: the user clicks now, but the work goes on a queue, waits for a retry, or runs in a batch.
  • Doing an action vs remembering it: undo, audit trails and replay need a record of what happened, not just its effect.
  • Many triggers vs one behavior: a menu item, a keyboard shortcut and an API call should all run the same operation.
  • Thin callers vs rich operations: the invoker should not know the receiver's API, but the operation needs it.

Therefore

Package each operation as an object with an execute method (and undo when needed), and have callers store and run those objects instead of calling the receiver directly.

The command holds a reference to the receiver and the arguments. The invoker keeps commands in a history, a queue or a log. For undo, a command records what it needs to reverse itself at execute time. In languages with closures, a command without undo is a function value; a pair of closures { run, undo } covers many undo cases too. Use classes when commands need names, serialization, or inspection.

Structure

Diagram source (Mermaid)
classDiagram
  class StockLedger {
    -StockCommand[] history
    +run(cmd) void
    +undoLast() void
  }
  class StockCommand {
    <<interface>>
    +execute() void
    +undo() void
  }
  class Receive {
    -String sku
    -int qty
    +execute() void
    +undo() void
  }
  class Pick {
    -String sku
    -int qty
    +execute() void
    +undo() void
  }
  class CycleCount {
    -int before
    +execute() void
    +undo() void
  }
  class Inventory {
    +qty(sku) int
    +set(sku, n) void
  }
  StockLedger o-- StockCommand
  StockCommand <|.. Receive
  StockCommand <|.. Pick
  StockCommand <|.. CycleCount
  Receive --> Inventory
  Pick --> Inventory
  CycleCount --> Inventory

Participants

  • Command (StockCommand): the interface the invoker calls. Usually execute, plus undo and a label if the invoker needs them.
  • Concrete commands (Receive, Pick, CycleCount): bind a receiver to arguments, and capture the state needed for undo when they run.
  • Receiver (Inventory): the object that does the real work. It knows nothing about commands.
  • Invoker (StockLedger): runs commands and keeps the history, queue or log. It never looks inside a command.
  • Client: creates commands with their receiver and arguments and hands them to the invoker.

Use it when

  • You need multi-level undo or redo for user actions.
  • Actions must be queued, scheduled, retried, or run on another worker, and you want them as data.
  • You need an audit log of what was done, in order, that can be replayed to rebuild state.
  • The same operation is triggered from several places (toolbar, shortcut, command palette, API) and should behave identically.
  • You want to group several operations into one macro or transaction that succeeds or rolls back together.

Do not use it when

  • You only need to pass behavior to be called later, with no undo, queue, history or logging. Pass a closure or function reference (onClick={() => save(doc)}); a SaveCommand class adds nothing.
  • The "queue" is a background job system. Put plain serializable messages ({ type: "ship", orderId }) on the queue and map types to handlers. Command objects holding live receivers can't be serialized across processes.
  • Undo is the only goal and the state is small or immutable. Keep previous versions of the state, or use Memento snapshots, instead of writing an inverse for every operation.
  • The operation is a single call from a single place. Call the receiver directly.
  • The variation is how a result is computed, not which action runs. That is Strategy.

Application grill

Inspect first: the actions that need queueing, retry or undo, and whether a job system already runs (BullMQ, pg-boss, Celery, Sidekiq, SQS, platform queues); how command arguments would be serialized, since a queued command outlives the code version that queued it.

Missing pieces: asynchronous execution needs a queue. Prefer what the platform already provides (Vercel Queues, Cloudflare Queues, SQS); on an existing Postgres, pg-boss or SKIP LOCKED polling adds no new service; Redis plus BullMQ is the step up when throughput needs it. An undo stack for a session is in-memory; persisted undo is a table with a retention policy.

Ask before you build:

  • Scale: how many commands per second, and how large are their payloads (queues bill per message and cap payload size)?
  • Performance: how soon must a command run - immediately, or is seconds-later acceptable?
  • Reliability: on failure, what happens - retry, dead-letter - and is the handler safe to run twice (idempotent-consumer)?
  • Budget: managed queue versus Postgres-backed polling - whose operational budget decides?
  • Implementation: who owns argument schema stability across deploys, and how are in-flight commands migrated?

Implementation steps

  1. Define the command interface from what the invoker needs: execute, and undo or label only if they are used.
  2. For each operation, create a command that takes the receiver and arguments in its constructor.
  3. In execute, call the receiver. If the command supports undo, capture the prior state here, not in the constructor.
  4. Make the invoker record a command only after execute succeeds, so a failed action never lands in the history.
  5. Point every trigger (button, shortcut, API route) at the invoker instead of the receiver.
  6. Test each command's execute followed by undo returns the receiver to its original state.

Example

A warehouse tool records stock movements. Clerks receive stock, pick it for orders, and correct counts after a cycle count. Every action is logged, a failed pick is refused without touching stock, and the last action can be undone.

class Inventory {
  private stock = new Map<string, number>();
  qty(sku: string): number {
    return this.stock.get(sku) ?? 0;
  }
  set(sku: string, n: number): void {
    this.stock.set(sku, n);
  }
}

interface StockCommand {
  readonly label: string;
  execute(): void;
  undo(): void;
}

class Receive implements StockCommand {
  constructor(private inv: Inventory, private sku: string, private n: number) {}
  get label() { return `receive ${this.n} ${this.sku}`; }
  execute() { this.inv.set(this.sku, this.inv.qty(this.sku) + this.n); }
  undo() { this.inv.set(this.sku, this.inv.qty(this.sku) - this.n); }
}

class Pick implements StockCommand {
  constructor(private inv: Inventory, private sku: string, private n: number) {}
  get label() { return `pick ${this.n} ${this.sku}`; }
  execute() {
    const onHand = this.inv.qty(this.sku);
    if (onHand < this.n) throw new Error(`${this.label}: only ${onHand} on hand`);
    this.inv.set(this.sku, onHand - this.n);
  }
  undo() { this.inv.set(this.sku, this.inv.qty(this.sku) + this.n); }
}

// Not reversible by arithmetic, so it captures the old value when it runs.
class CycleCount implements StockCommand {
  private before = 0;
  constructor(private inv: Inventory, private sku: string, private counted: number) {}
  get label() { return `count ${this.sku} = ${this.counted}`; }
  execute() {
    this.before = this.inv.qty(this.sku);
    this.inv.set(this.sku, this.counted);
  }
  undo() { this.inv.set(this.sku, this.before); }
}

class StockLedger {
  private history: StockCommand[] = [];
  readonly log: string[] = [];

  run(cmd: StockCommand): void {
    cmd.execute(); // throws before anything is recorded
    this.history.push(cmd);
    this.log.push(`do   ${cmd.label}`);
  }

  undoLast(): void {
    const cmd = this.history.pop();
    if (!cmd) return;
    cmd.undo();
    this.log.push(`undo ${cmd.label}`);
  }
}

const inv = new Inventory();
const ledger = new StockLedger();
ledger.run(new Receive(inv, "SKU-7", 40));
ledger.run(new Pick(inv, "SKU-7", 12));
try {
  ledger.run(new Pick(inv, "SKU-7", 500));
} catch (e) {
  console.log("refused:", (e as Error).message);
}
ledger.run(new CycleCount(inv, "SKU-7", 25));
ledger.undoLast();
console.log(inv.qty("SKU-7")); // 28
console.log(ledger.log.join("\n"));

Trade-offs

Gains

  • Undo, redo, queuing, retry and audit logging all fall out of one abstraction.
  • Triggers are decoupled from receivers: one command serves a button, a shortcut and an API route.
  • Commands compose into macros and transactions.
  • Each command is testable as an execute then undo round trip.

Costs

  • One class per operation. A large app can accumulate hundreds of thin command types.
  • Undo is only as correct as each command's inverse, and inverses drift when the receiver changes.
  • Commands holding live receivers can't be persisted or sent across processes without a separate message format.
  • Undo history holds references to receivers and captured state, which can keep memory alive.

Relations

  • Memento stores whole state snapshots. Command stores the delta. Combine them when an operation has no cheap inverse.
  • Strategy also puts behavior in an object, but to choose how to compute something. Command chooses what to do and when.
  • Chain of Responsibility can pass command objects along its handlers.
  • Mediator and Observer can dispatch commands between components without direct references.
  • Prototype helps when a command must be copied into the history before it is run again.

Agent checklist

  • There is a concrete need for undo, queuing, logging, retry or replay. Otherwise pass a function.
  • Commands capture undo state in execute, not in the constructor.
  • The invoker records a command only after it succeeds.
  • Every command has an execute then undo round-trip test that compares receiver state before and after.
  • If commands cross a process boundary, they are plain serializable data, not objects holding receivers.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.