Skip to content

Command

Intent

Package a request as an object: what to do, to whom, with which arguments, and how to reverse it. Whoever triggers it (a key press, a timer, a worker thread) needs only execute, so the same object can sit on an undo stack, wait in a queue or be written to a log, and the receiver never knows.

When to use and when not to

Use it when

  • Undo and redo are requirements. A command stores the change and its inverse, O(change) per step, where a snapshot costs O(state): 100 keystrokes on a 1 MB document cost 100 x 1 MB = 100 MB as snapshots, a few bytes each as commands.
  • Execution happens elsewhere or later: a scheduler at the due time, a worker pool on another thread.
  • You need a record of what happened, not only the result: an audit trail, a macro recorder.
  • Several requests must behave as one: a replace is a delete plus an insert, undone together.

Leave it out when

  • The request runs immediately and is never stored; a method call is a command with none of the ceremony.
  • Only the behaviour varies, with no inverse and no deferral: that is Strategy or a callback.
  • Undo has no cheap inverse (a reformat touching every line, a chess move with castling rights): snapshot the state instead, which is Memento, possibly carried inside the command.

Structure

Four roles: the Command interface, concrete commands carrying their arguments and their inverse, the Receiver that does the work, and the Invoker that runs and stacks them; the Client is main.

classDiagram
    class Command {
        <<abstract>>
        +execute() None
        +undo() None
        +coalesce(following: Command) Command
        +describe() str
    }
    class InsertText {
        +position: int
        +fragment: str
        +execute() None
        +undo() None
        +coalesce(following: Command) Command
    }
    class DeleteText {
        +position: int
        +length: int
        -_removed: str
        +execute() None
        +undo() None
    }
    class MacroCommand {
        -_commands: Tuple~Command~
        +execute() None
        +undo() None
    }
    class Document {
        +text: str
        +insert(position: int, fragment: str) None
        +delete(position: int, length: int) str
    }
    class CommandHistory {
        -_undo: deque~Command~
        -_redo: List~Command~
        +execute(command: Command) None
        +undo() None
        +redo() None
        +steps() List~str~
    }
    Command <|-- InsertText
    Command <|-- DeleteText
    Command <|-- MacroCommand
    MacroCommand o-- "many" Command : runs in order, undoes in reverse
    InsertText --> Document : receiver
    DeleteText --> Document : receiver
    CommandHistory o-- "many" Command : undo and redo stacks

CommandHistory depends on Command alone: it calls execute, undo and coalesce and never reads a position or a fragment. Document has no outgoing arrow; it does not know it is being undone. MacroCommand is a Composite of commands: one user action, one undo step.

Canonical example in Python

The receiver is an ordinary class that knows nothing about undo (code/patterns/command.py, tested by code/patterns/tests/test_command.py):

code/patterns/command.py — the receiver
class Document:
    """The Receiver: owns the text and knows how to change it. It knows nothing about undo."""

    def __init__(self, text: str = "") -> None:
        self._text = text

    @property
    def text(self) -> str:
        return self._text

    def insert(self, position: int, fragment: str) -> None:
        if not 0 <= position <= len(self._text):
            raise ValidationError(f"position {position} is outside the document")
        self._text = self._text[:position] + fragment + self._text[position:]

    def delete(self, position: int, length: int) -> str:
        """Remove ``length`` characters at ``position`` and return them, so a caller can put them back."""
        if length < 0 or not 0 <= position <= len(self._text) - length:
            raise ValidationError(f"cannot delete {length} characters at {position}")
        removed = self._text[position : position + length]
        self._text = self._text[:position] + self._text[position + length :]
        return removed

delete returns what it removed; that return value is what lets a delete command reverse itself.

The interface and the three concrete commands:

code/patterns/command.py — the interface and three commands
class Command(ABC):
    """The Command interface: do it, undo it, and say what it was.

    An ``ABC`` rather than a ``Protocol`` because two methods have shared defaults:
    ``coalesce`` (most commands never merge) and ``describe`` (for the log). A
    command is bound to its receiver and its arguments at construction, so whoever
    executes it later needs neither.
    """

    @abstractmethod
    def execute(self) -> None: ...

    @abstractmethod
    def undo(self) -> None:
        """Reverse ``execute``. Only valid after ``execute`` has run."""

    def coalesce(self, following: Command) -> Command | None:
        """One command equivalent to ``self`` then ``following``, or ``None`` to keep both."""
        return None

    def describe(self) -> str:
        return type(self).__name__


class InsertText(Command):
    """Insert a fragment at a position; undo deletes exactly that many characters there."""

    def __init__(self, document: Document, position: int, fragment: str) -> None:
        self._document = document
        self.position = position
        self.fragment = fragment

    def execute(self) -> None:
        self._document.insert(self.position, self.fragment)

    def undo(self) -> None:
        self._document.delete(self.position, len(self.fragment))

    def coalesce(self, following: Command) -> Command | None:
        """Consecutive typing is one undo step: 'Hel' then 'lo' at the next position is 'Hello'."""
        if (
            isinstance(following, InsertText)
            and following._document is self._document
            and following.position == self.position + len(self.fragment)
        ):
            return InsertText(self._document, self.position, self.fragment + following.fragment)
        return None

    def describe(self) -> str:
        return f"insert {self.fragment!r} at {self.position}"


class DeleteText(Command):
    """Delete a range; the removed text is captured during ``execute`` so ``undo`` can restore it.

    The command carries a small memento of its own: what it needs to reverse itself
    and nothing more. Before ``execute`` there is nothing to restore, hence the guard.
    """

    def __init__(self, document: Document, position: int, length: int) -> None:
        self._document = document
        self.position = position
        self.length = length
        self._removed: str | None = None

    def execute(self) -> None:
        self._removed = self._document.delete(self.position, self.length)

    def undo(self) -> None:
        if self._removed is None:
            raise InvalidStateError("cannot undo a delete that has not run")
        self._document.insert(self.position, self._removed)
        self._removed = None

    def describe(self) -> str:
        return f"delete {self.length} chars at {self.position}"


class MacroCommand(Command):
    """Several commands as one: a replace is a delete then an insert, undone together, in reverse.

    All or nothing: if a part fails halfway, the parts that ran are undone and the
    error propagates, so the history never records a half-applied step.
    """

    def __init__(self, name: str, commands: Iterable[Command]) -> None:
        self._name = name
        self._commands = tuple(commands)
        if not self._commands:
            raise ValidationError("a macro needs at least one command")

    def execute(self) -> None:
        done: list[Command] = []
        try:
            for command in self._commands:
                command.execute()
                done.append(command)
        except Exception:
            for command in reversed(done):
                command.undo()
            raise

    def undo(self) -> None:
        for command in reversed(self._commands):
            command.undo()

    def describe(self) -> str:
        return f"{self._name}: " + ", ".join(command.describe() for command in self._commands)

Three decisions to say out loud:

  • The inverse is captured during execute. DeleteText cannot know what it will remove until it runs, so it records the removed text then and refuses to undo before that. A command carries exactly the memento it needs, never the document.
  • Coalescing belongs to the command. Only InsertText knows that "Hel" then "lo" at the next position is one edit; the history asks coalesce and replaces the top of the stack without looking at text. Otherwise undo retreats one character at a time.
  • A macro is all or nothing. Parts run in order and undo in reverse; if one fails halfway, the parts that ran are undone before the error propagates.

The invoker:

code/patterns/command.py — the invoker
class CommandHistory:
    """The Invoker: runs commands and keeps the undo and redo stacks. It never reads a command.

    A command is recorded only after it succeeded, so the stacks hold nothing that
    did not happen. A new command abandons the redo branch. Consecutive commands
    that can ``coalesce`` share one undo step, but only when the previous one was
    executed directly (``_last``), never when it was reached by undo or redo. The
    undo stack is bounded, so a long session costs at most ``limit`` commands.
    One history serves one document on one thread; cross-thread work goes through
    a queue of commands instead (see ``run_queued``).
    """

    def __init__(self, limit: int = DEFAULT_HISTORY_LIMIT) -> None:
        if limit < 1:
            raise ValidationError("history needs room for at least one command")
        self._undo: deque[Command] = deque(maxlen=limit)
        self._redo: list[Command] = []
        self._last: Command | None = None

    def execute(self, command: Command) -> None:
        command.execute()
        self._redo.clear()
        merged = None
        if self._undo and self._undo[-1] is self._last:
            merged = self._undo[-1].coalesce(command)
        if merged is None:
            self._undo.append(command)
        else:
            self._undo[-1] = merged
        self._last = self._undo[-1]

    def undo(self) -> None:
        if not self._undo:
            raise InvalidStateError("nothing to undo")
        command = self._undo.pop()
        command.undo()
        self._redo.append(command)
        self._last = None

    def redo(self) -> None:
        if not self._redo:
            raise InvalidStateError("nothing to redo")
        command = self._redo.pop()
        command.execute()
        self._undo.append(command)
        self._last = None

    @property
    def undo_depth(self) -> int:
        return len(self._undo)

    @property
    def redo_depth(self) -> int:
        return len(self._redo)

    def steps(self) -> list[str]:
        """The undo steps as text, oldest first: commands are data, so the history can be shown."""
        return [command.describe() for command in self._undo]

A command is pushed only after it succeeded, so a failing edit leaves both stacks as they were. A new edit clears the redo stack: after undo, then type, the old future is gone. deque(maxlen=limit) bounds the session at limit commands, and _last lets typing merge only into the edit made immediately before it, never into one reached by undo or redo.

One keystroke and its undo: the history talks to the command, the command talks to the document.

sequenceDiagram
    autonumber
    actor U as User
    participant H as CommandHistory
    participant C as InsertText
    participant D as Document
    U->>H: execute(insert)
    activate H
    H->>C: execute()
    C->>D: insert(5, fragment)
    D-->>C: ok
    C-->>H: ok
    Note over H: redo cleared, command pushed or coalesced
    deactivate H
    U->>H: undo()
    activate H
    H->>C: undo()
    C->>D: delete(5, 7)
    D-->>C: removed text
    C-->>H: ok
    Note over H: command moved to the redo stack
    deactivate H

Running python -m patterns.command prints:

--- typing, undo, redo; consecutive inserts coalesce into one step ---
insert 'Hello'   -> 'Hello'          undo depth 1
insert ', world' -> 'Hello, world'   undo depth 1 (coalesced)
replace macro    -> 'Hello, there'   undo depth 2
undo             -> 'Hello, world'   redo depth 1
undo             -> ''               redo depth 2
redo             -> 'Hello, world'   redo depth 1
insert '!'       -> 'Hello, world!'  redo depth 0 (branch abandoned)
undo steps, oldest first:
  insert 'Hello, world' at 0
  insert '!' at 12
rejected: nothing to redo
--- a macro that fails halfway leaves no trace ---
rejected: position 99 is outside the document
text 'Hello, world!', undo depth 2
--- functional variant: partials and (do, undo) pairs ---
after two steps: 'cdef'
after two undos: 'abc'
worker ran 2 queued commands -> 'x'

Pythonic variant

A command with one method is a callable, and functools.partial is its constructor: it binds the receiver and the arguments now so whoever holds the result can call it later, knowing neither. The undo stack then holds (do, undo) pairs:

code/patterns/command.py — partials and (do, undo) pairs
# A command with one method is a callable. ``partial`` binds the receiver and the
# arguments now; whoever holds the result calls it later, knowing neither.
type Action = Callable[[], None]


class UndoStack:
    """Undo and redo over ``(do, undo)`` pairs: the invoker when commands are plain callables."""

    def __init__(self) -> None:
        self._undo: list[tuple[Action, Action]] = []
        self._redo: list[tuple[Action, Action]] = []

    def run(self, do: Action, undo: Action) -> None:
        do()
        self._undo.append((do, undo))
        self._redo.clear()

    def undo(self) -> None:
        if not self._undo:
            raise InvalidStateError("nothing to undo")
        do, undo = self._undo.pop()
        undo()
        self._redo.append((do, undo))

    def redo(self) -> None:
        if not self._redo:
            raise InvalidStateError("nothing to redo")
        do, undo = self._redo.pop()
        do()
        self._undo.append((do, undo))


def insert_step(document: Document, position: int, fragment: str) -> tuple[Action, Action]:
    """The (do, undo) pair for an insert: two partials over the same receiver."""
    return partial(document.insert, position, fragment), partial(document.delete, position, len(fragment))


def delete_step(document: Document, position: int, length: int) -> tuple[Action, Action]:
    """The (do, undo) pair for a delete: the closure keeps the removed text, as ``DeleteText`` kept a field."""
    removed: list[str] = []

    def do() -> None:
        removed.append(document.delete(position, length))

    def undo() -> None:
        document.insert(position, removed.pop())

    return do, undo


def run_queued(queue: deque[Action]) -> int:
    """Queueing: a worker drains commands it never created. Returns how many it ran."""
    ran = 0
    while queue:
        queue.popleft()()
        ran += 1
    return ran
  • partial(document.insert, 0, "Hello") is InsertText without the class. A bound method already carries its receiver; partial adds the arguments.
  • Closures carry the memento. delete_step keeps the removed text where its undo closure can see it, as DeleteText._removed did with a field.
  • A queue of callables is a command queue. run_queued is the whole worker; it never learns what it runs.

When is the callable enough?

Reach for When
partial(method, *args) Deferred or queued execution with no undo: timers, executors, callbacks
(do, undo) pairs on an UndoStack Undo over a few operations that need no name and no merging
A Command subclass The command is inspected after creation: logged, merged, grouped, retried
A frozen dataclass plus a separate executor The command crosses a process boundary and must be serialised

Draw the class diagram, then say "in Python the command is usually a partial on a queue; I promote it to a class when it needs captured state, a log line or merging".

Real-world usage

  • contextlib.ExitStack.callback(fn, *args) and unittest.TestCase.addCleanup: undo commands pushed on a stack and run in reverse on exit, the LIFO discipline of MacroCommand.undo.
  • threading.Timer(interval, function, args) is a command with a due time and a cancel; sched.scheduler.enter keeps such events in a heap, the core of a task scheduler.
  • concurrent.futures.Executor.submit(fn, *args) wraps the request into a work item, queues it for a worker thread and returns a Future.
  • Frameworks: Celery's task.delay serialises a command onto a broker; Qt's QUndoCommand.mergeWith is coalesce; a write-ahead log stores a redo and an undo record per change.
Looks like Command How to tell them apart
Memento Command stores what was done and its inverse, O(change); Memento stores what the state was, O(state). Editors use commands for keystrokes and a memento for operations with no cheap inverse.
Strategy A strategy is one way of performing an operation, chosen by the context; a command is a request to perform it, owned by whoever wants it done, possibly later.
Chain of Responsibility The command is the request; the chain is how it finds a receiver. A withdrawal command travels down the denomination chain.
Observer An observer is told that something happened; a command is told to make something happen. Only the second is undoable.
Mediator A mediator routes messages between colleagues and decides who acts; a command is one of the messages.
Composite MacroCommand is a Composite: a command made of commands, which the history treats like any other.

Where it appears in LLD problems

Interview tips

Interview tip

Lead with the invariant, not the class list: "the history only holds commands that ran; undo pops, reverses and pushes to redo; a new command clears redo." Volunteer the two follow-ups before they are asked, coalesced typing and the history cap, then the Python shortcut: partial on a queue, a class only when the command must be inspected.

Common mistake

An undo that computes its inverse from the receiver's current state. A delete whose undo trusts the current length works once and breaks as soon as another command runs in between. Capture what you need during execute (the removed text, the previous value) and make undo a pure replay of it. Runner-up: pushing a command before it succeeded.