Engineering Atlas › Low-Level Design

Command pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A browser-based text editor started with toolbar buttons and keyboard handlers that edit the document directly: the Bold button changes the selection, Backspace removes a character, Paste inserts a block. Then users ask for undo, a command palette, keyboard macros and collaborative editing. Each of these needs the same thing: to treat "what the user did" as data. Undo needs to know how to reverse the last action. The palette needs to list actions by name. Macros need to record a sequence and play it back. Collaboration needs to send actions to other clients.

The command pattern gives each action an object:

class Insert:
    def __init__(self, pos, text):
        self.pos, self.text = pos, text

    def execute(self, doc):
        doc.text = doc.text[:self.pos] + self.text + doc.text[self.pos:]

    def undo(self, doc):
        doc.text = doc.text[:self.pos] + doc.text[self.pos + len(self.text):]

Buttons, keys and the palette now create commands and hand them to a History object, which executes them and keeps them on an undo stack. The invoker (button, shortcut, macro player) knows nothing about how editing works; the receiver (the document) knows nothing about buttons.

The four roles

The Gang of Four name four participants. The command holds a request and its parameters. The receiver is the object that does the work (the document, the bank account). The invoker triggers commands and may store them (the toolbar, a scheduler, the history). The client creates commands and wires them to receivers. In practice the invoker is where the interesting behaviour lives: history, queueing, retrying, logging.

Undo and redo

A linear history needs two stacks. Executing a command pushes it on the undo stack and clears the redo stack, because the redo entries were computed against a state that no longer exists. Undo pops a command, calls its undo, and pushes it on the redo stack. Redo does the reverse. Each command must store exactly the data it needs to reverse itself: Insert needs the position and text; Delete must save the text it removed when it executes, because the original command only knew a position and a length.

Two refinements matter in real editors. Coalescing: typing "hello" should be one undo step, not five, so consecutive adjacent inserts merge into one command. Emacs groups runs of typed characters this way, and ProseMirror's history starts a new group only when an edit is not adjacent to the previous one or arrives more than 500 ms later. Bounding: history is capped by count or bytes, dropping the oldest entries.

Commands as values

When commands must outlive the process, they become records: a type name plus arguments, serialised as JSON or protobuf. Task queues store exactly that (a Celery task message carries the task name and its arguments), CQRS systems accept commands such as PlaceOrder that a handler validates and executes, VS Code registers every action under a string ID so menus, keybindings and extensions call executeCommand("editor.action.formatDocument"), and Redis's append-only file is a log of write commands replayed on restart. The task scheduler lesson stores actions this way, and the tic-tac-toe lesson uses moves as commands for undo; this lesson focuses on the inverse operations and history management those designs rely on.

Command versus event

A command is a request that may be rejected ("reserve seat 14C"); an event is a fact that already happened ("seat 14C reserved"). Keeping the two apart makes logs honest: replaying events rebuilds state without rerunning validation, while replaying commands would rerun business rules that may have changed.

Reviewing agent-written code

Asked to "add undo", an agent commonly deep-copies the whole document before each action and pushes the copy on a stack. Memory grows with document size times history length, and anything holding a reference to the old document breaks. Steer it to commands with inverses, a redo stack cleared on new commands, a bounded history, and tests that run execute then undo and compare with the original state. For queued commands, check that each carries an idempotency key and that handlers tolerate duplicates.

The same shape elsewhere

Commands address the ordering and multi-step consistency shapes: once an action is a value, you can sequence it, log it before applying it, and compensate for it. A database's write-ahead log records changes before applying them, so it can redo them after a crash; a saga stores a compensating command for each step so it can undo a partly completed business transaction; Git commits are recorded changes that git revert inverts with a new commit.

Down the stack

Layer 1The contract

An undoable command promises that undo applied immediately after execute restores the receiver to exactly the prior state, and that execute either fully applies or fails without changing anything (so a failed command is never recorded). A history promises linear semantics: undo reverses the most recent recorded command, redo is available only until a new command executes, and the history holds at most its configured limit. A queued command promises at-least-once or exactly-once delivery; at-least-once is far more common, so handlers must be idempotent, usually by deduplicating on a command ID.

Layer 2The mechanism

Emacs keeps undo information in buffer-undo-list, a list of entries such as (BEG . END) for inserted text and (TEXT . POSITION) for deleted text, separated by nil boundaries that mark where one command ends; undo walks back to the previous boundary and applies each inverse. ProseMirror represents edits as steps; each step can produce its inverse against the document it was applied to, and the history plugin stores inverted steps along with position maps so earlier entries can be rebased when collaborators change the document. Redis appends each write command to the AOF in the same RESP encoding clients send, and the appendfsync setting (always, everysec or no) decides how often the file is flushed with fsync. Go's os/exec.Cmd is a command in the literal sense: a struct describing a process to run, built first and started later with Run or Start.

Layer 3The machine

Each command is a heap allocation of a few dozen bytes plus its payload, and each execute is one indirect call: an itab lookup in Go, a vtable call in Java or C++, a prototype-chain lookup in JavaScript that V8's inline caches turn into a fixed offset when the call site sees few command classes. A Go closure used as a command allocates its captured variables once when it is created. None of that matters for user actions arriving at tens per second.

History memory does matter. A snapshot undo on a 20 MB document with a 500-step history needs up to 10 GB. Inverse commands store only what changed: an insert of 5 characters costs its position and those bytes, and a delete stores the removed text. Coalescing typed characters cuts entry counts by roughly an order of magnitude. String edits themselves can be the hidden cost: splicing a flat string is O(n) per edit, which is why editors store text as ropes or piece tables, where an insert touches O(log n) nodes and undo can reference unchanged pieces instead of copying them.

For concurrency, a single-consumer command queue is the simplest correct design: one goroutine or thread applies commands in order, so the receiver needs no locks, which is how Redis executes commands and how many actor systems work. Multiple producers enqueue through a channel or lock-free queue. When several users edit at once, commands must be transformed against each other (operational transformation) or replaced by CRDT operations, because an Insert(pos=10) means something different after another user's insert at position 3.

Why it's built this way

  1. Actions triggered in one place often need to run later, elsewhere, again or in reverse, so they must be data rather than direct calls.
  2. Reversing an action needs information captured when it ran, so each command stores its own inverse data.
  3. Snapshots cost memory proportional to the whole state, so storing inverses scales with the size of changes instead.
  4. Redo entries depend on the state they were made against, so a new command must invalidate them.
  5. Commands crossing processes can be delivered twice, so handlers deduplicate by ID rather than trusting the transport.

Diagram

stateDiagram-v2
  [*] --> Idle
  Idle --> Executed: execute(cmd) ok, push undo, clear redo
  Idle --> Idle: execute(cmd) fails, nothing recorded
  Executed --> Executed: execute(next), merge if adjacent insert
  Executed --> Undone: undo(), pop undo, push redo
  Undone --> Executed: redo(), pop redo, push undo
  Undone --> Executed: execute(new), redo cleared
  Undone --> Undone: undo() again

Worked example

A web document editor holds documents up to 2 MB of text, and users make about 3 edit commands a second while typing. The first undo implementation snapshotted the whole document per keystroke with a 1,000-step history: up to 2 GB per open tab in the worst case and about 40 MB typically, and the browser killed tabs on low-memory phones.

The team replaced snapshots with Insert and Delete commands storing positions and changed text, and coalesced adjacent inserts. A typical 30-minute session of 5,400 keystrokes became about 600 history entries averaging 40 bytes of text plus around 60 bytes of object overhead, roughly 60 KB in total. A cap of 10,000 entries or 5 MB, whichever comes first, bounds the worst case.

The tests that caught the most bugs were property tests: generate a random document and a random sequence of valid commands, execute them all, undo them all, and compare with the original; then redo them all and compare with the final state. They found two bugs in the first week: Delete at the end of a document stored one character too few, and merging an insert after an undo corrupted the redo stack.

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct method callsSimplest; no extra objectsNo undo, queueing, logging or replayActions that never need any of those
Closures as commandsConcise; captures contextNot serialisable; inverse must be written by handIn-process callbacks, retries, deferred work
Command objects with undoPrecise undo; small memoryEvery command needs a correct inverseEditors, design tools, admin consoles
Snapshot (memento) undoTrivially correctMemory scales with state sizeSmall state, or periodic checkpoints combined with commands
Serialisable command recordsQueue, log, send and replaySchema versioning; idempotency neededJob queues, CQRS, replication, audit

Pitfalls in production

Leadership lens

Check yourself

  1. Why must executing a new command clear the redo stack?

    Show answer

    Redo entries were recorded against a state that the new command has changed, so reapplying them could corrupt the document or apply edits at wrong positions.

  2. Why does a Delete command need to store data during execute that it was not constructed with?

    Show answer

    It is built with a position and length, but undo must reinsert the exact text removed, which is only known when it runs against the document.

  3. When is snapshot undo better than inverse commands?

    Show answer

    When the state is small or inverses are hard to write correctly; many systems combine periodic snapshots with commands to bound replay time.

  4. Why do queued command handlers need idempotency?

    Show answer

    Most queues deliver at least once, so a crash after executing but before acknowledging leads to redelivery; deduplicating by command ID makes the second execution harmless.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →