Low-level designCore8 min
Command
Turn each request into an object that knows how to carry itself out, and how to take itself back. Whatever runs requests can then queue, log, undo and redo them without knowing what any of them does.
A seating chart with undo
Planner moves and removes guests, and records each edit as an Action for undo and redo.
1 · The situation
A seating chart with undo
A wedding planner's app arranges guests at tables. SeatingChart knows who sits where; Planner offers the edits a planner makes, move() and remove(), and lets them undo() and redo() because seating plans change a dozen times an hour.
To undo, the first version records each edit as an Action: an ActionType enum, the guest, and the tables it moved from and to. undo() and redo() each switch on the type to reverse or repeat it. Python writes table_of().
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
class SeatingChart {
private final Map<String, Integer> tables = new TreeMap<>();
void seat(String guest, int table) {
tables.put(guest, table);
}
void unseat(String guest) {
tables.remove(guest);
}
int tableOf(String guest) {
return tables.get(guest);
}
String describe() {
Map<Integer, List<String>> byTable = new TreeMap<>();
for (Map.Entry<String, Integer> e : tables.entrySet()) {
byTable.computeIfAbsent(e.getValue(), t -> new ArrayList<>()).add(e.getKey());
}
List<String> parts = new ArrayList<>();
for (Map.Entry<Integer, List<String>> e : byTable.entrySet()) {
parts.add("T" + e.getKey() + ": " + String.join(", ", e.getValue()));
}
return String.join(" | ", parts);
}
}
enum ActionType { MOVE, REMOVE }
record Action(ActionType type, String guest, int from, int to) {}
class Planner {
private final SeatingChart chart;
private final Deque<Action> done = new ArrayDeque<>();
private final Deque<Action> undone = new ArrayDeque<>();
Planner(SeatingChart chart) {
this.chart = chart;
}
void move(String guest, int table) {
done.push(new Action(ActionType.MOVE, guest, chart.tableOf(guest), table));
undone.clear();
chart.seat(guest, table);
}
void remove(String guest) {
done.push(new Action(ActionType.REMOVE, guest, chart.tableOf(guest), 0));
undone.clear();
chart.unseat(guest);
}
void undo() {
Action a = done.pop();
ActionType type = a.type();
switch (type) {
case MOVE -> chart.seat(a.guest(), a.from());
case REMOVE -> chart.seat(a.guest(), a.from());
}
undone.push(a);
}
void redo() {
Action a = undone.pop();
ActionType type = a.type();
switch (type) {
case MOVE -> chart.seat(a.guest(), a.to());
case REMOVE -> chart.unseat(a.guest());
}
done.push(a);
}
}
public class Main {
public static void main(String[] args) {
SeatingChart chart = new SeatingChart();
chart.seat("Ana", 1);
chart.seat("Ben", 1);
chart.seat("Cai", 2);
Planner planner = new Planner(chart);
System.out.println("Start: " + chart.describe());
planner.move("Ana", 2);
System.out.println("Move Ana to 2: " + chart.describe());
planner.remove("Ben");
System.out.println("Remove Ben: " + chart.describe());
planner.undo();
System.out.println("Undo: " + chart.describe());
planner.undo();
System.out.println("Undo: " + chart.describe());
planner.redo();
System.out.println("Redo: " + chart.describe());
}
}The class diagram of this version, drawn from its code: the planner records actions as data and interprets them itself.
Two edits, two undos and a redo:
Start: T1: Ana, Ben | T2: Cai Move Ana to 2: T1: Ben | T2: Ana, Cai Remove Ben: T2: Ana, Cai Undo: T1: Ben | T2: Ana, Cai Undo: T1: Ana, Ben | T2: Cai Redo: T1: Ben | T2: Ana, Cai
2 · The change
A third kind of edit
Planners ask for swap: exchange the seats of two guests in one step, and undo it in one step. In this design a swap touches everything about actions:
class Planner {
private final SeatingChart chart;
private final Deque<Action> done = new ArrayDeque<>();
private final Deque<Action> undone = new ArrayDeque<>();
Planner(SeatingChart chart) {
this.chart = chart;
}
void move(String guest, int table) {
done.push(new Action(ActionType.MOVE, guest, chart.tableOf(guest), table));
undone.clear();
chart.seat(guest, table);
}
void remove(String guest) {
done.push(new Action(ActionType.REMOVE, guest, chart.tableOf(guest), 0));
undone.clear();
chart.unseat(guest);
}
void undo() {
Action a = done.pop();
ActionType type = a.type();
switch (type) {
case MOVE -> chart.seat(a.guest(), a.from());
case REMOVE -> chart.seat(a.guest(), a.from());
}
undone.push(a);
}
void redo() {
Action a = undone.pop();
ActionType type = a.type();
switch (type) {
case MOVE -> chart.seat(a.guest(), a.to());
case REMOVE -> chart.unseat(a.guest());
}
done.push(a);
}
}Place | What swap needs there |
|---|---|
| A new value |
| A second guest and a second table, which every other kind of action carries empty |
| A |
| A new case in each, kept in step with each other |
The knowledge of what an edit is is split in three: swap() performs it, undo() knows how to reverse it, redo() how to repeat it. Each new edit spreads across the same places, and Action grows fields that only one kind uses. The practice checks flag the design: ActionType is switched on in two places.
3 · The refactor
Make each edit an object
Start from the first version. Put everything about one kind of edit, doing it and undoing it, into a class of its own, and keep a history of those objects:
ChartCommand has execute() and undo(). MoveGuest and RemoveGuest implement it. Each holds the chart, and in execute() remembers what it needs to undo: the table the guest came from.
interface ChartCommand {
void execute();
void undo();
}
class MoveGuest implements ChartCommand {
private final SeatingChart chart;
private final String guest;
private final int to;
private int from;
MoveGuest(SeatingChart chart, String guest, int to) {
this.chart = chart;
this.guest = guest;
this.to = to;
}
public void execute() {
from = chart.tableOf(guest);
chart.seat(guest, to);
}
public void undo() {
chart.seat(guest, from);
}
}After the second step the program prints what it printed before. After the third, a swap is done and undone like any other edit:
Start: T1: Ana, Ben | T2: Cai Move Ana to 2: T1: Ben | T2: Ana, Cai Remove Ben: T2: Ana, Cai Undo: T1: Ben | T2: Ana, Cai Undo: T1: Ana, Ben | T2: Cai Redo: T1: Ben | T2: Ana, Cai Swap Ana and Ben: T1: Ana | T2: Ben, Cai Undo: T1: Ben | T2: Ana, Cai
Places in existing code that adding swap edits. Before: ActionType, Action, Planner and its two switches. After: none; SwapGuests is new.
4 · The pattern
Command: a request as an object
Command wraps a request in an object with a uniform method, usually execute(), that carries it out on some receiver. Because a request is now an object, it can be stored, passed around, queued, logged and, given an undo(), reversed. The invoker that runs commands depends only on the command interface.
The roles in the planner's code: ChartCommand is the command, History the invoker, and SeatingChart the receiver every command acts on.
Role | In the planner | Its job |
|---|---|---|
Command |
| The interface: |
Concrete command |
| One request each, with what it needs to undo it |
Receiver |
| Does the actual work the commands ask for |
Invoker |
| Runs commands and keeps them, without knowing what they do |
Client |
| Creates commands and hands them to the invoker |
The command remembers table 1 when it runs, so undoing it needs no help from anyone else.
Undo is only one thing a request-as-object allows:
- Queues and scheduling. Commands can be stored now and run later, or on another thread.
- Logs and replay. Recording commands lets you replay a session, or rebuild state after a crash.
- Macros. A command that holds a list of commands and runs them in order is a command too, a Composite of commands.
5 · What it costs
A class per request, and undo that must be exact
- A class per kind of request. Three edits are three classes and an interface, where there was one method each.
- Undo must restore exactly. A command saves what it needs when it runs. If the receiver can change in other ways between
execute()andundo(), say a table is deleted, undo can restore a state that no longer makes sense. - Some requests can't be undone. Sending the seating plan to the caterer can't be taken back. Such commands need to say so, and the history should stop at them.
- History uses memory. An unbounded undo stack grows for the whole session; cap it.
Situation | Command? |
|---|---|
Requests must be undone, redone, queued or logged | Yes |
One call, made immediately, never stored | No: call the method |
Requests have no state and no undo, and the language has functions | Often a plain function or lambda is the command |
Undo by restoring a snapshot is simpler than reversing each request | Consider Memento instead, or with it |
6 · Getting it right
Capture at execute, restore at undo
- Capture what undo needs in
execute(), not in the constructor.MoveGuestreads the old table when it runs, so redo after other edits still works. - Keep commands thin. The receiver does the work; a command decides which receiver calls to make. A command full of seating logic duplicates the chart.
- A new command clears redo. After an undo, running a new command makes the undone ones meaningless, so
run()empties the redo stack. - Bound the history, and mark commands that can't be undone.
Language | The usual shape |
|---|---|
Java | An interface and one class per command; |
C++ | An abstract class owned through |
JavaScript | Objects with |
Python | Classes matching a |
7 · Look-alikes
Objects that stand for behavior
Several patterns put behavior in an object. What differs is what the object stands for:
Pattern | The object stands for | Typical use |
|---|---|---|
Command | One request, often with its own undo | Undo, queues, logs, macros |
Strategy | One way of doing a job, swapped in | Choosing an algorithm |
Memento | A snapshot of state, not a request | Undo by restoring the snapshot |
Chain of Responsibility | One handler among several for a request | Passing a request until something handles it |
Command and Memento are the two classic ways to undo. Command undoes by reversing each request, which keeps history small but needs a correct reversal per command. Memento undoes by restoring a saved snapshot, which needs no reversal logic but stores more. Editors often combine them: a command that keeps a memento of what it changed.
8 · In practice
Commands you have already used
Java's Executor runs submitted Runnable tasks and decouples submitting a task from how it's run, on which thread and when. Each Runnable is a command with no undo: an object standing for a request, handed to an invoker that doesn't know what it does.
The practice checks recognize a Command when an interface of one or two methods has at least two implementations that each hold a receiver and call it, and something else runs them through the interface; a method named like undo() counts as a further sign.
Check yourself
4 questions
Takeaways
Remember this
- Command turns a request into an object with a uniform method, such as execute().
- The invoker runs and stores commands without knowing what they do.
- A command that captures what it changed can undo itself; a history of commands gives undo and redo.
- Commands also enable queues, logs, replay and macros.
- Capture undo state at execute time, and keep the work in the receiver.