Skip to content

9.4.15 — Command

What the original Gang of Four book says: Wrap a request as an object, which lets you pass requests around as arguments, queue them, log them, and support undo.

What that means when you are actually writing code: Take the sentence "do this thing" and turn it into a value you can hold in a variable. Once it is a value, you can put it in a list, send it somewhere else, run it later, run it again, write it to a log, or reverse it — none of which you can do with a plain method call.

Command is the pattern that turns an action into data. That sounds abstract right up until you notice what it unlocks: undo and redo, background job queues, the transactional outbox, audit logs, macro recording, request retries, and the entire git command line. Every one of those needs to hold on to an action before, during, or after running it. And you cannot hold on to a method call. A method call happens and is gone. A command object stays.

1. The story: the editor that could not undo

A drawing app calls operations directly on the document:

typescript
class Toolbar {                                            
  onMoveClicked()   { this.doc.moveShape(this.selectedId, +10, 0); }
  onDeleteClicked() { this.doc.deleteShape(this.selectedId); }
  onColorPicked(c)  { this.doc.setColor(this.selectedId, c); }
}

This is clean, readable code, and for the first week it is exactly right. Then the requirements arrive, and each one turns out to be impossible without a redesign.

"Add undo." There is nothing to undo with. The call to moveShape has already happened, and the information about how far the shape moved is gone. The usual first attempt is to take a snapshot of the whole document before every action and restore it on undo. That works, and then it dies at scale, because a document with four thousand shapes, snapshotted on every keystroke, is hundreds of megabytes of memory and a visible pause every time the user does anything.

"Add redo." Even with snapshots, redo needs the forward operation, and that was never recorded anywhere. You have the before-picture but not the instruction that produced the after-picture.

"Show a history panel that says 'Moved rectangle', 'Deleted circle'." The actions left no trace and had no names. There is nothing to list.

"Let the user select twelve shapes and align them as a single undo step." There is no object that represents "align these twelve", so there is nothing to group and nothing to undo as a unit.

"Record a macro and replay it." Nothing was recorded, because nothing was ever captured as data.

"Run the expensive operations on a background thread." You cannot queue a method call. You can only queue the making of one, which means you need the call to exist as a thing before it runs.

"Audit every change, with who did it and when." The logging would have to be copied into every single handler, and it would be forgotten in the next handler somebody adds.

"Retry the sync operation that failed." The operation is not a thing you can hold and try again. It already ran, failed, and vanished.

Look carefully and every one of these is the same requirement wearing a different hat: the action has to exist as a value, before it runs, while it runs, and after it runs.

typescript
const cmd = new MoveShape(docId, shapeId, { dx: 10, dy: 0 });   // ← the action, now a value
history.execute(cmd);        // run it, and remember it
history.undo();              // ask it to reverse itself
queue.enqueue(cmd);          // send it somewhere else
audit.log(cmd.describe());   // "Moved rectangle 40px right"

2. How you arrive at the pattern

Step 1 — Start naive. Call the method directly. This is the right choice for the overwhelming majority of code you will ever write, because a direct call is clearer, faster, and easy to find with a text search. Wrapping calls in command objects "for tidiness" is a real way to make a codebase unnavigable.

Step 2 — Wait for the force. The force is that you need to do something with the invocation itself, not merely perform it. You need to delay it, queue it, retry it, log it, undo it, replay it, check permissions on it uniformly, or hand it to another thread, process or machine. The tell is grammatical: the moment your sentence contains "the operation" as a noun — "queue the operation", "retry the operation", "undo the operation" — you need it to be an object.

Step 3 — Draw the line between what varies and what stays fixed.

What varieswhich operation it is, with which arguments, on which receiver
What stays fixedthat it will be executed — there is one uniform execute() handle

The fixed handle becomes the Command interface. Each specific operation, bundled with its arguments, becomes an instance of that interface.

Step 4 — Decide when the choice gets made. A command is created at one moment and executed at another, and those two moments can be far apart. The command might run much later, somewhere else entirely, or more than once. That separation in time and space is the entire point. If a command is always created and executed at the same instant in the same place, you have written a method call with extra steps and gained nothing.

Step 5 — Name the pattern and be clear about the costs. The name is Command. You will also see it called Action, Transaction, Job, or Message depending on the ecosystem.

The first cost is a class per operation, which is genuine ceremony. Section 5.2 shows how to reduce it with plain data objects plus handlers.

The second cost is indirection. The place where a command is triggered no longer tells you what will actually run, because the command was created elsewhere.

The third cost is serialization constraints if the command ever crosses a process boundary. A command that will go onto a queue can hold only plain data, never a live reference to an object in this process. This is the single most common mistake people make when they introduce a queue, and section 6.2 covers it in full.

The fourth cost, for undo specifically, is the burden of writing correct inverses, which turns out to be much harder than it first looks. Section 6.1 is entirely about that difficulty.

What you get in return is that everything in the story above becomes ordinary rather than impossible.

3. The mental model

In one sentence: a command is a written work order. It names the job, carries the details, and can be handed to somebody else, filed away, put in a queue, checked, and (if the job allows it) reversed.

The analogy that makes it stick — the restaurant ticket. A waiter does not walk into the kitchen and start cooking. They write a ticket that says "table six, one steak medium-rare, no butter", and every property of the pattern falls straight out of that ticket.

The ticket separates the waiter from the chef. Neither needs to know anything about the other. The ticket can be queued, so tickets stack up in the order they arrive. The ticket is a record, which proves what was ordered, and that is your audit log. The ticket can be handed to a different chef if the first one is busy, and it can be redone if a dish is dropped. It can be cancelled before it is cooked. And it can be prioritised, so an allergy ticket jumps the queue.

Map the roles and the whole pattern is named. The waiter is the invoker, the object that holds and triggers commands. The ticket is the command. The chef is the receiver, which knows how to do the actual work. And the customer is the client, who decided what to order in the first place.

When to reach for it. The signals are:

  • "add undo and redo"
  • "queue this work", "run this in the background", or "schedule this for later"
  • "retry the operation that failed"
  • "log and audit every action"
  • "record and replay"
  • "the user can bind any action to a button, a menu item, a keyboard shortcut, or an API call"
  • "apply these changes as one atomic step"
  • "send this operation to another service"

The common thread is that the operation must be named, stored, or transported.

When not to reach for it. If the operation runs immediately, right where it is created, exactly once, with no need to record it, then call the method. A Command object for every method is a well-known way to bury a codebase under classes that do nothing but forward one call, which is 9.3.4's YAGNI rule being violated. One-implementation command classes are the most common form of pattern cargo-culting you will see in the wild.

4. Structure

Toolbar / API(client)History / Queue(invoker)the request, as a valueMoveShape{ shapeId, dx, dy }execute() · undo()Document(receiver)① create② execute()③ do the work④ undo() reverses itBecause ② is a value, the invoker can also: queue · schedule · retry · log ·check permissions · batch · serialize and send to another machine
Figure 15 — The request becomes an object, and that changes what is possible. ① The client decides what should happen and packages it. ② The invoker (blue and cyan) holds the command without knowing what it does, and triggers it whenever its own rules say to. ③ The command calls the receiver (green), which contains the real logic. ④ If the command captured enough information, it can reverse itself. The band along the bottom lists the abilities that exist only because the request is a value rather than a call.

The participants are the four roles from the restaurant analogy. The Command is the interface, with execute() and optionally undo(). A ConcreteCommand binds a receiver and some arguments and implements execute. The Receiver is the object that knows how to perform the work, and the command should call it rather than contain the logic itself. The Invoker holds and triggers commands, and it can be a history stack, a queue, a scheduler, or a button. The Client creates commands and sets up the invoker.

The rule that keeps commands honest: the business logic does not live in the command. MoveShape.execute() should call document.moveShape(...) and little else. If the geometry, the validation and the persistence all live inside the command, then you have written a transaction script with an execute method bolted on, and the receiver has been hollowed out to nothing. Commands package and sequence. Receivers perform.

5. The code, walked through line by line

typescript
export interface Command {
  execute(): void;
  undo(): void;                                            // (1) only if the invoker supports history
  readonly label: string;                                  // (2) the history panel needs this
}

export class MoveShape implements Command {
  constructor(
    private readonly doc: Document,                        // (3) the receiver
    private readonly shapeId: ShapeId,
    private readonly dx: number,
    private readonly dy: number,
  ) {}
  readonly label = "Move shape";
  execute(): void { this.doc.translate(this.shapeId, this.dx, this.dy); }   // (4) call the receiver
  undo(): void    { this.doc.translate(this.shapeId, -this.dx, -this.dy); } // (5) the exact reverse
}

export class DeleteShape implements Command {
  private removed?: { shape: Shape; index: number };       // (6) captured when execute runs
  constructor(private readonly doc: Document, private readonly shapeId: ShapeId) {}
  readonly label = "Delete shape";
  execute(): void {
    this.removed = this.doc.remove(this.shapeId);          //     remember WHAT and WHERE
  }
  undo(): void {
    if (!this.removed) throw new Error("undo before execute");
    this.doc.insertAt(this.removed.index, this.removed.shape);   // (7) restore the position too
  }
}

export class History {                                     // (8) the invoker
  private done: Command[] = [];
  private undone: Command[] = [];

  execute(cmd: Command): void {
    cmd.execute();
    this.done.push(cmd);
    this.undone = [];                                      // (9) a new action clears the redo stack
  }
  undo(): void {
    const cmd = this.done.pop();
    if (!cmd) return;
    cmd.undo();
    this.undone.push(cmd);
  }
  redo(): void {
    const cmd = this.undone.pop();
    if (!cmd) return;
    cmd.execute();                                         // (10) redo is just execute again
    this.done.push(cmd);
  }
  get labels(): string[] { return this.done.map((c) => c.label); }   // the history panel
}

Now the numbered decisions.

(1) undo() belongs on the interface only if this system actually has history. A command built for a job queue has execute() and no undo() at all. Do not force an interface member that half your commands will implement by throwing an exception (9.3.8's Interface Segregation Principle). When you have both undoable and non-undoable commands, split them into Command and UndoableCommand.

(2) A human-readable label is not decoration. It is the text in the history panel, the entry in the audit log, and the message in an error. Putting it on the interface means no command can be created without one, so your history panel can never show a blank row.

(3) The receiver is passed in. The command holds what to act on, not a global variable. This is what makes commands testable with a fake document, and it is what lets the same command class act on different documents.

(4) execute calls the receiver. It is one line. If execute grows into fifty lines of geometry, that logic belongs in Document, not in the command.

(5) When a pure reverse exists, use it. Moving by (dx, dy) is undone by moving by (-dx, -dy). No captured state is needed, which means this command is trivially easy to save and to replay.

(6) When no pure reverse exists, capture the state when execute runs. Deleting cannot be reversed from its arguments alone, because you have to remember what was deleted in order to put it back. Notice that this capture happens inside execute(), and not in the constructor. The shape might change between the moment the command was created and the moment it runs, and the command must undo what it actually did, not what it planned to do.

(7) Restore everything that changed, including the position in the collection. The most common undo bug in the world is restoring the deleted object but appending it to the end of the list, so that undoing a delete silently reorders which shape draws on top of which. Undo has to be exact, not merely approximately right.

(8) The invoker owns the policy. History decides what "execute" means for this system: run it, remember it, clear redo. A queue invoker would decide something completely different: serialize it, enqueue it, acknowledge it. Same commands, different invoker, entirely different capability. That reuse is the payoff.

(9) A new action clears the redo stack. This is the standard linear-history behaviour that users expect. The alternative, a branching undo tree, is a real design choice that a few tools make and most avoid, because a branching history is genuinely hard to present in a user interface.

(10) Redo is simply execute again, which is exactly why execute must be repeatable. After a sequence of execute, then undo, then execute, the result has to equal the state after the first execute. A command that appends to a list every time it runs will duplicate the item on redo, so this property needs an explicit test.

What this does when you run it:

typescript
const h = new History();
h.execute(new MoveShape(doc, "s1", 10, 0));     // shape moves to x=10
h.execute(new DeleteShape(doc, "s2"));          // s2 removed (it was at index 3)
h.undo();                                        // s2 restored, back AT INDEX 3
h.undo();                                        // shape moves back to x=0
h.redo();                                        // shape moves to x=10 again
h.labels;                                        // ["Move shape"]

5.1 Composite commands: many actions, one undo step

A user thinks of "align twelve shapes" as one action, so undo has to reverse all twelve at once. This is Composite applied to Command, and it is one of the cleanest examples of two patterns combining:

typescript
export class MacroCommand implements Command {
  constructor(private readonly commands: readonly Command[], readonly label: string) {}
  execute(): void {
    const done: Command[] = [];
    try {
      for (const c of this.commands) { c.execute(); done.push(c); }
    } catch (e) {
      for (const c of done.reverse()) c.undo();            // (1) partial failure rolls back
      throw e;
    }
  }
  undo(): void {
    for (const c of [...this.commands].reverse()) c.undo();  // (2) REVERSE order, always
  }
}

(1) A failure halfway through must not leave a half-applied macro. If the fifth of twelve commands throws, the four that already ran are rolled back in reverse, and then the error is re-thrown. Without this, a later undo would try to reverse commands that never actually ran.

(2) Undo runs in reverse order, and this is not a style choice. If command B depends on the effect of command A — for example, A creates a group and B adds shapes to it — then undoing A first would leave B trying to undo itself against a world that no longer exists. Reverse order is the only order that is generally correct. It is the same rule as unwinding a stack, or as reversing a set of database migrations.

5.2 The lightweight version: data plus a handler

A class per operation is heavy when there are many operations. The modern approach separates the command's data from its behaviour, and as a bonus this makes it serializable by construction:

typescript
type Command =                                             // (1) plain data, no methods
  | { type: "shape.move";   shapeId: string; dx: number; dy: number }
  | { type: "shape.delete"; shapeId: string }
  | { type: "shape.color";  shapeId: string; color: string };

type Handler<C extends Command> = (cmd: C, ctx: Ctx) => void;

const HANDLERS: { [K in Command["type"]]: Handler<Extract<Command, { type: K }>> } = {   // (2)
  "shape.move":   (c, ctx) => ctx.doc.translate(c.shapeId, c.dx, c.dy),
  "shape.delete": (c, ctx) => ctx.doc.remove(c.shapeId),
  "shape.color":  (c, ctx) => ctx.doc.setColor(c.shapeId, c.color),
};

export function dispatch(cmd: Command, ctx: Ctx): void {
  (HANDLERS[cmd.type] as Handler<Command>)(cmd, ctx);      // (3) one dispatch point
}

(1) The command is plain data, which means it is trivially converted to JSON. It can go onto a queue, into a log, through a postMessage, or into an HTTP body with no extra work at all. This is the form to reach for the moment commands cross a boundary.

(2) The mapped type forces a handler for every command type. Add a variant to the union and the build fails until it is handled, which is the same exhaustiveness guarantee that State section 6.3 uses.

(3) There is one dispatch point, and that is where you attach the cross-cutting concerns: logging, validation, permission checks, metrics, retries, and the transaction boundary. Each of those is written once and applies to every command in the system. This is exactly what Redux's dispatch is, what a CQRS command bus is, and it is why this style scales better than a class per operation once the count grows.

The trade-off is that a data command cannot carry captured undo state as a field, because it is stateless. So undo in this style is done differently, either by recording inverse commands or through event sourcing, both of which section 6.1 covers. For queues, APIs and CQRS, data-plus-handler is the right default. For interactive undo, the class form's ability to capture state when execute runs is genuinely convenient.

5.3 Python: dataclass commands and a registry

python
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)                       # frozen: a command is a value, never mutated
class MoveShape:
    shape_id: str; dx: int; dy: int
    def execute(self, doc): doc.translate(self.shape_id, self.dx, self.dy)
    def undo(self, doc):    doc.translate(self.shape_id, -self.dx, -self.dy)

@dataclass(frozen=True)
class DeleteShape:
    shape_id: str
    def execute(self, doc): object.__setattr__(self, "_removed", doc.remove(self.shape_id))
    def undo(self, doc):    doc.insert_at(self._removed.index, self._removed.shape)

class Command(Protocol):                       # structural typing, no base class needed
    def execute(self, doc) -> None: ...
    def undo(self, doc) -> None: ...

functools.partial(doc.translate, "s1", 10, 0) is the one-line form when all you need is execute. It is worth knowing, and it is worth not using when the command has to be inspectable, serializable, or undoable, because a partial is opaque. You cannot ask it what it will do, you cannot log it meaningfully, and you cannot send it anywhere.

6. Going deeper

6.1 Undo is harder than it looks: four strategies

"Add undo" sounds like one feature, and it is actually a choice between four designs with very different costs. Knowing all four, and knowing when each one breaks, is what a senior answer sounds like.

StrategyHow it worksMemory costWhere it breaks
Inverse operationeach command computes its own reversetinywhen the operation has no inverse, such as clear() or a setColor that never recorded the old colour
State capture (memento)the command records the affected state when it runsproportional to what changedwhen the affected state is huge
Full snapshotcopy the whole document before each commandvery largewhen documents are big or actions are frequent
Event sourcingstore the log of operations; undo replays up to a pointgrows with historywhen replay is slow, which you fix with periodic snapshots

The practical answer is a hybrid, and it is what real editors actually do. Use pure inverses where they exist, such as move, resize and reorder. Use targeted state capture where they do not: delete captures the deleted object and its index, and setColor captures the previous colour. And use periodic snapshots to bound the replay cost in any system that keeps a long history.

Beyond choosing a strategy, there are four hazards that make undo genuinely hard, and each one has bitten every editor ever written.

Hazard one: side effects outside the model cannot be undone. A command that sent an email, charged a card, or called an external API has escaped your ability to reverse it. The rule is that commands which touch the outside world are not undoable — they are compensatable. Compensation is a new forward action, such as issuing a refund or sending an apology email, and it is not the same thing as undo. Mark these commands explicitly so the history layer refuses to put them on the undo stack, because a user who hits Ctrl-Z expecting their money back will be very unhappy.

Hazard two: undo must be exact, including the things that "don't matter". Selection, scroll position, the z-order of shapes, the insertion index, and the cursor location are all part of what the user expects to come back. The classic bug is undoing a delete and restoring the shape at the end of the list, which changes what gets drawn on top of what.

Hazard three: concurrency breaks naive undo. In a document that several people are editing at once, undoing your action after somebody else edited the same region cannot mean "restore the old bytes". It has to mean "apply an inverse operation, transformed against everything that happened since", which is exactly what Operational Transformation and CRDTs exist to compute. Single-user undo is a stack. Multi-user undo is an algorithm.

Hazard four: repeatability. Redo re-executes the command, so execute must give the same result every time it runs. A command that generates an ID, or captures a timestamp, at execute time will break this quietly, because the redo produces a different ID from the original. Generate IDs when the command is constructed, not when it runs.

6.2 Commands as jobs: queues, retries, and what a queue demands

The moment a command is executed by a worker instead of by the caller, four properties become mandatory. They are the difference between a queue that works and one that produces duplicate charges.

typescript
interface Job {
  readonly id: string;                 // (1) idempotency key, generated ONCE, at creation
  readonly type: JobType;
  readonly payload: unknown;           // (2) plain data only, no live references
  readonly attempts: number;
  readonly maxAttempts: number;        // (3) failure must be bounded
  readonly notBefore?: Date;           // (4) scheduling and backoff
}

(1) Idempotency. Queues deliver at-least-once, which means a worker can do the work and then crash before acknowledging it, so the same command will be executed twice at some point. The handler must make that harmless. Either deduplicate on the command's ID in the same transaction as the effect, or make the effect naturally repeatable, such as an upsert by key or a SET status='shipped' rather than an increment. And generate the ID when the command is created, not when it runs, because if every retry gets a fresh ID then the deduplication is worthless (10.4).

(2) Serializability. A command that holds a live Document reference cannot be put onto a queue. The payload carries identifiers and values, and the worker resolves them back into objects when it runs. This is the number-one surprise when in-process commands are first moved to a queue.

(3) Bounded failure. Retries with exponential backoff and jitter, a maximum number of attempts, and a dead-letter queue for anything that exhausts them — plus an alarm on that dead-letter queue, because a dead-letter queue nobody watches is a silent bucket where data goes to be lost.

(4) Scheduling and ordering. A notBefore field gives you delayed jobs and backoff almost for free. If ordering matters, it comes from partitioning by an entity key, and you must never assume a queue preserves global order on its own.

Two more properties matter in production. Poison-message protection: a command that crashes the worker will be redelivered and crash it again, so cap the attempts and quarantine it. And versioning: a command enqueued by today's code may be executed by next week's deploy, so payloads need a version field and handlers must tolerate the old shapes. That is the same discipline as event schema evolution in Observer.

6.3 Commands at architecture scale: CQRS and the command bus

Scaled up, Command becomes an architectural style. A command bus is one dispatch point that every state-changing operation in the application passes through, and its value is that the cross-cutting concerns attach in one place:

typescript
const bus = pipeline(                         // each layer wraps the next — Decorator over Command
  withCorrelationId,                          // trace every command end to end
  withValidation,                             // reject malformed commands before anything runs
  withAuthorization,                          // one uniform permission check per command type
  withTransaction,                            // one database transaction per command, defined once
  withAuditLog,                               // who did what, when, with which payload
  withMetrics,                                // duration and failure rate per command type
  withRetry,                                  // on transient failures only
)(handleCommand);

await bus.dispatch({ type: "order.ship", orderId, carrier, actor: user.id });

Notice what this is: Decorator applied to Command, and the ordering rules from that chapter apply directly here. Authorization must sit outside the transaction, and retry must sit outside any layer that is sensitive to idempotency, never inside it.

CQRS, which stands for Command Query Responsibility Segregation, is the discipline of separating operations that change state from operations that read it. Commands have no meaningful return value beyond acceptance, they are validated and audited and transactional. Queries have no side effects, they can be cached freely, and they can be served from a replica or a denormalised read model. The pattern gives you the vocabulary, and the architecture gives you the payoff, because reads and writes can then scale, cache and fail independently.

The honest caveat is that CQRS with separate read models introduces eventual consistency between the write side and the read side, which is a real cost that has to be justified. Many systems want the command bus, for its uniform cross-cutting concerns, without the separate read model and its asynchronous projections. Taking only the first half is a perfectly respectable choice.

Command versus event is a distinction that must be exact, because confusing the two causes real architectural damage:

CommandEvent
Grammarimperative — ShipOrderpast tense — OrderShipped
Recipientsexactly one handlerzero to many subscribers
Can it be rejected?yes, validation may refuse itno, it already happened
Coupling directionthe sender knows what it wants donethe publisher does not know who cares
What failure meansthe request faileda subscriber failed; the fact still stands

A command that is broadcast to many handlers has become an event with a misleading name. An event that only one specific service may handle, and may reject, is a command with a misleading name. Getting this right is what keeps an event-driven system from turning into a distributed monolith (9.4.13).

7. Where you would actually use this

(a) Undo and redo in editors. The standard case: documents, drawing tools, IDEs, spreadsheets, CAD. Every one uses Command plus a history stack, with the hybrid undo strategy from section 6.1.

(b) Job queues and background work. Sidekiq, Celery, BullMQ, SQS consumers, pg-boss. A job is a serialized command with retry and scheduling information attached. Sending an email, generating a PDF, transcoding a video, rebuilding a search index.

(c) The transactional outbox. A command written to a table in the same transaction as a state change, and delivered later by a relay. This is the durability mechanism referenced throughout Observer and State. The row is a command object.

(d) Binding actions in a user interface. The same SaveDocument command bound to a toolbar button, a menu item, Ctrl+S, a context menu, a command palette, and a CLI flag. Six invokers, one command, and the enabled or disabled state comes from asking the command canExecute(). This is precisely why VS Code, Emacs and every serious editor have a command palette: once actions are objects with IDs and labels, listing them and searching them is free.

(e) Redux and reducers. An action is a data command, the reducer is the handler, and the store is the invoker. Redux DevTools' time-travel debugging is undo and redo over a command log, which is the clearest mainstream proof that "actions as data" unlocks real capability.

(f) Database migrations. The up() and down() methods are execute() and undo(), applied in order and reversed in reverse order, exactly as in section 5.1. A migration tool is Command plus a persisted history table.

(g) Distributed transactions and sagas. Each step is a command with a compensating command, and the orchestrator runs them forward and compensates in reverse on failure (10.8.4). The macro rollback from section 5.1 is the in-process version of the same idea.

(h) Remote APIs and RPC. An HTTP request body is a serialized command, such as POST /orders/{id}/ship, and the endpoint is the dispatcher. Recognising this makes idempotency keys, retries and validation feel like the same problem you already solved locally (9.6.3).

(i) Macros and scripting. Recording user actions is capturing commands, and replaying is executing them. Photoshop actions, Vim's . repeat and its macro registers, and Excel's recorded macros are all this pattern.

8. Variants

VariantWhat it looks likeNotes
Classic command objecta class with execute(), and maybe undo()interactive apps and history stacks
Data command plus handlera tagged union and a handler mapserializable; the default for queues, APIs, CQRS
Closure commanda function valuewhen no metadata or serialization is needed
Undoable commandadds undo()a separate interface from plain Command
Composite / macroone command containing manyone undo step; rolls back on partial failure
Queued / job commandadds id, attempts, backoff, dead-letteridempotency is mandatory
Scheduled commandadds notBefore, crondelayed and recurring work
Command bus plus middlewareone dispatch point, decoratedauth, validation, transaction, audit, all once
canExecute()commands report whether they are availabledrives disabled buttons and menu state
Compensating commandan explicit forward "undo" for external effectssagas and refunds

canExecute() is worth calling out, because it solves an everyday UI problem elegantly. Instead of the toolbar containing logic about when "Delete" should be greyed out, which duplicates the command's own preconditions, the command answers for itself. Every invoker that binds it then gets the correct enabled state for free, and you also get a cheap dry-run: you can ask, before executing, whether this would even be allowed.

9. Where you already use it

What you have usedThe request, stored as a thing
Undo in any editoreach edit was kept, so it can be taken back
setTimeout(fn, 1000)a job written down now, run later
A background job queue"send this email" saved, picked up by a worker
Database migrations with up and downdo it, and the matching way to undo it
The command palette in your editorevery action has a name, a key and an entry

Undo is the clearest one, so work out what it demands. For the editor to undo your last action, it cannot simply have done the action and moved on. It had to keep something: what happened, and enough information to reverse it. "Typed the word cat at position 40" can be reversed by deleting four characters at position 40. "Deleted this paragraph" can only be reversed if the paragraph itself was kept.

So the request had to become an object that outlives the moment of doing it. That is the whole pattern, and every other row follows from the same move. Once the request is a thing rather than a call, you can also put it in a queue, run it on another machine, retry it after a crash, log it for an audit trail, or replay the whole list to rebuild what happened.

10. Ways to get it wrong

  1. A command class per method, run immediately. Ceremony with no capability gained.

    The fix: call the method; introduce commands when you must queue, log, undo, or transport.

  2. Business logic inside the command. The receiver is hollowed out and the logic cannot be reached from anywhere else.

    The fix: commands call, receivers perform.

  3. Live object references in a command that gets queued. It cannot serialize, and if it does, it holds a stale object.

    The fix: payloads carry IDs and values, and the worker resolves them.

  4. Capturing undo state in the constructor. The world may change between construction and execution.

    The fix: capture it when execute runs.

  5. Undoing side effects that escaped the model. An "undo" that tries to unsend an email.

    The fix: mark external-effect commands non-undoable, and compensate forward instead.

  6. Approximate undo. Restoring the object but not its index, selection or z-order.

    The fix: the round-trip property test on a deep snapshot.

  7. Macro undo in forward order. Dependencies break.

    The fix: always reverse.

  8. Non-repeatable execute. IDs or timestamps generated at execute time, so redo differs from the original.

    The fix: generate them at construction, and make execute idempotent.

  9. No idempotency for queued commands. At-least-once delivery duplicates the effect.

    The fix: an ID generated at creation, deduplicated in the same transaction as the effect.

  10. Unbounded history. The undo stack keeps every command and everything it captured, for the whole session.

The fix: cap the depth, coalesce fine-grained actions (typing forty characters is one undo step, not forty), and drop captured state beyond the cap.

  1. undo() on an interface half the commands cannot implement. The fix: separate Command and UndoableCommand.
  2. Commands used as events. Broadcast to many handlers, past-tense-ish, unrejectable.

The fix: name and route it as an event (section 6.3).

11. Command compared with its neighbours

Compared withThe differenceChoose Command when
Strategya Strategy is how to do a step of an ongoing operation, held by a context. A Command is what to do, held by an invoker until triggeredthe invocation itself must be stored, moved, or reversed
Observer / eventsan event is a past-tense fact for any number of subscribers and cannot be rejected. A command is an instruction for exactly one handler, which may refuse ityou are instructing, not announcing
Memento (9.4.1 section 2)saves the state; a Command saves the actionyou want the action itself as a value
Chain of Responsibilitya Chain routes a request to whichever handler can take it. A Command binds a request to its receiver up frontthe receiver is already known
Compositecomplementary: a macro command is a composite of commandsuse both
Function or closurea closure is a command with no metadata: no label, no canExecute, not serializable, not inspectableyou need to name, store, transport, or reverse it

Command versus Strategy is the pair to be precise about. Both wrap behaviour in an object, but the relationship is different. A strategy is a collaborator: a context holds one and calls it as part of doing its own job, such as "how should I quote shipping?". A command is a request: something creates it, an invoker holds it — possibly for a long time, possibly with no idea what it does — and triggers it later, such as "ship order 42". The test is to ask whether the object represents a way of doing something, which is Strategy, or a thing to be done, which is Command. And the practical tell: if you find yourself wanting to put it in a list, a queue, or a history, it is a Command.

12. Interview calibration

The 45-second answer, in the order you would say it:

Command turns an invocation into an object — receiver, method and arguments packaged as a value with execute(), and undo() if the system has history. That matters because you can then do things to the invocation that a method call does not allow: queue it, schedule it, retry it, log it for audit, batch several into one undoable step, bind the same action to a button and a shortcut and an API route, or serialize it and send it to another machine.

For undo I use a hybrid: pure inverses where they exist, and targeted state capture at execute time where they do not, and I am explicit that commands with external side effects are not undoable, they are compensatable. When commands cross a process boundary they have to be plain serializable data with an ID generated at creation, because queues are at-least-once and the handler has to be idempotent.

At architecture scale it becomes a command bus: one dispatch point where validation, authorization, the transaction boundary, audit logging and metrics attach once for every command. The cost is a class or a variant per operation, so I do not use it for calls that run immediately and are never stored.

Follow-up questions, with the seed of each answer:

  • "How do you implement undo?" — Four strategies: inverse, state capture, snapshot, event sourcing. Hybrid in practice. Capture when execute runs, not at construction. Test the round trip on a deep snapshot.
  • "What can't be undone?" — Anything that escaped the model: emails, charges, external API calls. Compensate forward instead, and mark those commands non-undoable.
  • "Command versus event?" — Imperative, one handler, rejectable, versus past-tense, many subscribers, unrejectable. Naming one as the other is how event-driven systems rot.
  • "What changes when a command goes on a queue?" — Plain-data payload, ID generated at creation, idempotent handler, bounded retries with backoff, a dead-letter queue you alert on, and payload versioning.
  • "Command versus Strategy?" — A way of doing something that you hold as a collaborator, versus a thing to be done that you hold in a list, queue or history.
  • "What is a command bus?" — One dispatch point wrapped in middleware, so validation, authorization, transactions, audit and metrics are written once. It is Decorator applied to Command, with the same ordering rules.

Recall

  • Command is an invocation held as a value: receiver plus method plus arguments, packaged with execute(), and undo() where it makes sense. The point is what you can then do to the invocation — queue, schedule, retry, log, batch, bind to many triggers, serialize and send.
  • How you arrive at it: what varies is which operation with which arguments on which receiver; what stays fixed is that something will be executed. The binding time is created now, executed later — if creation and execution are always the same instant in the same place, it is a method call with extra steps.
  • Commands call; receivers perform. Business logic inside execute() hollows out the domain object and makes the logic unreachable from anywhere else.
  • Undo, four strategies: pure inverse, state capture (memento), full snapshot, event sourcing — hybrid in practice. Capture undo state when execute runs, not in the constructor. Undo must be exact, including index, z-order and selection. External side effects are not undoable, they are compensatable — a refund is a new forward action. Redo re-executes, so execute must be repeatable: execute, undo, execute equals one execute.
  • Macro commands (Composite): one undo step for many actions; undo in reverse order, always; roll back the completed sub-commands if one fails partway.
  • Data command plus handler map (a tagged union plus Record<Type, Handler>) is the default when commands cross a boundary: serializable by construction, exhaustiveness checked by the compiler, and one dispatch point where middleware attaches.
  • On a queue, four properties are mandatory: idempotency (ID generated at creation, deduplicated in the same transaction as the effect), plain-data payloads (no live references), bounded retries with backoff and an alarmed dead-letter queue, and payload versioning.
  • Command bus and CQRS: one dispatch point decorated with correlation, validation, authorization, transaction, audit, metrics and retry — Decorator over Command, with the same ordering rules. Command versus event: imperative, one handler, rejectable, versus past-tense, many subscribers, unrejectable.

Self-test: Name four things you can do with a command that you cannot do with a method call. Why capture undo state at execute rather than at construction? Why must a macro undo run in reverse? What four properties does a queued command need, and why is the ID generated at creation? Give the one-sentence Command-versus-Strategy test.

Quiz Bank

FoundationalShow how Command is derived from an editor with no undo, and list what becomes possible once the request is an object.

The naive starting point is to call the method directly, which is right for the vast majority of code, because a direct call is clearer, faster, and findable with a text search.

The force is that you need to do something with the invocation itself rather than merely perform it. In the editor story, eight separate requirements were all the same requirement in disguise. Undo needs the action after it ran. Redo needs the forward action. A history panel needs the action's name. Batching twelve edits into one undo step needs an object to group them. Macro record and replay needs the actions as data. Queueing work off the UI thread is impossible, because you cannot queue a call, only the making of one. Auditing every change needs a uniform hook. And retrying a failed sync needs the operation to be held and tried again.

Drawing the line: which operation, with which arguments, on which receiver varies; that something will be executed is fixed, and that fixed part is one uniform execute() handle.

The binding time is the essential property: created at one moment, executed at another, possibly somewhere else, possibly more than once.

What becomes possible, and only because the request is a value: store it in a history stack for undo and redo; put it on a queue and run it in a worker; schedule it for later or retry it with backoff; log or audit it uniformly, including who issued it; batch several into a composite with a single undo step; bind one command to a button, a menu item, a keyboard shortcut, a command palette and an HTTP route; serialize it and send it to another process or machine; ask it whether it can run right now with canExecute(), which drives disabled buttons; record and replay sequences as macros; and attach cross-cutting middleware — validation, authorization, transactions, metrics — at one dispatch point for every operation in the system.

What the pattern costs: a class or variant per operation, indirection at the call site, serialization constraints once it crosses a process boundary (no live references), and the real difficulty of writing correct inverses for undo.

The discipline: commands package and sequence; receivers perform the domain logic.

FoundationalExplain the four undo strategies, the hybrid you would actually build, and the hazards that make undo harder than it looks.

The four strategies. First, inverse operation, where the command computes its own reverse: move by (dx, dy), undo by (−dx, −dy). Tiny memory cost, trivially serializable, but many operations have no inverse you can derive from their arguments, such as clear() or a setColor(red) that never recorded the old colour. Second, state capture, also called memento, where the command records the affected state when it runs: a delete captures the removed object and its index. Memory proportional to what changed, and this is the workhorse. Third, full snapshot, where you copy the entire document before each command. Simple and always correct, and it dies at scale, because a four-thousand-shape document snapshotted per keystroke is hundreds of megabytes and a visible pause. Fourth, event sourcing, where you keep the log of operations and undo by replaying up to a point. Perfect history and audit, but replay cost grows, which you fix with periodic snapshots.

The hybrid you build: pure inverses where they exist, such as move, resize and reorder; targeted capture where they do not, such as delete and set-property, which captures the previous value; and periodic snapshots to bound the replay cost in any system that keeps a long history.

The four hazards. First, external side effects cannot be undone. A command that sent an email or charged a card has escaped the model, so it is compensatable, not undoable: the reversal is a new forward action, such as a refund, with its own audit entry. Mark these commands so the history layer refuses to stack them, or users will press Ctrl-Z expecting their money back. Second, undo must be exact, including what seems incidental: the insertion index, the z-order, the selection, the scroll position. The classic bug is restoring a deleted shape by appending it, which silently changes what draws on top. The defence is a property test asserting that a deep snapshot is identical after execute then undo. Third, concurrency breaks naive undo. In a collaborative document, undoing your action after somebody else edited the same region cannot restore old bytes; it has to apply an inverse transformed against everything since, which is exactly what OT and CRDTs compute.

Single-user undo is a stack; multi-user undo is an algorithm, and saying so is the mark of somebody who has shipped one. Fourth, repeatability: redo re-executes, so execute-undo-execute must equal one execute. Commands that generate IDs or timestamps at execute time, or that append rather than set, break this and show up as "redo duplicated my object". Generate IDs at construction.

Two operational notes: cap the history depth and drop captured state beyond it, or a long session leaks memory steadily; and coalesce fine-grained actions, because typing forty characters is one undo step, not forty. Users think in intentions, not events.

AppliedAn in-process command system is being moved onto a job queue so work runs in a worker. What must change, and what breaks if it does not?

Four properties become mandatory, and each has a specific production failure if it is skipped.

First, payloads must be plain, serializable data. In-process commands routinely hold live references, such as new SendInvoice(this.customer, this.pdfRenderer). Those cannot be put on a queue, and if somebody makes them serializable by naive means, the worker deserializes a stale copy of the customer and emails last week's address. The fix is that payloads carry identifiers and values, and the worker resolves them from the database when it runs. This is the number-one surprise in this migration.

Second, the handler must be idempotent, and the ID must be generated at creation. Queues are at-least-once, which means a worker can complete the work and crash before acknowledging it, so the message is redelivered and the command runs twice. If the command charges a card, you have just double-charged. The fix is to give every command an ID when it is created, not when it runs, because if each retry gets a fresh ID then deduplication is worthless, and then have the handler either deduplicate on that ID in the same transaction as the effect or make the effect naturally repeatable, such as an upsert by key or a SET status='shipped' rather than an increment. Exactly-once delivery does not exist; exactly-once effect is what you build.

Third, failure must be bounded and visible. The fix is capped attempts with exponential backoff and jitter, a dead-letter queue for exhausted messages, and an alarm on the dead-letter queue, because an unwatched dead-letter queue is a silent bucket where data is lost. Add poison-message protection: a command that crashes the worker will be redelivered and crash it again, taking down throughput for everything, so quarantine it after a few crashes.

Fourth, payloads need versions. A command enqueued by today's deploy may be executed by next week's code, so a renamed field means the handler throws on messages that were already in flight during the rollout. The fix is a version field, additive-only changes within a version, and handlers that tolerate the previous shape for at least one deploy cycle.

Three further changes matter. Ordering: an in-process sequence ran in order; a queue does not guarantee that. If order matters, partition by an entity key so one entity's commands are serialized, and design handlers to tolerate reordering across entities. Transaction boundary: the command previously ran inside the caller's transaction and now does not, so anything that must be atomic with the caller's state change has to be enqueued through a transactional outbox — write the command row in the same transaction, and let a relay publish it — or you will enqueue jobs for transactions that later roll back. Observability: commands now run in another process, so a correlation ID must travel in the payload and be logged at enqueue, dequeue and completion, or debugging becomes guesswork.

What breaks if you skip each: live references give stale data or serialization crashes; no idempotency gives duplicate charges and emails; unbounded retries give infinite loops and silent losses; no versioning gives a failed deploy that poisons in-flight messages; no outbox gives jobs for orders that do not exist.

InterviewDesign collaborative undo for a multi-user document editor. Why doesn't the single-user command stack work, and what do you build instead?

Why the stack fails. Single-user undo is "pop the last command and reverse it", and it relies on an assumption that collaboration destroys: that nothing has happened since. With several people editing, three problems appear at once. First, whose action does Ctrl-Z undo? Global undo, which reverses the last action by anyone, is universally hated, because you undo a colleague's paragraph and they watch it vanish. Users expect local undo: reverse my last action, wherever it sits in the shared history. Second, the inverse may no longer be valid. You inserted text at offset 100; somebody else then deleted fifty characters before it. Your naive inverse, "delete five characters at offset 100", now removes the wrong text. Third, the document may have moved on in ways that make the inverse meaningless. You set a cell to red; somebody else deleted the whole row.

What you build. First, per-user undo stacks over a shared operation log. Each user's stack holds references to their own operations, and the document holds the totally ordered log of everyone's. Second, transformation. Undo is not "restore old bytes"; it is "apply the inverse operation, transformed against every operation that has been applied since". That is exactly what Operational Transformation computes: transform op A against a concurrent op B so the result converges.

CRDTs achieve the same convergence differently, by making operations commute through position identities rather than integer offsets, so your insert refers to a stable identity between two characters and a concurrent deletion elsewhere leaves it valid with no transformation needed. Both are real answers. CRDTs are the modern default for text, because they avoid a central transformation authority and tolerate offline editing, at the cost of metadata size and tombstones.

Third, define the semantics you promise, because there is no universally correct one and pretending otherwise is the wrong answer. The common choice is that undo reverses the effect of your operation as far as it still exists, and if the target has been deleted or replaced by another user, the undo becomes a no-op with a notification rather than a resurrection, because bringing back content another user deliberately removed is worse than doing nothing.

Fourth, intention preservation. If you undo a formatting change on a paragraph that has since gained new text, the undo should remove the formatting from the paragraph as it now is, not restore the old paragraph. This "preserve intention, not bytes" rule is the heart of collaborative editing, and it is the reason snapshot-based undo is disqualified outright. Fifth, conflict-aware redo: redo re-applies the operation transformed against everything since the undo, and must be abandoned if its target has vanished.

Practical scoping decisions worth stating: undo granularity is per-intention, not per-keystroke, so coalesce typing runs, and coalesce only within one user's contiguous session; undo across a session boundary is usually disallowed, because the transformation history is not retained forever; and locked or permission-restricted regions must reject an undo the same way they would reject a forward edit, because an undo is an edit and must pass the same authorization.

The summary sentence: collaborative undo is per-user, operates on the operation log rather than on snapshots, and applies an inverse transformed against everything that happened since, so it needs OT or CRDT machinery, an explicit rule for what happens when the target no longer exists, and intention preservation instead of byte restoration; the single-user stack is not a starting point, it is a different problem.

StaffDesign a command bus for a large application: uniform validation, authorization, transactions, audit and retries across about 200 operations, without it becoming a bottleneck or a god object. Cover ordering, failure semantics and evolution.

The shape. Commands are plain data with a type tag and a version. Handlers are pure-ish functions of the form (command, context) → result. The bus is a pipeline of middleware wrapping the handler, which is Decorator over Command.

The god-object risk is real, and it is avoided by one rule: no if (command.type === …) may ever appear in the bus or in the middleware. Anything type-specific is expressed as metadata on the command type — the required permission, whether it needs a transaction, whether it is retryable, how to extract its idempotency key — which generic middleware reads. That single rule is what keeps a two-hundred-operation bus down to a few hundred lines.

Middleware ordering is the substance of the design, because ordering bugs here are security and money bugs. From the outermost layer inward: first correlation and context, which assigns or propagates a correlation ID and the actor identity so everything downstream can log coherently; second deserialization and schema validation, which rejects malformed input cheaply before it touches anything; third authentication and authorization, placed outside the transaction so an unauthorized command never opens one, and outside retry so a permission failure is not retried; fourth rate limiting and quota, before any expensive work; fifth idempotency, placed outside the transaction and outside retry, which looks up the idempotency key and returns the stored response on a replay — putting retry outside idempotency is the classic mistake, and it produces exactly the duplicate side effects the key exists to prevent; sixth retry, only for classified transient failures, with backoff and jitter, and only for commands whose metadata declares them retryable; seventh transaction, one database transaction per command, opened here so business-validation failures roll back cleanly; eighth audit, recording the command, actor, timestamp and outcome in the same transaction as the effect, so the audit log cannot disagree with reality, because an audit written outside the transaction is a lie waiting to happen; ninth domain-event collection, where handlers append events that are dispatched after commit, or written to an outbox in the transaction for durability; and tenth metrics and tracing, spanning the whole thing, per command type.

Failure semantics, defined once and documented. A validation failure is a 400-class error, no retry, no audit entry beyond a rejection record. An authorization failure is a 403, and it is audited, because attempted actions are security-relevant. A concurrency conflict, from optimistic locking, retries a bounded number of times and then returns a 409. A transient infrastructure failure retries with backoff and then fails. A domain-rule violation is a 422, no retry, and it is not an error in the metrics sense, because a rejected command is normal business, and conflating it with 500s destroys your alerting.

Not becoming a bottleneck. The bus is in-process and synchronous by default, so it is a chain of function calls, not a service, and it adds microseconds. Two real risks remain. Transaction scope creep: a handler that calls three external APIs inside the transaction holds locks for seconds, so enforce a rule that external calls happen before the transaction or after commit, and fail CI on obvious violations. Serialization by a single lock or connection: avoid any shared mutable state in the bus itself. For genuinely slow commands, the bus's job ends at accepting the command — it validates, authorizes, persists the intent to an outbox or queue, and returns, while the work happens in a worker. Making that async decision per command type via metadata, rather than per caller, is what keeps the API consistent.

Evolution. Command types are versioned, such as order.ship.v2. Handlers may serve several versions, and the union-plus-handler-map ensures a new type cannot be added without a handler, because the build fails. Deprecation is a two-phase process: emit a deprecation metric when a v1 command arrives, migrate callers, then remove. Consumer-driven contract tests cover any command type accepted over the network.

Observability. Per-command-type dashboards for rate, p95 duration and failure rate by class; a slow-command alert; and a searchable audit log keyed by correlation ID and actor.

The summary sentence: keep the bus tiny and type-agnostic by pushing everything type-specific into command metadata, fix the middleware order with authorization and idempotency outside the transaction and retry outside idempotency, write the audit inside the transaction and dispatch domain events after commit, classify failures so rejected business rules never look like outages, and version command types so two hundred operations can evolve independently.

Flashcards

FlashCommand in one line

An invocation held as a value: receiver plus method plus arguments, with execute() and maybe undo(). The point is what you can do TO the invocation — queue, log, retry, undo, transport.

FlashCommand: the binding-time test

Created at one moment, executed at another — possibly elsewhere, possibly more than once. If creation and execution are always the same instant in the same place, it is a method call with extra steps.

FlashUndo: four strategies

Pure inverse; state capture when execute runs (memento); full snapshot; event sourcing. Hybrid in practice. External side effects are compensatable, never undoable.

FlashMacro command rules

One undo step for many actions (Composite over Command). Undo in REVERSE order, always. If a sub-command fails partway, roll back the completed ones before re-throwing.

FlashQueued commands: four musts

Plain serializable payload (no live references); ID generated at creation plus an idempotent handler; bounded retries with backoff and an alarmed dead-letter queue; payload versioning.

FlashCommand versus event

Command: imperative, exactly one handler, may be rejected, the sender knows what it wants. Event: past tense, zero-to-many subscribers, already happened, the publisher does not know who cares.

Scenario Drill

DrillDesign the operation layer of a collaborative spreadsheet: cell edits, formula changes, row and column insert and delete, sorting, formatting, and pasting a 10,000-cell range — with undo and redo, offline editing, and an audit trail. Show the command design and the hard parts.

The instructive difficulty is that a spreadsheet's operations are structural, so what an operation means depends on the shape of the grid at the moment it is applied — and in a collaborative, offline-capable editor, that shape has usually changed by the time the operation arrives.

The command design. Every operation is plain, serializable data with a type, a version, an author, a client-generated ID, and a logical timestamp, such as {id, type:"cell.set", sheetId, ref, value, formula?, authorId, lamport}. Structural operations, such as row.insert, col.delete and range.sort, are separate types, because they move other cells, which is the crux of the whole problem.

Reference identity is the first design decision, and it decides everything downstream. If a cell edit says "B7", then a concurrent row.insert above shifts what B7 means, and the edit lands in the wrong cell. So cells, rows and columns carry stable identities rather than positional addresses. An operation targets row:9f3e/col:2c1a, and A1-style references such as "B7" become a display projection resolved at render time. Formulas store identity references internally and render as =SUM(B2:B7). This is the spreadsheet version of a CRDT position identity, and adopting it turns most transformation problems into non-problems, which is exactly why it is worth the added complexity.

Undo uses per-user stacks over the shared operation log, as in any collaborative editor. Cell edits capture the previous value when they run, which gives an exact inverse. Structural operations capture what was removed: a deleted row captures its cells and its identity, so undo restores the same identity and dependent formulas re-bind correctly. Restoring a row with a new identity would silently orphan every formula that referenced it, which is the subtle bug this whole design exists to prevent. Undo semantics when the target is gone: a no-op with a notification, never a resurrection.

The 10,000-cell paste is a design test in itself. Done naively it is ten thousand commands: the undo stack explodes, the network carries ten thousand messages, and undo takes ten seconds. The correct handling is one range.paste command carrying the block plus a compact capture of the region it replaced, using run-length or sparse encoding since most pasted-over regions are empty, executed as one operation, undone as one operation, and transmitted as one message. The rule is that command granularity follows user intention, not implementation steps, which is the same rule that makes typing forty characters one undo step. For a genuinely huge range, chunk the transport while keeping one logical command ID, so the undo step stays atomic even though delivery is not.

Formula recalculation is not part of the command; it is a derived consequence. The command records the edit, and a dependency graph recomputes the affected cells. This separation matters for three reasons. Recalculation must not be on the undo stack, because undoing an edit implies undoing its recalculation, and storing both would double-count. Recalculation must be deterministic given the same operation log, or two clients will diverge. And circular references and long chains need bounded evaluation with cycle detection, which is an evaluator concern, not a command concern. Volatile functions such as NOW() and RAND() break determinism outright, so their values are materialized into the operation when it runs, on one authority, and shipped as data rather than recomputed per client.

Offline editing. Commands queue locally with client IDs and a logical clock, and they are applied optimistically to the local view. On reconnect they are sent in order, the server assigns an authoritative order, and it broadcasts. With stable identities, most operations commute. The ones that do not — two users sorting the same range, or one deleting a row another is editing — need explicit resolution rules stated in the product: last-writer-wins per cell by authoritative order, deletion beats a concurrent edit (with the edit surfaced as a notification rather than silently lost), and a sort is treated as a bulk move of identities so concurrent edits follow their cells to the new positions. Idempotency is essential, because the client ID deduplicates replays, since a client that reconnects mid-flush will resend.

Audit. The operation log is the audit trail: append-only, with each operation's author, timestamp and correlation ID, and the before-values captured for undo, which double as the "what changed" record. Snapshots every N operations bound both the replay time and the log retention, and a named version history is simply a labelled snapshot pointer.

Permissions are enforced per command against the target's identity. Protected ranges reject edits, and an undo is an edit subject to the same check, which people routinely forget.

The summary sentence: make every operation a serializable command against stable cell and row identities rather than positional references, size commands to user intentions so a ten-thousand-cell paste is one undoable operation, capture before-values when execute runs so undo is exact and doubles as the audit trail, keep recalculation as a deterministic derived consequence outside the command log, and state the conflict rules explicitly — because in a collaborative spreadsheet the hard part was never the undo stack, it was that "B7" means something different by the time the message arrives.