Low Level DesignDesign patterns
Command pattern
The Command design pattern through three real examples, from editor toolbars and shortcuts to undo and redo to a retry-safe job queue, with diagrams, live demos and code in five languages.
- Read first
- StrategySingle responsibility
A command is a request turned into an object. Say the code wants to make text bold. Instead of
calling document.bold() directly, it builds a BoldCommand. That object knows what to do, and to
whom. The code hands it to whoever will run it. Because the request is now a value, it can be passed
around and run from several places. It can be stored, undone and logged. It can even be put on a
queue, and run later by another process.
You have used it without the name. Every action in VS Code’s command palette is a registered
command. Swing’s Action is shared by a menu item and a toolbar button. WPF’s ICommand has
Execute and CanExecute. Background job systems such as Celery put a task name and its arguments
onto a queue. This page builds in three steps. First, one action shared by three triggers. Then undo
and redo. Then commands that cross a network, and must survive crashes and retries.
The shape
Section titled “The shape”Every command setup has the same parts:
- The command interface declares
execute(), and oftenundo()orcanExecute(). - Concrete commands implement it. Each holds a reference to its receiver and the
arguments it needs, for example
DeleteRange(start, end)on a document. - The receiver does the real work, for example
Document. It knows nothing about commands. - The invoker triggers commands without knowing what they do: a button, a key map, an undo history, a job worker.
- The client builds concrete commands, connects them to receivers and gives them to invokers. It’s usually the startup code.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
The invoker has no arrow to the receiver. That gap is the pattern. The button doesn’t know a document exists. So the same button class works for any action, and the same action works from any button.
Worked examples
Section titled “Worked examples”One action, three triggers
- The basic shape
- Invokers that share one action
- One place to check and log
A notes app has a toolbar, a menu bar and keyboard shortcuts. Bold is offered in all three: the B button, Format › Bold, and Ctrl+B. So is Delete. All three must behave the same way. All three must be greyed out when nothing is selected. And the product team wants a log of which actions people use, and from where.
Without the pattern
Section titled “Without the pattern”Each piece of UI edits the document itself:
// The document the editor shows: some text and a selection [start, end).class Document { String text; int start, end; Document(String text) { this.text = text; }
void select(int start, int end) { this.start = start; this.end = end; } String selected() { return text.substring(start, end); } void replaceSelection(String s) { text = text.substring(0, start) + s + text.substring(end); end = start + s.length(); }}
// Each piece of UI edits the document itself, so the logic for "bold" lives in three places.class Toolbar { private final Document doc; Toolbar(Document doc) { this.doc = doc; }
void onBoldClick() { if (doc.start == doc.end) return; // nothing selected doc.replaceSelection("**" + doc.selected() + "**"); }}
class Menu { private final Document doc; Menu(Document doc) { this.doc = doc; }
void onBold() { // A copy of the toolbar's code. A fix to one copy has to be made to every copy. if (doc.start == doc.end) return; doc.replaceSelection("**" + doc.selected() + "**"); }
void onDelete() { doc.replaceSelection(""); }}
class Shortcuts { private final Document doc; Shortcuts(Document doc) { this.doc = doc; }
void onKey(String keys) { // Another copy, and this one forgot the empty-selection check. if (keys.equals("Ctrl+B")) doc.replaceSelection("**" + doc.selected() + "**"); else if (keys.equals("Delete")) doc.replaceSelection(""); }}
class Main { public static void main(String[] args) { Document doc = new Document("ship the release today"); Toolbar toolbar = new Toolbar(doc); Menu menu = new Menu(doc); Shortcuts keys = new Shortcuts(doc);
doc.select(9, 16); toolbar.onBoldClick(); System.out.println(doc.text); // ship the **release** today
doc.select(20, 26); keys.onKey("Delete"); System.out.println(doc.text); // ship the **release**
// The delete left nothing selected. The toolbar would do nothing here; the shortcut's copy // inserts stray markers. keys.onKey("Ctrl+B"); System.out.println(doc.text); // ship the **release******
// And there is no single place to log actions, grey out buttons, or add undo later. }}# The document the editor shows: some text and a selection [start, end).class Document: def __init__(self, text: str) -> None: self.text = text self.start = self.end = 0
def select(self, start: int, end: int) -> None: self.start, self.end = start, end
def selected(self) -> str: return self.text[self.start : self.end]
def replace_selection(self, s: str) -> None: self.text = self.text[: self.start] + s + self.text[self.end :] self.end = self.start + len(s)
# Each piece of UI edits the document itself, so the logic for "bold" lives in three places.class Toolbar: def __init__(self, doc: Document) -> None: self.doc = doc
def on_bold_click(self) -> None: if self.doc.start == self.doc.end: return # nothing selected self.doc.replace_selection(f"**{self.doc.selected()}**")
class Menu: def __init__(self, doc: Document) -> None: self.doc = doc
def on_bold(self) -> None: # A copy of the toolbar's code. A fix to one copy has to be made to every copy. if self.doc.start == self.doc.end: return self.doc.replace_selection(f"**{self.doc.selected()}**")
def on_delete(self) -> None: self.doc.replace_selection("")
class Shortcuts: def __init__(self, doc: Document) -> None: self.doc = doc
def on_key(self, keys: str) -> None: # Another copy, and this one forgot the empty-selection check. if keys == "Ctrl+B": self.doc.replace_selection(f"**{self.doc.selected()}**") elif keys == "Delete": self.doc.replace_selection("")
doc = Document("ship the release today")toolbar, menu, keys = Toolbar(doc), Menu(doc), Shortcuts(doc)
doc.select(9, 16)toolbar.on_bold_click()print(doc.text) # ship the **release** today
doc.select(20, 26)keys.on_key("Delete")print(doc.text) # ship the **release**
# The delete left nothing selected. The toolbar would do nothing here; the shortcut's copy# inserts stray markers.keys.on_key("Ctrl+B")print(doc.text) # ship the **release******
# And there is no single place to log actions, grey out buttons, or add undo later.using System;
// The document the editor shows: some text and a selection [Start, End).public sealed class Document{ public string Text { get; private set; } public int Start { get; private set; } public int End { get; private set; } public Document(string text) => Text = text;
public void Select(int start, int end) { Start = start; End = end; } public string Selected() => Text[Start..End]; public void ReplaceSelection(string s) { Text = Text[..Start] + s + Text[End..]; End = Start + s.Length; }}
// Each piece of UI edits the document itself, so the logic for "bold" lives in three places.public sealed class Toolbar{ private readonly Document _doc; public Toolbar(Document doc) => _doc = doc;
public void OnBoldClick() { if (_doc.Start == _doc.End) return; // nothing selected _doc.ReplaceSelection($"**{_doc.Selected()}**"); }}
public sealed class Menu{ private readonly Document _doc; public Menu(Document doc) => _doc = doc;
public void OnBold() { // A copy of the toolbar's code. A fix to one copy has to be made to every copy. if (_doc.Start == _doc.End) return; _doc.ReplaceSelection($"**{_doc.Selected()}**"); }
public void OnDelete() => _doc.ReplaceSelection("");}
public sealed class Shortcuts{ private readonly Document _doc; public Shortcuts(Document doc) => _doc = doc;
public void OnKey(string keys) { // Another copy, and this one forgot the empty-selection check. if (keys == "Ctrl+B") _doc.ReplaceSelection($"**{_doc.Selected()}**"); else if (keys == "Delete") _doc.ReplaceSelection(""); }}
public static class Program{ public static void Main() { var doc = new Document("ship the release today"); var toolbar = new Toolbar(doc); var menu = new Menu(doc); var keys = new Shortcuts(doc);
doc.Select(9, 16); toolbar.OnBoldClick(); Console.WriteLine(doc.Text); // ship the **release** today
doc.Select(20, 26); keys.OnKey("Delete"); Console.WriteLine(doc.Text); // ship the **release**
// The delete left nothing selected. The toolbar would do nothing here; the shortcut's copy // inserts stray markers. keys.OnKey("Ctrl+B"); Console.WriteLine(doc.Text); // ship the **release******
// And there is no single place to log actions, grey out buttons, or add undo later. }}package main
import "fmt"
// Document is what the editor shows: some text and a selection [Start, End).type Document struct { Text string Start, End int}
func (d *Document) Select(start, end int) { d.Start, d.End = start, end }func (d *Document) Selected() string { return d.Text[d.Start:d.End] }func (d *Document) ReplaceSelection(s string) { d.Text = d.Text[:d.Start] + s + d.Text[d.End:] d.End = d.Start + len(s)}
// Each piece of UI edits the document itself, so the logic for "bold" lives in three places.type Toolbar struct{ Doc *Document }
func (t Toolbar) OnBoldClick() { if t.Doc.Start == t.Doc.End { return // nothing selected } t.Doc.ReplaceSelection("**" + t.Doc.Selected() + "**")}
type Menu struct{ Doc *Document }
func (m Menu) OnBold() { // A copy of the toolbar's code. A fix to one copy has to be made to every copy. if m.Doc.Start == m.Doc.End { return } m.Doc.ReplaceSelection("**" + m.Doc.Selected() + "**")}
func (m Menu) OnDelete() { m.Doc.ReplaceSelection("") }
type Shortcuts struct{ Doc *Document }
func (s Shortcuts) OnKey(keys string) { // Another copy, and this one forgot the empty-selection check. switch keys { case "Ctrl+B": s.Doc.ReplaceSelection("**" + s.Doc.Selected() + "**") case "Delete": s.Doc.ReplaceSelection("") }}
func main() { doc := &Document{Text: "ship the release today"} toolbar, keys := Toolbar{doc}, Shortcuts{doc} _ = Menu{doc}
doc.Select(9, 16) toolbar.OnBoldClick() fmt.Println(doc.Text) // ship the **release** today
doc.Select(20, 26) keys.OnKey("Delete") fmt.Println(doc.Text) // ship the **release**
// The delete left nothing selected. The toolbar would do nothing here; the shortcut's copy // inserts stray markers. keys.OnKey("Ctrl+B") fmt.Println(doc.Text) // ship the **release******
// And there is no single place to log actions, grey out buttons, or add undo later.}// The document the editor shows: some text and a selection [start, end).class Document { start = 0; end = 0; constructor(public text: string) {}
select(start: number, end: number): void { this.start = start; this.end = end; } selected(): string { return this.text.slice(this.start, this.end); } replaceSelection(s: string): void { this.text = this.text.slice(0, this.start) + s + this.text.slice(this.end); this.end = this.start + s.length; }}
// Each piece of UI edits the document itself, so the logic for "bold" lives in three places.class Toolbar { constructor(private readonly doc: Document) {} onBoldClick(): void { if (this.doc.start === this.doc.end) return; // nothing selected this.doc.replaceSelection(`**${this.doc.selected()}**`); }}
class Menu { constructor(private readonly doc: Document) {} onBold(): void { // A copy of the toolbar's code. A fix to one copy has to be made to every copy. if (this.doc.start === this.doc.end) return; this.doc.replaceSelection(`**${this.doc.selected()}**`); } onDelete(): void { this.doc.replaceSelection(''); }}
class Shortcuts { constructor(private readonly doc: Document) {} onKey(keys: string): void { // Another copy, and this one forgot the empty-selection check. if (keys === 'Ctrl+B') this.doc.replaceSelection(`**${this.doc.selected()}**`); else if (keys === 'Delete') this.doc.replaceSelection(''); }}
const doc = new Document('ship the release today');const toolbar = new Toolbar(doc);const menu = new Menu(doc);const keys = new Shortcuts(doc);void menu;
doc.select(9, 16);toolbar.onBoldClick();console.log(doc.text); // ship the **release** today
doc.select(20, 26);keys.onKey('Delete');console.log(doc.text); // ship the **release**
// The delete left nothing selected. The toolbar would do nothing here; the shortcut's copy// inserts stray markers.keys.onKey('Ctrl+B');console.log(doc.text); // ship the **release******
// And there is no single place to log actions, grey out buttons, or add undo later.The logic for bold is written three times. The keyboard’s copy was written later, and it missed the
empty-selection check. So Ctrl+B with nothing selected inserts ****. There is no single place to
grey out the toolbar and the menu together, or to log usage. And every new trigger (a right-click
menu, a command palette) means another copy.
With Command
Section titled “With Command”Make each action an object with execute() and canExecute(). Every invoker holds a command
and runs it through one CommandRunner, which checks, executes and logs:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Select a word, then use the toolbar, the menu or the keyboard. Switch to the other design and press Ctrl+B with nothing selected:
Click a word to select it; shift-click to extend. While the text has focus, Ctrl+B (⌘B) and Delete work too.
Command objects
- BoldCommandused by B, Format › Bold, Ctrl+B0 runs
- DeleteCommandused by Delete, Edit › Delete, Delete key0 runs
No command objects. Each button and key handler edits the document with its own copy of the code.
Runner log
Click "release" to select it, then make it bold from any of the three places.
import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;
// The receiver: the object that knows how to change the text. It knows nothing about buttons.class Document { String text; int start, end; Document(String text) { this.text = text; }
void select(int start, int end) { this.start = start; this.end = end; } String selected() { return text.substring(start, end); } void replaceSelection(String s) { text = text.substring(0, start) + s + text.substring(end); end = start + s.length(); }}
// One object per action. Whoever triggers it only needs this interface.interface Command { String name(); // Every invoker asks the same question, so buttons, menus and keys enable together. boolean canExecute(); void execute();}
class BoldCommand implements Command { private final Document doc; BoldCommand(Document doc) { this.doc = doc; } public String name() { return "bold"; } public boolean canExecute() { return doc.start < doc.end; } public void execute() { doc.replaceSelection("**" + doc.selected() + "**"); }}
class DeleteCommand implements Command { private final Document doc; DeleteCommand(Document doc) { this.doc = doc; } public String name() { return "delete"; } public boolean canExecute() { return doc.start < doc.end; } public void execute() { doc.replaceSelection(""); }}
// Every invoker goes through one runner, so checking, logging (and later, undo) happen once.class CommandRunner { final List<String> log = new ArrayList<>();
void run(Command cmd, String source) { if (!cmd.canExecute()) { System.out.println("[" + source + "] " + cmd.name() + " skipped: nothing selected"); return; } cmd.execute(); log.add(cmd.name() + " via " + source); System.out.println("[" + source + "] " + cmd.name()); }}
// Invokers hold a command and trigger it. None of them knows what bold means.class ToolbarButton { private final Command cmd; private final CommandRunner runner; ToolbarButton(Command cmd, CommandRunner runner) { this.cmd = cmd; this.runner = runner; } void click() { runner.run(cmd, "toolbar"); }}
class MenuItem { final String label; private final Command cmd; private final CommandRunner runner; MenuItem(String label, Command cmd, CommandRunner runner) { this.label = label; this.cmd = cmd; this.runner = runner; } boolean enabled() { return cmd.canExecute(); } void click() { runner.run(cmd, "menu"); }}
class Keymap { private final CommandRunner runner; private final Map<String, Command> bindings = new HashMap<>(); Keymap(CommandRunner runner) { this.runner = runner; } void bind(String keys, Command cmd) { bindings.put(keys, cmd); } void press(String keys) { Command cmd = bindings.get(keys); if (cmd != null) runner.run(cmd, keys); }}
class Main { public static void main(String[] args) { Document doc = new Document("ship the release today"); CommandRunner runner = new CommandRunner();
// One command object per action, shared by every invoker that offers it. Command bold = new BoldCommand(doc), delete = new DeleteCommand(doc); ToolbarButton boldButton = new ToolbarButton(bold, runner); MenuItem formatBold = new MenuItem("Format > Bold", bold, runner); MenuItem editDelete = new MenuItem("Edit > Delete", delete, runner); Keymap keys = new Keymap(runner); keys.bind("Ctrl+B", bold); keys.bind("Delete", delete);
doc.select(9, 16); boldButton.click(); // [toolbar] bold System.out.println(doc.text); // ship the **release** today
doc.select(20, 26); keys.press("Delete"); // [Delete] delete System.out.println(doc.text); // ship the **release**
// Nothing is selected now. The shortcut asks the same command, so it can't insert stray markers. keys.press("Ctrl+B"); // [Ctrl+B] bold skipped: nothing selected String state = editDelete.enabled() ? "enabled" : "greyed out"; System.out.println(editDelete.label + " is " + state); // Edit > Delete is greyed out
doc.select(0, 4); formatBold.click(); // [menu] bold System.out.println(doc.text); // **ship** the **release** System.out.println("log: " + String.join(", ", runner.log)); // log: bold via toolbar, delete via Delete, bold via menu }}from typing import Protocol
# The receiver: the object that knows how to change the text. It knows nothing about buttons.class Document: def __init__(self, text: str) -> None: self.text = text self.start = self.end = 0
def select(self, start: int, end: int) -> None: self.start, self.end = start, end
def selected(self) -> str: return self.text[self.start : self.end]
def replace_selection(self, s: str) -> None: self.text = self.text[: self.start] + s + self.text[self.end :] self.end = self.start + len(s)
# One object per action. Whoever triggers it only needs this interface.class Command(Protocol): name: str
# Every invoker asks the same question, so buttons, menus and keys enable together. def can_execute(self) -> bool: ... def execute(self) -> None: ...
class BoldCommand: name = "bold"
def __init__(self, doc: Document) -> None: self.doc = doc
def can_execute(self) -> bool: return self.doc.start < self.doc.end
def execute(self) -> None: self.doc.replace_selection(f"**{self.doc.selected()}**")
class DeleteCommand: name = "delete"
def __init__(self, doc: Document) -> None: self.doc = doc
def can_execute(self) -> bool: return self.doc.start < self.doc.end
def execute(self) -> None: self.doc.replace_selection("")
# Every invoker goes through one runner, so checking, logging (and later, undo) happen once.class CommandRunner: def __init__(self) -> None: self.log: list[str] = []
def run(self, cmd: Command, source: str) -> None: if not cmd.can_execute(): print(f"[{source}] {cmd.name} skipped: nothing selected") return cmd.execute() self.log.append(f"{cmd.name} via {source}") print(f"[{source}] {cmd.name}")
# Invokers hold a command and trigger it. None of them knows what bold means.class ToolbarButton: def __init__(self, cmd: Command, runner: CommandRunner) -> None: self.cmd, self.runner = cmd, runner
def click(self) -> None: self.runner.run(self.cmd, "toolbar")
class MenuItem: def __init__(self, label: str, cmd: Command, runner: CommandRunner) -> None: self.label, self.cmd, self.runner = label, cmd, runner
def enabled(self) -> bool: return self.cmd.can_execute()
def click(self) -> None: self.runner.run(self.cmd, "menu")
class Keymap: def __init__(self, runner: CommandRunner) -> None: self.runner = runner self.bindings: dict[str, Command] = {}
def bind(self, keys: str, cmd: Command) -> None: self.bindings[keys] = cmd
def press(self, keys: str) -> None: if keys in self.bindings: self.runner.run(self.bindings[keys], keys)
doc = Document("ship the release today")runner = CommandRunner()
# One command object per action, shared by every invoker that offers it.bold, delete = BoldCommand(doc), DeleteCommand(doc)bold_button = ToolbarButton(bold, runner)format_bold = MenuItem("Format > Bold", bold, runner)edit_delete = MenuItem("Edit > Delete", delete, runner)keys = Keymap(runner)keys.bind("Ctrl+B", bold)keys.bind("Delete", delete)
doc.select(9, 16)bold_button.click() # [toolbar] boldprint(doc.text) # ship the **release** today
doc.select(20, 26)keys.press("Delete") # [Delete] deleteprint(doc.text) # ship the **release**
# Nothing is selected now. The shortcut asks the same command, so it can't insert stray markers.keys.press("Ctrl+B") # [Ctrl+B] bold skipped: nothing selectedstate = "enabled" if edit_delete.enabled() else "greyed out"print(f"{edit_delete.label} is {state}") # Edit > Delete is greyed out
doc.select(0, 4)format_bold.click() # [menu] boldprint(doc.text) # **ship** the **release**print("log: " + ", ".join(runner.log)) # log: bold via toolbar, delete via Delete, bold via menuusing System;using System.Collections.Generic;
// The receiver: the object that knows how to change the text. It knows nothing about buttons.public sealed class Document{ public string Text { get; private set; } public int Start { get; private set; } public int End { get; private set; } public Document(string text) => Text = text;
public void Select(int start, int end) { Start = start; End = end; } public string Selected() => Text[Start..End]; public void ReplaceSelection(string s) { Text = Text[..Start] + s + Text[End..]; End = Start + s.Length; }}
// One object per action. Whoever triggers it only needs this interface.public interface ICommand{ string Name { get; } // Every invoker asks the same question, so buttons, menus and keys enable together. bool CanExecute(); void Execute();}
public sealed class BoldCommand : ICommand{ private readonly Document _doc; public BoldCommand(Document doc) => _doc = doc; public string Name => "bold"; public bool CanExecute() => _doc.Start < _doc.End; public void Execute() => _doc.ReplaceSelection($"**{_doc.Selected()}**");}
public sealed class DeleteCommand : ICommand{ private readonly Document _doc; public DeleteCommand(Document doc) => _doc = doc; public string Name => "delete"; public bool CanExecute() => _doc.Start < _doc.End; public void Execute() => _doc.ReplaceSelection("");}
// Every invoker goes through one runner, so checking, logging (and later, undo) happen once.public sealed class CommandRunner{ public List<string> Log { get; } = new();
public void Run(ICommand cmd, string source) { if (!cmd.CanExecute()) { Console.WriteLine($"[{source}] {cmd.Name} skipped: nothing selected"); return; } cmd.Execute(); Log.Add($"{cmd.Name} via {source}"); Console.WriteLine($"[{source}] {cmd.Name}"); }}
// Invokers hold a command and trigger it. None of them knows what bold means.public sealed class ToolbarButton{ private readonly ICommand _cmd; private readonly CommandRunner _runner; public ToolbarButton(ICommand cmd, CommandRunner runner) { _cmd = cmd; _runner = runner; } public void Click() => _runner.Run(_cmd, "toolbar");}
public sealed class MenuItem{ public string Label { get; } private readonly ICommand _cmd; private readonly CommandRunner _runner; public MenuItem(string label, ICommand cmd, CommandRunner runner) { Label = label; _cmd = cmd; _runner = runner; } public bool Enabled() => _cmd.CanExecute(); public void Click() => _runner.Run(_cmd, "menu");}
public sealed class Keymap{ private readonly CommandRunner _runner; private readonly Dictionary<string, ICommand> _bindings = new(); public Keymap(CommandRunner runner) => _runner = runner; public void Bind(string keys, ICommand cmd) => _bindings[keys] = cmd; public void Press(string keys) { if (_bindings.TryGetValue(keys, out var cmd)) _runner.Run(cmd, keys); }}
public static class Program{ public static void Main() { var doc = new Document("ship the release today"); var runner = new CommandRunner();
// One command object per action, shared by every invoker that offers it. ICommand bold = new BoldCommand(doc), delete = new DeleteCommand(doc); var boldButton = new ToolbarButton(bold, runner); var formatBold = new MenuItem("Format > Bold", bold, runner); var editDelete = new MenuItem("Edit > Delete", delete, runner); var keys = new Keymap(runner); keys.Bind("Ctrl+B", bold); keys.Bind("Delete", delete);
doc.Select(9, 16); boldButton.Click(); // [toolbar] bold Console.WriteLine(doc.Text); // ship the **release** today
doc.Select(20, 26); keys.Press("Delete"); // [Delete] delete Console.WriteLine(doc.Text); // ship the **release**
// Nothing is selected now. The shortcut asks the same command, so it can't insert stray markers. keys.Press("Ctrl+B"); // [Ctrl+B] bold skipped: nothing selected var state = editDelete.Enabled() ? "enabled" : "greyed out"; Console.WriteLine($"{editDelete.Label} is {state}"); // Edit > Delete is greyed out
doc.Select(0, 4); formatBold.Click(); // [menu] bold Console.WriteLine(doc.Text); // **ship** the **release** Console.WriteLine("log: " + string.Join(", ", runner.Log)); // log: bold via toolbar, delete via Delete, bold via menu }}package main
import ( "fmt" "strings")
// Document is the receiver: it knows how to change the text. It knows nothing about buttons.type Document struct { Text string Start, End int}
func (d *Document) Select(start, end int) { d.Start, d.End = start, end }func (d *Document) Selected() string { return d.Text[d.Start:d.End] }func (d *Document) ReplaceSelection(s string) { d.Text = d.Text[:d.Start] + s + d.Text[d.End:] d.End = d.Start + len(s)}
// Command is one action as an object. Whoever triggers it only needs this interface.type Command interface { Name() string // Every invoker asks the same question, so buttons, menus and keys enable together. CanExecute() bool Execute()}
type BoldCommand struct{ Doc *Document }
func (c BoldCommand) Name() string { return "bold" }func (c BoldCommand) CanExecute() bool { return c.Doc.Start < c.Doc.End }func (c BoldCommand) Execute() { c.Doc.ReplaceSelection("**" + c.Doc.Selected() + "**") }
type DeleteCommand struct{ Doc *Document }
func (c DeleteCommand) Name() string { return "delete" }func (c DeleteCommand) CanExecute() bool { return c.Doc.Start < c.Doc.End }func (c DeleteCommand) Execute() { c.Doc.ReplaceSelection("") }
// CommandRunner is the one path every invoker takes, so checking, logging (and later, undo)// happen once.type CommandRunner struct{ Log []string }
func (r *CommandRunner) Run(cmd Command, source string) { if !cmd.CanExecute() { fmt.Printf("[%s] %s skipped: nothing selected\n", source, cmd.Name()) return } cmd.Execute() r.Log = append(r.Log, cmd.Name()+" via "+source) fmt.Printf("[%s] %s\n", source, cmd.Name())}
// Invokers hold a command and trigger it. None of them knows what bold means.type ToolbarButton struct { Cmd Command Runner *CommandRunner}
func (b ToolbarButton) Click() { b.Runner.Run(b.Cmd, "toolbar") }
type MenuItem struct { Label string Cmd Command Runner *CommandRunner}
func (m MenuItem) Enabled() bool { return m.Cmd.CanExecute() }func (m MenuItem) Click() { m.Runner.Run(m.Cmd, "menu") }
type Keymap struct { Runner *CommandRunner Bindings map[string]Command}
func (k Keymap) Bind(keys string, cmd Command) { k.Bindings[keys] = cmd }func (k Keymap) Press(keys string) { if cmd, ok := k.Bindings[keys]; ok { k.Runner.Run(cmd, keys) }}
func main() { doc := &Document{Text: "ship the release today"} runner := &CommandRunner{}
// One command value per action, shared by every invoker that offers it. bold, del := BoldCommand{doc}, DeleteCommand{doc} boldButton := ToolbarButton{bold, runner} formatBold := MenuItem{"Format > Bold", bold, runner} editDelete := MenuItem{"Edit > Delete", del, runner} keys := Keymap{runner, map[string]Command{}} keys.Bind("Ctrl+B", bold) keys.Bind("Delete", del)
doc.Select(9, 16) boldButton.Click() // [toolbar] bold fmt.Println(doc.Text) // ship the **release** today
doc.Select(20, 26) keys.Press("Delete") // [Delete] delete fmt.Println(doc.Text) // ship the **release**
// Nothing is selected now. The shortcut asks the same command, so it can't insert stray markers. keys.Press("Ctrl+B") // [Ctrl+B] bold skipped: nothing selected state := "greyed out" if editDelete.Enabled() { state = "enabled" } fmt.Printf("%s is %s\n", editDelete.Label, state) // Edit > Delete is greyed out
doc.Select(0, 4) formatBold.Click() // [menu] bold fmt.Println(doc.Text) // **ship** the **release** fmt.Println("log: " + strings.Join(runner.Log, ", ")) // log: bold via toolbar, delete via Delete, bold via menu}// The receiver: the object that knows how to change the text. It knows nothing about buttons.class Document { start = 0; end = 0; constructor(public text: string) {}
select(start: number, end: number): void { this.start = start; this.end = end; } selected(): string { return this.text.slice(this.start, this.end); } replaceSelection(s: string): void { this.text = this.text.slice(0, this.start) + s + this.text.slice(this.end); this.end = this.start + s.length; }}
// One object per action. Whoever triggers it only needs this interface.interface Command { readonly name: string; // Every invoker asks the same question, so buttons, menus and keys enable together. canExecute(): boolean; execute(): void;}
class BoldCommand implements Command { readonly name = 'bold'; constructor(private readonly doc: Document) {} canExecute(): boolean { return this.doc.start < this.doc.end; } execute(): void { this.doc.replaceSelection(`**${this.doc.selected()}**`); }}
class DeleteCommand implements Command { readonly name = 'delete'; constructor(private readonly doc: Document) {} canExecute(): boolean { return this.doc.start < this.doc.end; } execute(): void { this.doc.replaceSelection(''); }}
// Every invoker goes through one runner, so checking, logging (and later, undo) happen once.class CommandRunner { readonly log: string[] = []; run(cmd: Command, source: string): void { if (!cmd.canExecute()) { console.log(`[${source}] ${cmd.name} skipped: nothing selected`); return; } cmd.execute(); this.log.push(`${cmd.name} via ${source}`); console.log(`[${source}] ${cmd.name}`); }}
// Invokers hold a command and trigger it. None of them knows what bold means.class ToolbarButton { constructor(private readonly cmd: Command, private readonly runner: CommandRunner) {} click(): void { this.runner.run(this.cmd, 'toolbar'); }}
class MenuItem { constructor( readonly label: string, private readonly cmd: Command, private readonly runner: CommandRunner, ) {} enabled(): boolean { return this.cmd.canExecute(); } click(): void { this.runner.run(this.cmd, 'menu'); }}
class Keymap { private readonly bindings = new Map<string, Command>(); constructor(private readonly runner: CommandRunner) {} bind(keys: string, cmd: Command): void { this.bindings.set(keys, cmd); } press(keys: string): void { const cmd = this.bindings.get(keys); if (cmd) this.runner.run(cmd, keys); }}
const doc = new Document('ship the release today');const runner = new CommandRunner();
// One command object per action, shared by every invoker that offers it.const bold = new BoldCommand(doc);const del = new DeleteCommand(doc);const boldButton = new ToolbarButton(bold, runner);const formatBold = new MenuItem('Format > Bold', bold, runner);const editDelete = new MenuItem('Edit > Delete', del, runner);const keys = new Keymap(runner);keys.bind('Ctrl+B', bold);keys.bind('Delete', del);
doc.select(9, 16);boldButton.click(); // [toolbar] boldconsole.log(doc.text); // ship the **release** today
doc.select(20, 26);keys.press('Delete'); // [Delete] deleteconsole.log(doc.text); // ship the **release**
// Nothing is selected now. The shortcut asks the same command, so it can't insert stray markers.keys.press('Ctrl+B'); // [Ctrl+B] bold skipped: nothing selectedconst state = editDelete.enabled() ? 'enabled' : 'greyed out';console.log(`${editDelete.label} is ${state}`); // Edit > Delete is greyed out
doc.select(0, 4);formatBold.click(); // [menu] boldconsole.log(doc.text); // **ship** the **release**console.log('log: ' + runner.log.join(', ')); // log: bold via toolbar, delete via Delete, bold via menuWhat to notice
Section titled “What to notice”- One object per action, shared. The toolbar’s B, Format › Bold and Ctrl+B hold the
same
BoldCommand. A fix to bold is made once, and every trigger gets it. - Invokers are generic.
ToolbarButtonandMenuItemdon’t know what bold is. A command palette or a right-click menu is just a new invoker over the existing commands. A new action is a new command, and every invoker can offer it. canExecutekeeps the UI consistent. Every invoker asks the command. So the toolbar and the menu grey out together, and the shortcut can’t do something the button wouldn’t. WPF’sICommandand Swing’sAction(withisEnabled) are built around the same idea.- The runner is a single choke point. Checking, logging and timing happen once, for every action from every source. In the Medium level this is where undo history goes.
- The receiver stays simple.
Documentknows how to replace a selection. It doesn’t know about menus, shortcuts or logs, which is single responsibility applied to UI actions.
Our editor has bold in the toolbar, the menu and Ctrl+B, and they keep drifting apart. Now we’re adding a command palette. How would you structure the actions?
Whether you make each action an object with execute and canExecute, shared by every invoker
and run through one place that can log. Copying the logic into a fourth handler, or a giant
switch (actionName) that each UI element calls, is the red flag.
I’ll make each action a command object, with execute and canExecute. The toolbar, the menu, the shortcuts and the palette are invokers. Each holds a command, and runs it through one runner. The document only knows how to edit text. A new trigger is a new invoker. A new action is a new command.
Undo and redo
- undo() next to execute()
- Undo and redo stacks
- Merging keystrokes into one step
- Commands vs. snapshots (Memento)
The same editor now needs undo and redo. Users expect three things from any editor they’ve used. Undo takes back a word they typed, not one letter. Redo works until they make a new change. And undo of a delete brings back exactly what was deleted.
Without the pattern
Section titled “Without the pattern”Record the name of each action, and reverse it in one big method:
import java.util.ArrayList;import java.util.List;
// Undo without command objects: the editor records the NAME of each action, and one big// method tries to work out how to reverse it after the fact.class Editor { String text = ""; final List<Object[]> history = new ArrayList<>(); // {action, position, length}
void insert(int pos, String s) { text = text.substring(0, pos) + s + text.substring(pos); history.add(new Object[] {"insert", pos, s.length()}); }
void delete(int start, int end) { text = text.substring(0, start) + text.substring(end); // Only the position and length are recorded. The removed text is gone. history.add(new Object[] {"delete", start, end - start}); }
void undo() { Object[] last = history.remove(history.size() - 1); String action = (String) last[0]; int pos = (int) last[1], n = (int) last[2]; // How to reverse an action lives here, far from the code that performed it. Every // new action needs a new branch, and each branch needs data nobody kept. if (action.equals("insert")) { text = text.substring(0, pos) + text.substring(pos + n); } else if (action.equals("delete")) { System.out.println("cannot undo delete: the removed text was never saved"); } }}
class Main { public static void main(String[] args) { Editor ed = new Editor(); for (char ch : "Hello".toCharArray()) { ed.insert(ed.text.length(), String.valueOf(ch)); // one history entry per keystroke }
// Undoing the typing takes back one letter at a time, and there is no redo at all. ed.undo(); System.out.println("\"" + ed.text + "\" with " + ed.history.size() + " steps left"); // "Hell" with 4 steps left
ed.delete(0, 4); ed.undo(); // cannot undo delete: the removed text was never saved System.out.println("\"" + ed.text + "\""); // "" }}# Undo without command objects: the editor records the NAME of each action, and one big# method tries to work out how to reverse it after the fact.class Editor: def __init__(self) -> None: self.text = "" self.history: list[tuple[str, int, int]] = [] # (action, position, length)
def insert(self, pos: int, s: str) -> None: self.text = self.text[:pos] + s + self.text[pos:] self.history.append(("insert", pos, len(s)))
def delete(self, start: int, end: int) -> None: self.text = self.text[:start] + self.text[end:] # Only the position and length are recorded. The removed text is gone. self.history.append(("delete", start, end - start))
def undo(self) -> None: action, pos, n = self.history.pop() # How to reverse an action lives here, far from the code that performed it. Every # new action needs a new branch, and each branch needs data nobody kept. if action == "insert": self.text = self.text[:pos] + self.text[pos + n :] elif action == "delete": print("cannot undo delete: the removed text was never saved")
ed = Editor()for ch in "Hello": ed.insert(len(ed.text), ch) # one history entry per keystroke
# Undoing the typing takes back one letter at a time, and there is no redo at all.ed.undo()print(f'"{ed.text}" with {len(ed.history)} steps left') # "Hell" with 4 steps left
ed.delete(0, 4)ed.undo() # cannot undo delete: the removed text was never savedprint(f'"{ed.text}"') # ""using System;using System.Collections.Generic;
// Undo without command objects: the editor records the NAME of each action, and one big// method tries to work out how to reverse it after the fact.public sealed class Editor{ public string Text { get; private set; } = ""; public List<(string Action, int Pos, int N)> History { get; } = new();
public void Insert(int pos, string s) { Text = Text[..pos] + s + Text[pos..]; History.Add(("insert", pos, s.Length)); }
public void Delete(int start, int end) { Text = Text[..start] + Text[end..]; // Only the position and length are recorded. The removed text is gone. History.Add(("delete", start, end - start)); }
public void Undo() { var (action, pos, n) = History[^1]; History.RemoveAt(History.Count - 1); // How to reverse an action lives here, far from the code that performed it. Every // new action needs a new branch, and each branch needs data nobody kept. if (action == "insert") Text = Text[..pos] + Text[(pos + n)..]; else if (action == "delete") Console.WriteLine("cannot undo delete: the removed text was never saved"); }}
public static class Program{ public static void Main() { var ed = new Editor(); foreach (var ch in "Hello") ed.Insert(ed.Text.Length, ch.ToString()); // one history entry per keystroke
// Undoing the typing takes back one letter at a time, and there is no redo at all. ed.Undo(); Console.WriteLine($"\"{ed.Text}\" with {ed.History.Count} steps left"); // "Hell" with 4 steps left
ed.Delete(0, 4); ed.Undo(); // cannot undo delete: the removed text was never saved Console.WriteLine($"\"{ed.Text}\""); // "" }}package main
import "fmt"
// Undo without command objects: the editor records the NAME of each action, and one big// method tries to work out how to reverse it after the fact.type entry struct { Action string Pos, N int}
type Editor struct { Text string History []entry}
func (e *Editor) Insert(pos int, s string) { e.Text = e.Text[:pos] + s + e.Text[pos:] e.History = append(e.History, entry{"insert", pos, len(s)})}
func (e *Editor) Delete(start, end int) { e.Text = e.Text[:start] + e.Text[end:] // Only the position and length are recorded. The removed text is gone. e.History = append(e.History, entry{"delete", start, end - start})}
func (e *Editor) Undo() { last := e.History[len(e.History)-1] e.History = e.History[:len(e.History)-1] // How to reverse an action lives here, far from the code that performed it. Every // new action needs a new branch, and each branch needs data nobody kept. switch last.Action { case "insert": e.Text = e.Text[:last.Pos] + e.Text[last.Pos+last.N:] case "delete": fmt.Println("cannot undo delete: the removed text was never saved") }}
func main() { ed := &Editor{} for _, ch := range "Hello" { ed.Insert(len(ed.Text), string(ch)) // one history entry per keystroke }
// Undoing the typing takes back one letter at a time, and there is no redo at all. ed.Undo() fmt.Printf("%q with %d steps left\n", ed.Text, len(ed.History)) // "Hell" with 4 steps left
ed.Delete(0, 4) ed.Undo() // cannot undo delete: the removed text was never saved fmt.Printf("%q\n", ed.Text) // ""}// Undo without command objects: the editor records the NAME of each action, and one big// method tries to work out how to reverse it after the fact.class Editor { text = ''; readonly history: [string, number, number][] = []; // [action, position, length]
insert(pos: number, s: string): void { this.text = this.text.slice(0, pos) + s + this.text.slice(pos); this.history.push(['insert', pos, s.length]); }
delete(start: number, end: number): void { this.text = this.text.slice(0, start) + this.text.slice(end); // Only the position and length are recorded. The removed text is gone. this.history.push(['delete', start, end - start]); }
undo(): void { const [action, pos, n] = this.history.pop()!; // How to reverse an action lives here, far from the code that performed it. Every // new action needs a new branch, and each branch needs data nobody kept. if (action === 'insert') { this.text = this.text.slice(0, pos) + this.text.slice(pos + n); } else if (action === 'delete') { console.log('cannot undo delete: the removed text was never saved'); } }}
const ed = new Editor();for (const ch of 'Hello') ed.insert(ed.text.length, ch); // one history entry per keystroke
// Undoing the typing takes back one letter at a time, and there is no redo at all.ed.undo();console.log(`"${ed.text}" with ${ed.history.length} steps left`); // "Hell" with 4 steps left
ed.delete(0, 4);ed.undo(); // cannot undo delete: the removed text was never savedconsole.log(`"${ed.text}"`); // ""Undo lives far from the code that performs each action, so every new action needs a new
branch in undo. Worse, the delete didn’t keep the text it removed, so there is nothing to
put back. Typing is one step per letter, and there is no redo.
With Command
Section titled “With Command”Give every command an undo() next to its execute(), and let it keep what it needs to
reverse itself. A History invoker holds two stacks: commands that can be undone, and
commands that were just undone:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
The rules are short:
- Run: execute the command, push it on the undo stack (or merge it into the top one), and clear the redo stack.
- Undo: pop from the undo stack, call
undo(), push onto the redo stack. - Redo: pop from the redo stack, call
execute()again, push back onto the undo stack.
Type into the box or use the buttons, then undo and redo. Undo something and type again to see the redo stack cleared. Turn merging off to see one step per key:
Undo stack (0)
Redo stack (0)
Click into the box and type, or use the buttons. Then undo and redo.
import java.util.ArrayDeque;import java.util.ArrayList;import java.util.Deque;import java.util.Iterator;import java.util.List;
// The receiver. It only knows how to insert and delete; it has no idea undo exists.class Document { String text = "";
void insert(int pos, String s) { text = text.substring(0, pos) + s + text.substring(pos); }
String delete(int start, int end) { String removed = text.substring(start, end); text = text.substring(0, start) + text.substring(end); return removed; }}
// Each command knows how to do itself AND how to take itself back, so reversing an action// lives next to the code that performs it.interface Command { void execute(Document doc); void undo(Document doc); // Asked by the history: "can you absorb this next command into yourself?" boolean merge(Command next); String label();}
class InsertText implements Command { static final int MERGE_WINDOW_MS = 1000; final int pos; String text; long lastMs;
InsertText(int pos, String text, long atMs) { this.pos = pos; this.text = text; this.lastMs = atMs; }
public void execute(Document doc) { doc.insert(pos, text); } public void undo(Document doc) { doc.delete(pos, pos + text.length()); }
public boolean merge(Command next) { // Typing is many keystrokes but one undo step: absorb the next insert if it continues // right where this one ended, within a second of the last key. if (next instanceof InsertText n && n.pos == pos + text.length() && n.lastMs - lastMs <= MERGE_WINDOW_MS) { text += n.text; lastMs = n.lastMs; return true; } return false; }
public String label() { return "insert \"" + text + "\""; }}
class DeleteRange implements Command { private final int start, end; private String removed = "";
DeleteRange(int start, int end) { this.start = start; this.end = end; }
public void execute(Document doc) { // Capture what undo will need at the moment of execution, not at construction: // only now do we know what the range holds. removed = doc.delete(start, end); }
public void undo(Document doc) { doc.insert(start, removed); } public boolean merge(Command next) { return false; } public String label() { return "delete \"" + removed + "\""; }}
// The invoker that remembers. Two stacks: what can be undone, and what was just undone.class History { private final Document doc; private final int limit; private final Deque<Command> undoStack = new ArrayDeque<>(); private final Deque<Command> redoStack = new ArrayDeque<>(); // After an undo or redo, the next keystroke starts a new step instead of merging. private boolean sealed = true;
History(Document doc, int limit) { this.doc = doc; this.limit = limit; }
void run(Command cmd) { cmd.execute(doc); if (undoStack.isEmpty() || sealed || !undoStack.peek().merge(cmd)) { undoStack.push(cmd); if (undoStack.size() > limit) undoStack.removeLast(); // memory is bounded: the oldest step is forgotten } sealed = false; // A new action starts a new timeline. The undone future no longer applies to this // text, so redoing it would corrupt the document. redoStack.clear(); }
void undo() { if (undoStack.isEmpty()) return; Command cmd = undoStack.pop(); cmd.undo(doc); redoStack.push(cmd); sealed = true; }
void redo() { if (redoStack.isEmpty()) return; Command cmd = redoStack.pop(); cmd.execute(doc); undoStack.push(cmd); sealed = true; }
void show() { // Stacks print bottom to top: the last item is the next to be undone or redone. System.out.println("\"" + doc.text + "\" | undo: [" + labels(undoStack) + "] | redo: [" + labels(redoStack) + "]"); }
private static String labels(Deque<Command> stack) { List<String> out = new ArrayList<>(); for (Iterator<Command> it = stack.descendingIterator(); it.hasNext(); ) out.add(it.next().label()); return String.join(", ", out); }}
class Main { static Document doc = new Document(); static History history = new History(doc, 100);
// One InsertText per keystroke, 100 ms apart, the way a keyboard delivers them. static void typeText(String s, long atMs) { for (int i = 0; i < s.length(); i++) { history.run(new InsertText(doc.text.length(), String.valueOf(s.charAt(i)), atMs + i * 100L)); } }
public static void main(String[] args) { typeText("Hello", 0); typeText(" world", 2000); // after a 1.6 s pause, so a new undo step history.show(); // "Hello world" | undo: [insert "Hello", insert " world"] | redo: []
history.undo(); history.undo(); history.show(); // "" | undo: [] | redo: [insert " world", insert "Hello"]
history.redo(); history.show(); // "Hello" | undo: [insert "Hello"] | redo: [insert " world"]
// A new edit after undo throws the redo stack away. typeText("!", 5000); history.show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: []
history.run(new DeleteRange(0, 5)); history.show(); // "!" | undo: [insert "Hello", insert "!", delete "Hello"] | redo: [] history.undo(); history.show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: [delete "Hello"] }}from typing import Protocol
# The receiver. It only knows how to insert and delete; it has no idea undo exists.class Document: def __init__(self) -> None: self.text = ""
def insert(self, pos: int, s: str) -> None: self.text = self.text[:pos] + s + self.text[pos:]
def delete(self, start: int, end: int) -> str: removed = self.text[start:end] self.text = self.text[:start] + self.text[end:] return removed
# Each command knows how to do itself AND how to take itself back, so reversing an action# lives next to the code that performs it.class Command(Protocol): def execute(self, doc: Document) -> None: ... def undo(self, doc: Document) -> None: ...
# Asked by the history: "can you absorb this next command into yourself?" def merge(self, nxt: "Command") -> bool: ... def label(self) -> str: ...
class InsertText: MERGE_WINDOW_MS = 1000
def __init__(self, pos: int, text: str, at_ms: int) -> None: self.pos, self.text, self.last_ms = pos, text, at_ms
def execute(self, doc: Document) -> None: doc.insert(self.pos, self.text)
def undo(self, doc: Document) -> None: doc.delete(self.pos, self.pos + len(self.text))
def merge(self, nxt: "Command") -> bool: # Typing is many keystrokes but one undo step: absorb the next insert if it continues # right where this one ended, within a second of the last key. if ( isinstance(nxt, InsertText) and nxt.pos == self.pos + len(self.text) and nxt.last_ms - self.last_ms <= self.MERGE_WINDOW_MS ): self.text += nxt.text self.last_ms = nxt.last_ms return True return False
def label(self) -> str: return f'insert "{self.text}"'
class DeleteRange: def __init__(self, start: int, end: int) -> None: self.start, self.end = start, end self.removed = ""
def execute(self, doc: Document) -> None: # Capture what undo will need at the moment of execution, not at construction: # only now do we know what the range holds. self.removed = doc.delete(self.start, self.end)
def undo(self, doc: Document) -> None: doc.insert(self.start, self.removed)
def merge(self, nxt: "Command") -> bool: return False
def label(self) -> str: return f'delete "{self.removed}"'
# The invoker that remembers. Two stacks: what can be undone, and what was just undone.class History: def __init__(self, doc: Document, limit: int = 100) -> None: self.doc, self.limit = doc, limit self.undo_stack: list[Command] = [] self.redo_stack: list[Command] = [] # After an undo or redo, the next keystroke starts a new step instead of merging. self.sealed = True
def run(self, cmd: Command) -> None: cmd.execute(self.doc) if not (self.undo_stack and not self.sealed and self.undo_stack[-1].merge(cmd)): self.undo_stack.append(cmd) if len(self.undo_stack) > self.limit: self.undo_stack.pop(0) # memory is bounded: the oldest step is forgotten self.sealed = False # A new action starts a new timeline. The undone future no longer applies to this # text, so redoing it would corrupt the document. self.redo_stack.clear()
def undo(self) -> None: if self.undo_stack: cmd = self.undo_stack.pop() cmd.undo(self.doc) self.redo_stack.append(cmd) self.sealed = True
def redo(self) -> None: if self.redo_stack: cmd = self.redo_stack.pop() cmd.execute(self.doc) self.undo_stack.append(cmd) self.sealed = True
def show(self) -> None: # Stacks print bottom to top: the last item is the next to be undone or redone. u = ", ".join(c.label() for c in self.undo_stack) r = ", ".join(c.label() for c in self.redo_stack) print(f'"{self.doc.text}" | undo: [{u}] | redo: [{r}]')
doc = Document()history = History(doc)
def type_text(s: str, at_ms: int) -> None: # One InsertText per keystroke, 100 ms apart, the way a keyboard delivers them. for i, ch in enumerate(s): history.run(InsertText(len(doc.text), ch, at_ms + i * 100))
type_text("Hello", 0)type_text(" world", 2000) # after a 1.6 s pause, so a new undo stephistory.show() # "Hello world" | undo: [insert "Hello", insert " world"] | redo: []
history.undo()history.undo()history.show() # "" | undo: [] | redo: [insert " world", insert "Hello"]
history.redo()history.show() # "Hello" | undo: [insert "Hello"] | redo: [insert " world"]
# A new edit after undo throws the redo stack away.type_text("!", 5000)history.show() # "Hello!" | undo: [insert "Hello", insert "!"] | redo: []
history.run(DeleteRange(0, 5))history.show() # "!" | undo: [insert "Hello", insert "!", delete "Hello"] | redo: []history.undo()history.show() # "Hello!" | undo: [insert "Hello", insert "!"] | redo: [delete "Hello"]using System;using System.Collections.Generic;using System.Linq;
// The receiver. It only knows how to insert and delete; it has no idea undo exists.public sealed class Document{ public string Text { get; private set; } = "";
public void Insert(int pos, string s) => Text = Text[..pos] + s + Text[pos..];
public string Delete(int start, int end) { var removed = Text[start..end]; Text = Text[..start] + Text[end..]; return removed; }}
// Each command knows how to do itself AND how to take itself back, so reversing an action// lives next to the code that performs it.public interface ICommand{ void Execute(Document doc); void Undo(Document doc); // Asked by the history: "can you absorb this next command into yourself?" bool Merge(ICommand next); string Label();}
public sealed class InsertText : ICommand{ private const int MergeWindowMs = 1000; public int Pos { get; } public string Text { get; private set; } public long LastMs { get; private set; }
public InsertText(int pos, string text, long atMs) { Pos = pos; Text = text; LastMs = atMs; }
public void Execute(Document doc) => doc.Insert(Pos, Text); public void Undo(Document doc) => doc.Delete(Pos, Pos + Text.Length);
public bool Merge(ICommand next) { // Typing is many keystrokes but one undo step: absorb the next insert if it continues // right where this one ended, within a second of the last key. if (next is InsertText n && n.Pos == Pos + Text.Length && n.LastMs - LastMs <= MergeWindowMs) { Text += n.Text; LastMs = n.LastMs; return true; } return false; }
public string Label() => $"insert \"{Text}\"";}
public sealed class DeleteRange : ICommand{ private readonly int _start, _end; private string _removed = "";
public DeleteRange(int start, int end) { _start = start; _end = end; }
// Capture what undo will need at the moment of execution, not at construction: // only now do we know what the range holds. public void Execute(Document doc) => _removed = doc.Delete(_start, _end); public void Undo(Document doc) => doc.Insert(_start, _removed); public bool Merge(ICommand next) => false; public string Label() => $"delete \"{_removed}\"";}
// The invoker that remembers. Two stacks: what can be undone, and what was just undone.public sealed class History{ private readonly Document _doc; private readonly int _limit; private readonly List<ICommand> _undo = new(); private readonly List<ICommand> _redo = new(); // After an undo or redo, the next keystroke starts a new step instead of merging. private bool _sealed = true;
public History(Document doc, int limit = 100) { _doc = doc; _limit = limit; }
public void Run(ICommand cmd) { cmd.Execute(_doc); if (_undo.Count == 0 || _sealed || !_undo[^1].Merge(cmd)) { _undo.Add(cmd); if (_undo.Count > _limit) _undo.RemoveAt(0); // memory is bounded: the oldest step is forgotten } _sealed = false; // A new action starts a new timeline. The undone future no longer applies to this // text, so redoing it would corrupt the document. _redo.Clear(); }
public void Undo() { if (_undo.Count == 0) return; var cmd = _undo[^1]; _undo.RemoveAt(_undo.Count - 1); cmd.Undo(_doc); _redo.Add(cmd); _sealed = true; }
public void Redo() { if (_redo.Count == 0) return; var cmd = _redo[^1]; _redo.RemoveAt(_redo.Count - 1); cmd.Execute(_doc); _undo.Add(cmd); _sealed = true; }
public void Show() { // Stacks print bottom to top: the last item is the next to be undone or redone. var u = string.Join(", ", _undo.Select(c => c.Label())); var r = string.Join(", ", _redo.Select(c => c.Label())); Console.WriteLine($"\"{_doc.Text}\" | undo: [{u}] | redo: [{r}]"); }}
public static class Program{ public static void Main() { var doc = new Document(); var history = new History(doc);
// One InsertText per keystroke, 100 ms apart, the way a keyboard delivers them. void TypeText(string s, long atMs) { for (var i = 0; i < s.Length; i++) history.Run(new InsertText(doc.Text.Length, s[i].ToString(), atMs + i * 100)); }
TypeText("Hello", 0); TypeText(" world", 2000); // after a 1.6 s pause, so a new undo step history.Show(); // "Hello world" | undo: [insert "Hello", insert " world"] | redo: []
history.Undo(); history.Undo(); history.Show(); // "" | undo: [] | redo: [insert " world", insert "Hello"]
history.Redo(); history.Show(); // "Hello" | undo: [insert "Hello"] | redo: [insert " world"]
// A new edit after undo throws the redo stack away. TypeText("!", 5000); history.Show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: []
history.Run(new DeleteRange(0, 5)); history.Show(); // "!" | undo: [insert "Hello", insert "!", delete "Hello"] | redo: [] history.Undo(); history.Show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: [delete "Hello"] }}package main
import ( "fmt" "strings")
// Document is the receiver. It only knows how to insert and delete; it has no idea undo exists.type Document struct{ Text string }
func (d *Document) Insert(pos int, s string) { d.Text = d.Text[:pos] + s + d.Text[pos:] }func (d *Document) Delete(start, end int) string { removed := d.Text[start:end] d.Text = d.Text[:start] + d.Text[end:] return removed}
// Command knows how to do itself AND how to take itself back, so reversing an action lives// next to the code that performs it.type Command interface { Execute(doc *Document) Undo(doc *Document) // Merge is asked by the history: "can you absorb this next command into yourself?" Merge(next Command) bool Label() string}
const mergeWindowMs = 1000
type InsertText struct { Pos int Text string LastMs int}
func (c *InsertText) Execute(doc *Document) { doc.Insert(c.Pos, c.Text) }func (c *InsertText) Undo(doc *Document) { doc.Delete(c.Pos, c.Pos+len(c.Text)) }func (c *InsertText) Merge(next Command) bool { // Typing is many keystrokes but one undo step: absorb the next insert if it continues // right where this one ended, within a second of the last key. n, ok := next.(*InsertText) if ok && n.Pos == c.Pos+len(c.Text) && n.LastMs-c.LastMs <= mergeWindowMs { c.Text += n.Text c.LastMs = n.LastMs return true } return false}func (c *InsertText) Label() string { return fmt.Sprintf("insert %q", c.Text) }
type DeleteRange struct { Start, End int removed string}
func (c *DeleteRange) Execute(doc *Document) { // Capture what undo will need at the moment of execution, not at construction: // only now do we know what the range holds. c.removed = doc.Delete(c.Start, c.End)}func (c *DeleteRange) Undo(doc *Document) { doc.Insert(c.Start, c.removed) }func (c *DeleteRange) Merge(next Command) bool { return false }func (c *DeleteRange) Label() string { return fmt.Sprintf("delete %q", c.removed) }
// History is the invoker that remembers. Two stacks: what can be undone, and what was just undone.type History struct { doc *Document limit int undo, redo []Command // After an undo or redo, the next keystroke starts a new step instead of merging. sealed bool}
func NewHistory(doc *Document, limit int) *History { return &History{doc: doc, limit: limit, sealed: true}}
func (h *History) Run(cmd Command) { cmd.Execute(h.doc) if len(h.undo) == 0 || h.sealed || !h.undo[len(h.undo)-1].Merge(cmd) { h.undo = append(h.undo, cmd) if len(h.undo) > h.limit { h.undo = h.undo[1:] // memory is bounded: the oldest step is forgotten } } h.sealed = false // A new action starts a new timeline. The undone future no longer applies to this // text, so redoing it would corrupt the document. h.redo = nil}
func (h *History) Undo() { if len(h.undo) == 0 { return } cmd := h.undo[len(h.undo)-1] h.undo = h.undo[:len(h.undo)-1] cmd.Undo(h.doc) h.redo = append(h.redo, cmd) h.sealed = true}
func (h *History) Redo() { if len(h.redo) == 0 { return } cmd := h.redo[len(h.redo)-1] h.redo = h.redo[:len(h.redo)-1] cmd.Execute(h.doc) h.undo = append(h.undo, cmd) h.sealed = true}
func labels(stack []Command) string { out := make([]string, len(stack)) for i, c := range stack { out[i] = c.Label() } return strings.Join(out, ", ")}
// Show prints the stacks bottom to top: the last item is the next to be undone or redone.func (h *History) Show() { fmt.Printf("%q | undo: [%s] | redo: [%s]\n", h.doc.Text, labels(h.undo), labels(h.redo))}
func main() { doc := &Document{} history := NewHistory(doc, 100)
// One InsertText per keystroke, 100 ms apart, the way a keyboard delivers them. typeText := func(s string, atMs int) { for i, ch := range s { history.Run(&InsertText{Pos: len(doc.Text), Text: string(ch), LastMs: atMs + i*100}) } }
typeText("Hello", 0) typeText(" world", 2000) // after a 1.6 s pause, so a new undo step history.Show() // "Hello world" | undo: [insert "Hello", insert " world"] | redo: []
history.Undo() history.Undo() history.Show() // "" | undo: [] | redo: [insert " world", insert "Hello"]
history.Redo() history.Show() // "Hello" | undo: [insert "Hello"] | redo: [insert " world"]
// A new edit after undo throws the redo stack away. typeText("!", 5000) history.Show() // "Hello!" | undo: [insert "Hello", insert "!"] | redo: []
history.Run(&DeleteRange{Start: 0, End: 5}) history.Show() // "!" | undo: [insert "Hello", insert "!", delete "Hello"] | redo: [] history.Undo() history.Show() // "Hello!" | undo: [insert "Hello", insert "!"] | redo: [delete "Hello"]}// The receiver. It only knows how to insert and delete; it has no idea undo exists.class Document { text = ''; insert(pos: number, s: string): void { this.text = this.text.slice(0, pos) + s + this.text.slice(pos); } delete(start: number, end: number): string { const removed = this.text.slice(start, end); this.text = this.text.slice(0, start) + this.text.slice(end); return removed; }}
// Each command knows how to do itself AND how to take itself back, so reversing an action// lives next to the code that performs it.interface Command { execute(doc: Document): void; undo(doc: Document): void; // Asked by the history: "can you absorb this next command into yourself?" merge(next: Command): boolean; label(): string;}
class InsertText implements Command { static readonly MERGE_WINDOW_MS = 1000; constructor(readonly pos: number, public text: string, public lastMs: number) {}
execute(doc: Document): void { doc.insert(this.pos, this.text); } undo(doc: Document): void { doc.delete(this.pos, this.pos + this.text.length); } merge(next: Command): boolean { // Typing is many keystrokes but one undo step: absorb the next insert if it continues // right where this one ended, within a second of the last key. if ( next instanceof InsertText && next.pos === this.pos + this.text.length && next.lastMs - this.lastMs <= InsertText.MERGE_WINDOW_MS ) { this.text += next.text; this.lastMs = next.lastMs; return true; } return false; } label(): string { return `insert "${this.text}"`; }}
class DeleteRange implements Command { private removed = ''; constructor(private readonly start: number, private readonly end: number) {}
execute(doc: Document): void { // Capture what undo will need at the moment of execution, not at construction: // only now do we know what the range holds. this.removed = doc.delete(this.start, this.end); } undo(doc: Document): void { doc.insert(this.start, this.removed); } merge(): boolean { return false; } label(): string { return `delete "${this.removed}"`; }}
// The invoker that remembers. Two stacks: what can be undone, and what was just undone.class History { private readonly undoStack: Command[] = []; private redoStack: Command[] = []; // After an undo or redo, the next keystroke starts a new step instead of merging. private sealed = true;
constructor(private readonly doc: Document, private readonly limit = 100) {}
run(cmd: Command): void { cmd.execute(this.doc); const top = this.undoStack[this.undoStack.length - 1]; if (!top || this.sealed || !top.merge(cmd)) { this.undoStack.push(cmd); if (this.undoStack.length > this.limit) this.undoStack.shift(); // memory is bounded: the oldest step is forgotten } this.sealed = false; // A new action starts a new timeline. The undone future no longer applies to this // text, so redoing it would corrupt the document. this.redoStack = []; }
undo(): void { const cmd = this.undoStack.pop(); if (!cmd) return; cmd.undo(this.doc); this.redoStack.push(cmd); this.sealed = true; }
redo(): void { const cmd = this.redoStack.pop(); if (!cmd) return; cmd.execute(this.doc); this.undoStack.push(cmd); this.sealed = true; }
show(): void { // Stacks print bottom to top: the last item is the next to be undone or redone. const u = this.undoStack.map((c) => c.label()).join(', '); const r = this.redoStack.map((c) => c.label()).join(', '); console.log(`"${this.doc.text}" | undo: [${u}] | redo: [${r}]`); }}
const doc = new Document();const history = new History(doc);
// One InsertText per keystroke, 100 ms apart, the way a keyboard delivers them.function typeText(s: string, atMs: number): void { [...s].forEach((ch, i) => history.run(new InsertText(doc.text.length, ch, atMs + i * 100)));}
typeText('Hello', 0);typeText(' world', 2000); // after a 1.6 s pause, so a new undo stephistory.show(); // "Hello world" | undo: [insert "Hello", insert " world"] | redo: []
history.undo();history.undo();history.show(); // "" | undo: [] | redo: [insert " world", insert "Hello"]
history.redo();history.show(); // "Hello" | undo: [insert "Hello"] | redo: [insert " world"]
// A new edit after undo throws the redo stack away.typeText('!', 5000);history.show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: []
history.run(new DeleteRange(0, 5));history.show(); // "!" | undo: [insert "Hello", insert "!", delete "Hello"] | redo: []history.undo();history.show(); // "Hello!" | undo: [insert "Hello", insert "!"] | redo: [delete "Hello"]What to notice
Section titled “What to notice”- A command keeps what it needs, captured when it runs.
DeleteRangesaves the removed text inexecute, not in its constructor. Only when it runs does it know what the range holds. That one field is what makes delete undoable. - A new edit clears the redo stack. The undone commands were recorded against a text that no longer exists. Say the user typed “!” at position 5. Replaying “ world” at position 5 could now insert it in the wrong place. So editors drop that branch of history.
- Typing merges. Eleven keystrokes become two undo steps.
InsertText.mergeabsorbs the next insert when it continues at the same position less than a second later. Java Swing’sUndoableEdit.addEditgives an edit the same chance to absorb the next one. Real editors also break steps at word boundaries or when the cursor jumps. - Undo and redo seal the top step. After an undo or redo, the next keystroke starts a new step even if it is quick. Otherwise it could merge into a command the user just redid.
- The history is bounded. After
limitsteps the oldest is dropped. Without a limit, an editor left open all day holds every keystroke since morning. - Receivers stay ignorant.
Documenthasinsertanddeleteand no idea that undo exists.
The alternative: snapshots (Memento)
Section titled “The alternative: snapshots (Memento)”There is another way to undo. Save a copy of the whole state before each step, and restore it on undo. That is the Memento pattern. It needs no inverse logic, so it can’t get an inverse wrong. It also works for actions that are hard to reverse, such as an image filter. The cost is memory, because each step stores the whole document. A 1 MB document with 100 undo steps holds about 100 MB of snapshots. The commands above hold only the characters that changed. The demo’s meter shows the gap growing as the text gets longer.
Real tools often combine the two. They use commands for most steps. They take a snapshot every N steps, or before an operation that is expensive to reverse. Or they use persistent data structures, which share the unchanged parts between versions.
Add undo and redo to this text editor. Users complain that undo takes back one letter at a time, and that undoing a delete doesn’t bring the text back.
Whether each command stores what it needs to reverse itself (the deleted text, captured on execute). Whether you use two stacks, and clear redo on a new action. And whether you merge consecutive typing. Knowing the snapshot alternative, and its memory cost, is the bar for mid-level. The red flag is reversing actions in one big switch. So is saving a full copy of the document on every keystroke, without noticing the cost.
A retry-safe job queue
- Commands as serialized data
- Versioned payloads
- Idempotency keys
- Retries, backoff and a dead-letter queue
- An audit log
A shop’s checkout must not wait for slow work. When an order is paid, the web server enqueues
SendInvoice{orderId}. When a product photo is uploaded, it enqueues ResizeImage{image, width}.
Workers on other machines pick the jobs up. But workers crash and get redeployed. The email API
times out. Some photos are corrupt. And the web server sometimes enqueues the same job twice, when
its own request times out and is retried. Customers must get exactly one invoice per order. And
support must be able to answer “was this invoice sent, and when?”
These are still commands: a request, captured as a value, run later by someone else. But they now cross a process boundary, and that changes what the pattern has to handle.
Commands become data
Section titled “Commands become data”Code can’t be put on a queue. So a command that crosses a boundary is a message: a type name, a
version, an idempotency key and a payload. The execute method moves to a handler on the
worker, looked up by type. CQRS frameworks use the same split, and call it a command and its command
handler.
{"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"}- Send ids, not objects. The payload carries
orderId, not the order. The worker reads the current order when it runs. That keeps messages small, and avoids acting on a stale copy. Sometimes the job must use the values as they were at enqueue time, like the amount the customer agreed to. Then put those values in the payload on purpose. - Version every payload. After a deploy, jobs enqueued by the previous release are still on the
queue. The worker upgrades old versions as it reads them. (This is called “upcasting”.)
SendInvoicev1 had no currency, and every v1 order was in euros. So v1 is read as v2 withEUR. Add fields with defaults. Never change what an existing field means. - Deploy consumers first. A producer that sends v3 before any worker understands it creates jobs nobody can run. Retrying won’t help, so they go straight to the dead-letter queue, to be replayed once the new workers are out.
Delivery is at least once
Section titled “Delivery is at least once”A queue can’t know whether a worker that went silent finished the job. So queues such as Amazon SQS don’t delete a message when it’s received. They hide it for a while, called a lease. (SQS calls it the visibility timeout.) When the worker is done, it deletes the message with an ack. If the worker crashes first, the lease runs out, and the job is delivered again. So every handler may run twice, and every handler must be safe to run twice.
Try it below: enqueue an invoice, tick “crash”, and watch the job come back after its lease. Then turn idempotency keys off and do it again:
Queue
Mailer: invoices sent
Dead-letter queue
Audit log
Enqueue a SendInvoice, tick "crash", and watch what happens when the job comes back.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Entity
- Facade
import java.util.ArrayList;import java.util.HashMap;import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;import java.util.TreeMap;import java.util.regex.Matcher;import java.util.regex.Pattern;
// The wire format. Once a command crosses a process boundary it is data, not an object with// an execute method: code can't be put on a queue, so the behavior moves to a handler.// Real code uses a JSON library such as Jackson; this keeps the example dependency-free.final class Wire { private static final Pattern FIELD = Pattern.compile("\"(\\w+)\":\"([^\"]*)\"");
// Sorted keys and string values: the same command always encodes to the same text. static String encode(Map<String, String> fields) { List<String> parts = new ArrayList<>(); new TreeMap<>(fields).forEach((k, v) -> parts.add("\"" + k + "\":\"" + v + "\"")); return "{" + String.join(",", parts) + "}"; }
static Map<String, String> decode(String wire) { Map<String, String> fields = new HashMap<>(); Matcher m = FIELD.matcher(wire); while (m.find()) fields.put(m.group(1), m.group(2)); return fields; }}
// Typed commands: what handlers work with after the payload is checked and upgraded.record SendInvoice(String orderId, String currency) {}
record ResizeImage(String image, int width) {}
// Retrying can't fix it: unknown type or version, or invalid data.class PermanentException extends RuntimeException { PermanentException(String message) { super(message); }}
final class Commands { // Old versions are upgraded as they are read ("upcasting"), so jobs enqueued by the // previous release still run after a deploy. Unknown versions fail permanently. static Object parse(Map<String, String> f) { String kind = f.get("type"), v = f.get("v"); if (kind.equals("SendInvoice") && v.equals("1")) { return new SendInvoice(f.get("orderId"), "EUR"); // v1 had no currency; every v1 order was in euros } if (kind.equals("SendInvoice") && v.equals("2")) return new SendInvoice(f.get("orderId"), f.get("currency")); if (kind.equals("ResizeImage") && v.equals("1")) { return new ResizeImage(f.get("image"), Integer.parseInt(f.get("width"))); } throw new PermanentException("unknown command " + kind + " v" + v); }
static void log(int now, String line) { System.out.println("t=" + now + "s " + line); }}
// A fake email API. Like many payment and email APIs, it takes an idempotency key and// ignores a repeat, so a retried job can't send the same invoice twice.class Mailer { final Map<String, String> sent = new HashMap<>();
void sendInvoice(String key, SendInvoice cmd, int now) { if (sent.containsKey(key)) { Commands.log(now, "mailer: duplicate key " + key + ", not sent again"); return; } sent.put(key, cmd.orderId()); Commands.log(now, "mailer: invoice " + cmd.orderId() + " sent (" + cmd.currency() + ")"); }}
// A fake image service: busy on the first call, and it always chokes on one corrupt file.// Resizing is idempotent by nature (same input, same output file), so it needs no key.class ImageService { private final Map<String, Integer> calls = new HashMap<>();
void resize(ResizeImage cmd, int now) { int n = calls.merge(cmd.image(), 1, Integer::sum); if (cmd.image().equals("img/bad.png")) throw new RuntimeException("corrupt image"); if (n == 1) throw new RuntimeException("image service 503"); Commands.log(now, "image: " + cmd.image() + " resized to " + cmd.width() + "px"); }}
class Job { final String id, wire; int visibleAt, attempts; Job(String id, String wire) { this.id = id; this.wire = wire; }}
class JobQueue { static final int LEASE_S = 30; // a delivered job is hidden this long; if nobody acks it, it is delivered again final List<Job> jobs = new ArrayList<>();
void enqueue(String id, Map<String, String> fields) { jobs.add(new Job(id, Wire.encode(fields))); }
Job receive(int now) { // At-least-once delivery: a received job is hidden for a lease, not removed. If the // worker dies before acking, the job comes back. So every handler may run twice. for (Job job : jobs) { if (job.visibleAt <= now) { job.visibleAt = now + LEASE_S; job.attempts++; return job; } } return null; }
void ack(Job job) { jobs.remove(job); } void retryAt(Job job, int at) { job.visibleAt = at; }}
interface Handler { void handle(Object cmd, String key, int now); }
class Worker { static final int MAX_ATTEMPTS = 3; static final int BACKOFF_BASE_S = 2; // waits 2 s, then 4 s; real systems add random jitter
private final JobQueue queue; private final Map<String, Handler> handlers; private final Set<String> done = new HashSet<>(); // idempotency keys of finished jobs (a table in real life) final List<String> dead = new ArrayList<>(); // the dead-letter queue: jobs parked for a person to look at final Set<String> crashAfter = new HashSet<>(); // simulation only: jobs whose worker dies before ack
Worker(JobQueue queue, Map<String, Handler> handlers) { this.queue = queue; this.handlers = handlers; }
// Every attempt and its outcome, in order. In production: an append-only table. private void audit(int now, Job job, String outcome) { Commands.log(now, job.id + " attempt " + job.attempts + ": " + outcome); }
boolean poll(int now) { Job job = queue.receive(now); if (job == null) return false; Map<String, String> f = Wire.decode(job.wire); String key = f.get("key"); if (done.contains(key)) { // Same key as a finished job, e.g. the producer enqueued it twice. Ack, don't rerun. queue.ack(job); audit(now, job, "skipped, key " + key + " already done"); return true; } try { Object cmd = Commands.parse(f); handlers.get(f.get("type")).handle(cmd, key, now); } catch (PermanentException e) { // Retrying can't help, so park it at once instead of burning attempts. queue.ack(job); dead.add(job.id); audit(now, job, "dead-lettered (" + e.getMessage() + ")"); return true; } catch (RuntimeException e) { if (job.attempts >= MAX_ATTEMPTS) { // A poison job: it fails every time. Park it so it stops blocking the queue. queue.ack(job); dead.add(job.id); audit(now, job, "dead-lettered after " + MAX_ATTEMPTS + " attempts (" + e.getMessage() + ")"); } else { int delay = BACKOFF_BASE_S * (1 << (job.attempts - 1)); queue.retryAt(job, now + delay); audit(now, job, "failed (" + e.getMessage() + "), retry in " + delay + "s"); } return true; } if (crashAfter.remove(job.id)) { // Simulation: the process dies between the side effect and the ack. Nothing below // runs, so the key isn't recorded, and the job is delivered again after its lease. audit(now, job, "worker crashed before ack"); return true; } done.add(key); queue.ack(job); audit(now, job, "done"); return true; }}
class Main { public static void main(String[] args) { Mailer mailer = new Mailer(); ImageService images = new ImageService(); JobQueue queue = new JobQueue(); Worker worker = new Worker(queue, Map.of( "SendInvoice", (cmd, key, now) -> mailer.sendInvoice(key, (SendInvoice) cmd, now), "ResizeImage", (cmd, key, now) -> images.resize((ResizeImage) cmd, now)));
// The idempotency key comes from what makes the request unique (one invoice per order), not // from a random id per enqueue, so a duplicate enqueue carries the same key. queue.enqueue("j1", Map.of("type", "SendInvoice", "v", "2", "key", "invoice:1001", "orderId", "1001", "currency", "USD")); queue.enqueue("j2", Map.of("type", "ResizeImage", "v", "1", "key", "resize:img/42.jpg:640", "image", "img/42.jpg", "width", "640")); queue.enqueue("j3", Map.of("type", "SendInvoice", "v", "1", "key", "invoice:1002", "orderId", "1002")); // from the old release queue.enqueue("j4", Map.of("type", "SendInvoice", "v", "1", "key", "invoice:1002", "orderId", "1002")); // enqueued twice queue.enqueue("j5", Map.of("type", "ResizeImage", "v", "1", "key", "resize:img/bad.png:640", "image", "img/bad.png", "width", "640")); queue.enqueue("j6", Map.of("type", "SendInvoice", "v", "3", "key", "invoice:1003", "orderId", "1003", "currency", "USD")); // a producer deployed too early System.out.println("wire j1: " + queue.jobs.get(0).wire); // wire j1: {"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"} worker.crashAfter.add("j1");
for (int now = 0; now < 40; now++) { while (worker.poll(now)) { } } // t=0s mailer: invoice 1001 sent (USD) // t=0s j1 attempt 1: worker crashed before ack // t=0s j2 attempt 1: failed (image service 503), retry in 2s // t=0s mailer: invoice 1002 sent (EUR) // t=0s j3 attempt 1: done // t=0s j4 attempt 1: skipped, key invoice:1002 already done // t=0s j5 attempt 1: failed (corrupt image), retry in 2s // t=0s j6 attempt 1: dead-lettered (unknown command SendInvoice v3) // t=2s image: img/42.jpg resized to 640px // t=2s j2 attempt 2: done // t=2s j5 attempt 2: failed (corrupt image), retry in 4s // t=6s j5 attempt 3: dead-lettered after 3 attempts (corrupt image) // t=30s mailer: duplicate key invoice:1001, not sent again // t=30s j1 attempt 2: done
System.out.println("emails sent: " + mailer.sent.size()); // emails sent: 2 System.out.println("dead letters: " + String.join(", ", worker.dead)); // dead letters: j6, j5 }}import jsonfrom dataclasses import dataclassfrom typing import Callable
LEASE_S = 30 # a delivered job is hidden this long; if nobody acks it, it is delivered againMAX_ATTEMPTS = 3BACKOFF_BASE_S = 2 # waits 2 s, then 4 s; real systems add random jitter
# The wire format. Once a command crosses a process boundary it is data, not an object with# an execute method: code can't be put on a queue, so the behavior moves to a handler.def encode(fields: dict[str, str]) -> str: # Sorted keys and string values: the same command always encodes to the same text. return json.dumps(fields, sort_keys=True, separators=(",", ":"))
def decode(wire: str) -> dict[str, str]: return json.loads(wire)
# Typed commands: what handlers work with after the payload is checked and upgraded.@dataclass(frozen=True)class SendInvoice: order_id: str currency: str
@dataclass(frozen=True)class ResizeImage: image: str width: int
# Retrying can't fix it: unknown type or version, or invalid data.class PermanentError(Exception): ...
def parse(f: dict[str, str]) -> object: # Old versions are upgraded as they are read ("upcasting"), so jobs enqueued by the # previous release still run after a deploy. Unknown versions fail permanently. kind, v = f["type"], f["v"] if kind == "SendInvoice" and v == "1": return SendInvoice(f["orderId"], "EUR") # v1 had no currency; every v1 order was in euros if kind == "SendInvoice" and v == "2": return SendInvoice(f["orderId"], f["currency"]) if kind == "ResizeImage" and v == "1": return ResizeImage(f["image"], int(f["width"])) raise PermanentError(f"unknown command {kind} v{v}")
def log(now: int, line: str) -> None: print(f"t={now}s {line}")
# A fake email API. Like many payment and email APIs, it takes an idempotency key and# ignores a repeat, so a retried job can't send the same invoice twice.class Mailer: def __init__(self) -> None: self.sent: dict[str, str] = {}
def send_invoice(self, key: str, cmd: SendInvoice, now: int) -> None: if key in self.sent: log(now, f"mailer: duplicate key {key}, not sent again") return self.sent[key] = cmd.order_id log(now, f"mailer: invoice {cmd.order_id} sent ({cmd.currency})")
# A fake image service: busy on the first call, and it always chokes on one corrupt file.# Resizing is idempotent by nature (same input, same output file), so it needs no key.class ImageService: def __init__(self) -> None: self.calls: dict[str, int] = {}
def resize(self, cmd: ResizeImage, now: int) -> None: self.calls[cmd.image] = self.calls.get(cmd.image, 0) + 1 if cmd.image == "img/bad.png": raise RuntimeError("corrupt image") if self.calls[cmd.image] == 1: raise RuntimeError("image service 503") log(now, f"image: {cmd.image} resized to {cmd.width}px")
@dataclassclass Job: id: str wire: str visible_at: int = 0 attempts: int = 0
class Queue: def __init__(self) -> None: self.jobs: list[Job] = []
def enqueue(self, job_id: str, fields: dict[str, str]) -> None: self.jobs.append(Job(job_id, encode(fields)))
def receive(self, now: int) -> Job | None: # At-least-once delivery: a received job is hidden for a lease, not removed. If the # worker dies before acking, the job comes back. So every handler may run twice. for job in self.jobs: if job.visible_at <= now: job.visible_at = now + LEASE_S job.attempts += 1 return job return None
def ack(self, job: Job) -> None: self.jobs.remove(job)
def retry_at(self, job: Job, at: int) -> None: job.visible_at = at
Handler = Callable[[object, str, int], None]
class Worker: def __init__(self, queue: Queue, handlers: dict[str, Handler]) -> None: self.queue, self.handlers = queue, handlers self.done: set[str] = set() # idempotency keys of finished jobs (a table in real life) self.dead: list[str] = [] # the dead-letter queue: jobs parked for a person to look at self.crash_after: set[str] = set() # simulation only: jobs whose worker dies before ack
def audit(self, now: int, job: Job, outcome: str) -> None: # Every attempt and its outcome, in order. In production: an append-only table. log(now, f"{job.id} attempt {job.attempts}: {outcome}")
def poll(self, now: int) -> bool: job = self.queue.receive(now) if job is None: return False f = decode(job.wire) key = f["key"] if key in self.done: # Same key as a finished job, e.g. the producer enqueued it twice. Ack, don't rerun. self.queue.ack(job) self.audit(now, job, f"skipped, key {key} already done") return True try: self.handlers[f["type"]](parse(f), key, now) except PermanentError as e: # Retrying can't help, so park it at once instead of burning attempts. self.queue.ack(job) self.dead.append(job.id) self.audit(now, job, f"dead-lettered ({e})") return True except Exception as e: if job.attempts >= MAX_ATTEMPTS: # A poison job: it fails every time. Park it so it stops blocking the queue. self.queue.ack(job) self.dead.append(job.id) self.audit(now, job, f"dead-lettered after {MAX_ATTEMPTS} attempts ({e})") else: delay = BACKOFF_BASE_S * 2 ** (job.attempts - 1) self.queue.retry_at(job, now + delay) self.audit(now, job, f"failed ({e}), retry in {delay}s") return True if job.id in self.crash_after: # Simulation: the process dies between the side effect and the ack. Nothing below # runs, so the key isn't recorded, and the job is delivered again after its lease. self.crash_after.discard(job.id) self.audit(now, job, "worker crashed before ack") return True self.done.add(key) self.queue.ack(job) self.audit(now, job, "done") return True
mailer, images, queue = Mailer(), ImageService(), Queue()worker = Worker( queue, { "SendInvoice": lambda cmd, key, now: mailer.send_invoice(key, cmd, now), "ResizeImage": lambda cmd, key, now: images.resize(cmd, now), },)
# The idempotency key comes from what makes the request unique (one invoice per order), not# from a random id per enqueue, so a duplicate enqueue carries the same key.queue.enqueue("j1", {"type": "SendInvoice", "v": "2", "key": "invoice:1001", "orderId": "1001", "currency": "USD"})queue.enqueue("j2", {"type": "ResizeImage", "v": "1", "key": "resize:img/42.jpg:640", "image": "img/42.jpg", "width": "640"})queue.enqueue("j3", {"type": "SendInvoice", "v": "1", "key": "invoice:1002", "orderId": "1002"}) # from the old releasequeue.enqueue("j4", {"type": "SendInvoice", "v": "1", "key": "invoice:1002", "orderId": "1002"}) # enqueued twicequeue.enqueue("j5", {"type": "ResizeImage", "v": "1", "key": "resize:img/bad.png:640", "image": "img/bad.png", "width": "640"})queue.enqueue("j6", {"type": "SendInvoice", "v": "3", "key": "invoice:1003", "orderId": "1003", "currency": "USD"}) # a producer deployed too earlyprint("wire j1: " + queue.jobs[0].wire) # wire j1: {"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"}worker.crash_after.add("j1")
for now in range(40): while worker.poll(now): pass# t=0s mailer: invoice 1001 sent (USD)# t=0s j1 attempt 1: worker crashed before ack# t=0s j2 attempt 1: failed (image service 503), retry in 2s# t=0s mailer: invoice 1002 sent (EUR)# t=0s j3 attempt 1: done# t=0s j4 attempt 1: skipped, key invoice:1002 already done# t=0s j5 attempt 1: failed (corrupt image), retry in 2s# t=0s j6 attempt 1: dead-lettered (unknown command SendInvoice v3)# t=2s image: img/42.jpg resized to 640px# t=2s j2 attempt 2: done# t=2s j5 attempt 2: failed (corrupt image), retry in 4s# t=6s j5 attempt 3: dead-lettered after 3 attempts (corrupt image)# t=30s mailer: duplicate key invoice:1001, not sent again# t=30s j1 attempt 2: done
print(f"emails sent: {len(mailer.sent)}") # emails sent: 2print("dead letters: " + ", ".join(worker.dead)) # dead letters: j6, j5using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;
// The wire format. Once a command crosses a process boundary it is data, not an object with// an execute method: code can't be put on a queue, so the behavior moves to a handler.public static class Wire{ // Sorted keys and string values: the same command always encodes to the same text. public static string Encode(Dictionary<string, string> fields) => JsonSerializer.Serialize(new SortedDictionary<string, string>(fields, StringComparer.Ordinal));
public static Dictionary<string, string> Decode(string wire) => JsonSerializer.Deserialize<Dictionary<string, string>>(wire)!;}
// Typed commands: what handlers work with after the payload is checked and upgraded.public sealed record SendInvoice(string OrderId, string Currency);public sealed record ResizeImage(string Image, int Width);
// Retrying can't fix it: unknown type or version, or invalid data.public sealed class PermanentException : Exception{ public PermanentException(string message) : base(message) { }}
public static class Commands{ // Old versions are upgraded as they are read ("upcasting"), so jobs enqueued by the // previous release still run after a deploy. Unknown versions fail permanently. public static object Parse(Dictionary<string, string> f) => (f["type"], f["v"]) switch { ("SendInvoice", "1") => new SendInvoice(f["orderId"], "EUR"), // v1 had no currency; every v1 order was in euros ("SendInvoice", "2") => new SendInvoice(f["orderId"], f["currency"]), ("ResizeImage", "1") => new ResizeImage(f["image"], int.Parse(f["width"])), var (kind, v) => throw new PermanentException($"unknown command {kind} v{v}"), };
public static void Log(int now, string line) => Console.WriteLine($"t={now}s {line}");}
// A fake email API. Like many payment and email APIs, it takes an idempotency key and// ignores a repeat, so a retried job can't send the same invoice twice.public sealed class Mailer{ public Dictionary<string, string> Sent { get; } = new();
public void SendInvoice(string key, SendInvoice cmd, int now) { if (Sent.ContainsKey(key)) { Commands.Log(now, $"mailer: duplicate key {key}, not sent again"); return; } Sent[key] = cmd.OrderId; Commands.Log(now, $"mailer: invoice {cmd.OrderId} sent ({cmd.Currency})"); }}
// A fake image service: busy on the first call, and it always chokes on one corrupt file.// Resizing is idempotent by nature (same input, same output file), so it needs no key.public sealed class ImageService{ private readonly Dictionary<string, int> _calls = new();
public void Resize(ResizeImage cmd, int now) { var n = _calls[cmd.Image] = _calls.GetValueOrDefault(cmd.Image) + 1; if (cmd.Image == "img/bad.png") throw new InvalidOperationException("corrupt image"); if (n == 1) throw new InvalidOperationException("image service 503"); Commands.Log(now, $"image: {cmd.Image} resized to {cmd.Width}px"); }}
public sealed class Job{ public string Id { get; } public string Wire { get; } public int VisibleAt { get; set; } public int Attempts { get; set; } public Job(string id, string wire) { Id = id; Wire = wire; }}
public sealed class JobQueue{ public const int LeaseS = 30; // a delivered job is hidden this long; if nobody acks it, it is delivered again public List<Job> Jobs { get; } = new();
public void Enqueue(string id, Dictionary<string, string> fields) => Jobs.Add(new Job(id, global::Wire.Encode(fields)));
public Job? Receive(int now) { // At-least-once delivery: a received job is hidden for a lease, not removed. If the // worker dies before acking, the job comes back. So every handler may run twice. var job = Jobs.FirstOrDefault(j => j.VisibleAt <= now); if (job != null) { job.VisibleAt = now + LeaseS; job.Attempts++; } return job; }
public void Ack(Job job) => Jobs.Remove(job); public void RetryAt(Job job, int at) => job.VisibleAt = at;}
public delegate void Handler(object cmd, string key, int now);
public sealed class Worker{ private const int MaxAttempts = 3; private const int BackoffBaseS = 2; // waits 2 s, then 4 s; real systems add random jitter
private readonly JobQueue _queue; private readonly Dictionary<string, Handler> _handlers; private readonly HashSet<string> _done = new(); // idempotency keys of finished jobs (a table in real life) public List<string> Dead { get; } = new(); // the dead-letter queue: jobs parked for a person to look at public HashSet<string> CrashAfter { get; } = new(); // simulation only: jobs whose worker dies before ack
public Worker(JobQueue queue, Dictionary<string, Handler> handlers) { _queue = queue; _handlers = handlers; }
// Every attempt and its outcome, in order. In production: an append-only table. private void Audit(int now, Job job, string outcome) => Commands.Log(now, $"{job.Id} attempt {job.Attempts}: {outcome}");
public bool Poll(int now) { var job = _queue.Receive(now); if (job == null) return false; var f = global::Wire.Decode(job.Wire); var key = f["key"]; if (_done.Contains(key)) { // Same key as a finished job, e.g. the producer enqueued it twice. Ack, don't rerun. _queue.Ack(job); Audit(now, job, $"skipped, key {key} already done"); return true; } try { _handlers[f["type"]](Commands.Parse(f), key, now); } catch (PermanentException e) { // Retrying can't help, so park it at once instead of burning attempts. _queue.Ack(job); Dead.Add(job.Id); Audit(now, job, $"dead-lettered ({e.Message})"); return true; } catch (Exception e) { if (job.Attempts >= MaxAttempts) { // A poison job: it fails every time. Park it so it stops blocking the queue. _queue.Ack(job); Dead.Add(job.Id); Audit(now, job, $"dead-lettered after {MaxAttempts} attempts ({e.Message})"); } else { var delay = BackoffBaseS << (job.Attempts - 1); _queue.RetryAt(job, now + delay); Audit(now, job, $"failed ({e.Message}), retry in {delay}s"); } return true; } if (CrashAfter.Remove(job.Id)) { // Simulation: the process dies between the side effect and the ack. Nothing below // runs, so the key isn't recorded, and the job is delivered again after its lease. Audit(now, job, "worker crashed before ack"); return true; } _done.Add(key); _queue.Ack(job); Audit(now, job, "done"); return true; }}
public static class Program{ public static void Main() { var mailer = new Mailer(); var images = new ImageService(); var queue = new JobQueue(); var worker = new Worker(queue, new Dictionary<string, Handler> { ["SendInvoice"] = (cmd, key, now) => mailer.SendInvoice(key, (SendInvoice)cmd, now), ["ResizeImage"] = (cmd, key, now) => images.Resize((ResizeImage)cmd, now), });
// The idempotency key comes from what makes the request unique (one invoice per order), not // from a random id per enqueue, so a duplicate enqueue carries the same key. queue.Enqueue("j1", new() { ["type"] = "SendInvoice", ["v"] = "2", ["key"] = "invoice:1001", ["orderId"] = "1001", ["currency"] = "USD" }); queue.Enqueue("j2", new() { ["type"] = "ResizeImage", ["v"] = "1", ["key"] = "resize:img/42.jpg:640", ["image"] = "img/42.jpg", ["width"] = "640" }); queue.Enqueue("j3", new() { ["type"] = "SendInvoice", ["v"] = "1", ["key"] = "invoice:1002", ["orderId"] = "1002" }); // from the old release queue.Enqueue("j4", new() { ["type"] = "SendInvoice", ["v"] = "1", ["key"] = "invoice:1002", ["orderId"] = "1002" }); // enqueued twice queue.Enqueue("j5", new() { ["type"] = "ResizeImage", ["v"] = "1", ["key"] = "resize:img/bad.png:640", ["image"] = "img/bad.png", ["width"] = "640" }); queue.Enqueue("j6", new() { ["type"] = "SendInvoice", ["v"] = "3", ["key"] = "invoice:1003", ["orderId"] = "1003", ["currency"] = "USD" }); // a producer deployed too early Console.WriteLine("wire j1: " + queue.Jobs[0].Wire); // wire j1: {"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"} worker.CrashAfter.Add("j1");
for (var now = 0; now < 40; now++) { while (worker.Poll(now)) { } } // t=0s mailer: invoice 1001 sent (USD) // t=0s j1 attempt 1: worker crashed before ack // t=0s j2 attempt 1: failed (image service 503), retry in 2s // t=0s mailer: invoice 1002 sent (EUR) // t=0s j3 attempt 1: done // t=0s j4 attempt 1: skipped, key invoice:1002 already done // t=0s j5 attempt 1: failed (corrupt image), retry in 2s // t=0s j6 attempt 1: dead-lettered (unknown command SendInvoice v3) // t=2s image: img/42.jpg resized to 640px // t=2s j2 attempt 2: done // t=2s j5 attempt 2: failed (corrupt image), retry in 4s // t=6s j5 attempt 3: dead-lettered after 3 attempts (corrupt image) // t=30s mailer: duplicate key invoice:1001, not sent again // t=30s j1 attempt 2: done
Console.WriteLine($"emails sent: {mailer.Sent.Count}"); // emails sent: 2 Console.WriteLine("dead letters: " + string.Join(", ", worker.Dead)); // dead letters: j6, j5 }}package main
import ( "encoding/json" "errors" "fmt" "strconv" "strings")
const ( leaseS = 30 // a delivered job is hidden this long; if nobody acks it, it is delivered again maxAttempts = 3 backoffBaseS = 2 // waits 2 s, then 4 s; real systems add random jitter)
// The wire format. Once a command crosses a process boundary it is data, not an object with// an execute method: code can't be put on a queue, so the behavior moves to a handler.// encoding/json writes map keys sorted, so the same command always encodes to the same text.func encode(fields map[string]string) string { b, _ := json.Marshal(fields) return string(b)}
func decode(wire string) map[string]string { var f map[string]string _ = json.Unmarshal([]byte(wire), &f) return f}
// Typed commands: what handlers work with after the payload is checked and upgraded.type SendInvoice struct{ OrderID, Currency string }
type ResizeImage struct { Image string Width int}
// PermanentError means retrying can't fix it: unknown type or version, or invalid data.type PermanentError struct{ Msg string }
func (e *PermanentError) Error() string { return e.Msg }
// parse upgrades old versions as they are read ("upcasting"), so jobs enqueued by the// previous release still run after a deploy. Unknown versions fail permanently.func parse(f map[string]string) (any, error) { kind, v := f["type"], f["v"] switch { case kind == "SendInvoice" && v == "1": return SendInvoice{f["orderId"], "EUR"}, nil // v1 had no currency; every v1 order was in euros case kind == "SendInvoice" && v == "2": return SendInvoice{f["orderId"], f["currency"]}, nil case kind == "ResizeImage" && v == "1": w, err := strconv.Atoi(f["width"]) if err != nil { return nil, &PermanentError{"bad width " + f["width"]} } return ResizeImage{f["image"], w}, nil } return nil, &PermanentError{fmt.Sprintf("unknown command %s v%s", kind, v)}}
func logAt(now int, line string) { fmt.Printf("t=%ds %s\n", now, line) }
// Mailer is a fake email API. Like many payment and email APIs, it takes an idempotency key// and ignores a repeat, so a retried job can't send the same invoice twice.type Mailer struct{ Sent map[string]string }
func (m *Mailer) SendInvoice(key string, cmd SendInvoice, now int) error { if _, ok := m.Sent[key]; ok { logAt(now, "mailer: duplicate key "+key+", not sent again") return nil } m.Sent[key] = cmd.OrderID logAt(now, fmt.Sprintf("mailer: invoice %s sent (%s)", cmd.OrderID, cmd.Currency)) return nil}
// ImageService is fake: busy on the first call, and it always chokes on one corrupt file.// Resizing is idempotent by nature (same input, same output file), so it needs no key.type ImageService struct{ calls map[string]int }
func (s *ImageService) Resize(cmd ResizeImage, now int) error { s.calls[cmd.Image]++ if cmd.Image == "img/bad.png" { return errors.New("corrupt image") } if s.calls[cmd.Image] == 1 { return errors.New("image service 503") } logAt(now, fmt.Sprintf("image: %s resized to %dpx", cmd.Image, cmd.Width)) return nil}
type Job struct { ID, Wire string VisibleAt, Attempts int}
type Queue struct{ Jobs []*Job }
func (q *Queue) Enqueue(id string, fields map[string]string) { q.Jobs = append(q.Jobs, &Job{ID: id, Wire: encode(fields)})}
// Receive gives at-least-once delivery: a received job is hidden for a lease, not removed.// If the worker dies before acking, the job comes back. So every handler may run twice.func (q *Queue) Receive(now int) *Job { for _, job := range q.Jobs { if job.VisibleAt <= now { job.VisibleAt = now + leaseS job.Attempts++ return job } } return nil}
func (q *Queue) Ack(job *Job) { for i, j := range q.Jobs { if j == job { q.Jobs = append(q.Jobs[:i], q.Jobs[i+1:]...) return } }}
func (q *Queue) RetryAt(job *Job, at int) { job.VisibleAt = at }
type Handler func(cmd any, key string, now int) error
type Worker struct { queue *Queue handlers map[string]Handler done map[string]bool // idempotency keys of finished jobs (a table in real life) Dead []string // the dead-letter queue: jobs parked for a person to look at CrashAfter map[string]bool // simulation only: jobs whose worker dies before ack}
// audit records every attempt and its outcome, in order. In production: an append-only table.func (w *Worker) audit(now int, job *Job, outcome string) { logAt(now, fmt.Sprintf("%s attempt %d: %s", job.ID, job.Attempts, outcome))}
func (w *Worker) Poll(now int) bool { job := w.queue.Receive(now) if job == nil { return false } f := decode(job.Wire) key := f["key"] if w.done[key] { // Same key as a finished job, e.g. the producer enqueued it twice. Ack, don't rerun. w.queue.Ack(job) w.audit(now, job, "skipped, key "+key+" already done") return true } cmd, err := parse(f) if err == nil { err = w.handlers[f["type"]](cmd, key, now) } var permanent *PermanentError switch { case errors.As(err, &permanent): // Retrying can't help, so park it at once instead of burning attempts. w.queue.Ack(job) w.Dead = append(w.Dead, job.ID) w.audit(now, job, fmt.Sprintf("dead-lettered (%v)", err)) return true case err != nil && job.Attempts >= maxAttempts: // A poison job: it fails every time. Park it so it stops blocking the queue. w.queue.Ack(job) w.Dead = append(w.Dead, job.ID) w.audit(now, job, fmt.Sprintf("dead-lettered after %d attempts (%v)", maxAttempts, err)) return true case err != nil: delay := backoffBaseS << (job.Attempts - 1) w.queue.RetryAt(job, now+delay) w.audit(now, job, fmt.Sprintf("failed (%v), retry in %ds", err, delay)) return true } if w.CrashAfter[job.ID] { // Simulation: the process dies between the side effect and the ack. Nothing below // runs, so the key isn't recorded, and the job is delivered again after its lease. delete(w.CrashAfter, job.ID) w.audit(now, job, "worker crashed before ack") return true } w.done[key] = true w.queue.Ack(job) w.audit(now, job, "done") return true}
func main() { mailer := &Mailer{Sent: map[string]string{}} images := &ImageService{calls: map[string]int{}} queue := &Queue{} worker := &Worker{ queue: queue, handlers: map[string]Handler{ "SendInvoice": func(cmd any, key string, now int) error { return mailer.SendInvoice(key, cmd.(SendInvoice), now) }, "ResizeImage": func(cmd any, key string, now int) error { return images.Resize(cmd.(ResizeImage), now) }, }, done: map[string]bool{}, CrashAfter: map[string]bool{}, }
// The idempotency key comes from what makes the request unique (one invoice per order), not // from a random id per enqueue, so a duplicate enqueue carries the same key. queue.Enqueue("j1", map[string]string{"type": "SendInvoice", "v": "2", "key": "invoice:1001", "orderId": "1001", "currency": "USD"}) queue.Enqueue("j2", map[string]string{"type": "ResizeImage", "v": "1", "key": "resize:img/42.jpg:640", "image": "img/42.jpg", "width": "640"}) queue.Enqueue("j3", map[string]string{"type": "SendInvoice", "v": "1", "key": "invoice:1002", "orderId": "1002"}) // from the old release queue.Enqueue("j4", map[string]string{"type": "SendInvoice", "v": "1", "key": "invoice:1002", "orderId": "1002"}) // enqueued twice queue.Enqueue("j5", map[string]string{"type": "ResizeImage", "v": "1", "key": "resize:img/bad.png:640", "image": "img/bad.png", "width": "640"}) queue.Enqueue("j6", map[string]string{"type": "SendInvoice", "v": "3", "key": "invoice:1003", "orderId": "1003", "currency": "USD"}) // a producer deployed too early fmt.Println("wire j1: " + queue.Jobs[0].Wire) // wire j1: {"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"} worker.CrashAfter["j1"] = true
for now := 0; now < 40; now++ { for worker.Poll(now) { } } // t=0s mailer: invoice 1001 sent (USD) // t=0s j1 attempt 1: worker crashed before ack // t=0s j2 attempt 1: failed (image service 503), retry in 2s // t=0s mailer: invoice 1002 sent (EUR) // t=0s j3 attempt 1: done // t=0s j4 attempt 1: skipped, key invoice:1002 already done // t=0s j5 attempt 1: failed (corrupt image), retry in 2s // t=0s j6 attempt 1: dead-lettered (unknown command SendInvoice v3) // t=2s image: img/42.jpg resized to 640px // t=2s j2 attempt 2: done // t=2s j5 attempt 2: failed (corrupt image), retry in 4s // t=6s j5 attempt 3: dead-lettered after 3 attempts (corrupt image) // t=30s mailer: duplicate key invoice:1001, not sent again // t=30s j1 attempt 2: done
fmt.Printf("emails sent: %d\n", len(mailer.Sent)) // emails sent: 2 fmt.Println("dead letters: " + strings.Join(worker.Dead, ", ")) // dead letters: j6, j5}const LEASE_S = 30; // a delivered job is hidden this long; if nobody acks it, it is delivered againconst MAX_ATTEMPTS = 3;const BACKOFF_BASE_S = 2; // waits 2 s, then 4 s; real systems add random jitter
type Fields = Record<string, string>;
// The wire format. Once a command crosses a process boundary it is data, not an object with// an execute method: code can't be put on a queue, so the behavior moves to a handler.function encode(fields: Fields): string { // Sorted keys and string values: the same command always encodes to the same text. const sorted = Object.fromEntries(Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : 1))); return JSON.stringify(sorted);}
function decode(wire: string): Fields { return JSON.parse(wire) as Fields;}
// Typed commands: what handlers work with after the payload is checked and upgraded.type SendInvoice = { kind: 'SendInvoice'; orderId: string; currency: string };type ResizeImage = { kind: 'ResizeImage'; image: string; width: number };type JobCommand = SendInvoice | ResizeImage;
// Retrying can't fix it: unknown type or version, or invalid data.class PermanentError extends Error {}
// Old versions are upgraded as they are read ("upcasting"), so jobs enqueued by the// previous release still run after a deploy. Unknown versions fail permanently.function parse(f: Fields): JobCommand { const kind = f.type; const v = f.v; if (kind === 'SendInvoice' && v === '1') { return { kind, orderId: f.orderId, currency: 'EUR' }; // v1 had no currency; every v1 order was in euros } if (kind === 'SendInvoice' && v === '2') return { kind, orderId: f.orderId, currency: f.currency }; if (kind === 'ResizeImage' && v === '1') return { kind, image: f.image, width: Number(f.width) }; throw new PermanentError(`unknown command ${kind} v${v}`);}
function log(now: number, line: string): void { console.log(`t=${now}s ${line}`);}
// A fake email API. Like many payment and email APIs, it takes an idempotency key and// ignores a repeat, so a retried job can't send the same invoice twice.class Mailer { readonly sent = new Map<string, string>(); sendInvoice(key: string, cmd: SendInvoice, now: number): void { if (this.sent.has(key)) { log(now, `mailer: duplicate key ${key}, not sent again`); return; } this.sent.set(key, cmd.orderId); log(now, `mailer: invoice ${cmd.orderId} sent (${cmd.currency})`); }}
// A fake image service: busy on the first call, and it always chokes on one corrupt file.// Resizing is idempotent by nature (same input, same output file), so it needs no key.class ImageService { private readonly calls = new Map<string, number>(); resize(cmd: ResizeImage, now: number): void { const n = (this.calls.get(cmd.image) ?? 0) + 1; this.calls.set(cmd.image, n); if (cmd.image === 'img/bad.png') throw new Error('corrupt image'); if (n === 1) throw new Error('image service 503'); log(now, `image: ${cmd.image} resized to ${cmd.width}px`); }}
class Job { visibleAt = 0; attempts = 0; constructor(readonly id: string, readonly wire: string) {}}
class Queue { readonly jobs: Job[] = [];
enqueue(id: string, fields: Fields): void { this.jobs.push(new Job(id, encode(fields))); }
receive(now: number): Job | undefined { // At-least-once delivery: a received job is hidden for a lease, not removed. If the // worker dies before acking, the job comes back. So every handler may run twice. const job = this.jobs.find((j) => j.visibleAt <= now); if (job) { job.visibleAt = now + LEASE_S; job.attempts++; } return job; }
ack(job: Job): void { this.jobs.splice(this.jobs.indexOf(job), 1); }
retryAt(job: Job, at: number): void { job.visibleAt = at; }}
type Handler = (cmd: JobCommand, key: string, now: number) => void;
class Worker { private readonly done = new Set<string>(); // idempotency keys of finished jobs (a table in real life) readonly dead: string[] = []; // the dead-letter queue: jobs parked for a person to look at readonly crashAfter = new Set<string>(); // simulation only: jobs whose worker dies before ack
constructor(private readonly queue: Queue, private readonly handlers: Record<string, Handler>) {}
// Every attempt and its outcome, in order. In production: an append-only table. private audit(now: number, job: Job, outcome: string): void { log(now, `${job.id} attempt ${job.attempts}: ${outcome}`); }
poll(now: number): boolean { const job = this.queue.receive(now); if (!job) return false; const f = decode(job.wire); const key = f.key; if (this.done.has(key)) { // Same key as a finished job, e.g. the producer enqueued it twice. Ack, don't rerun. this.queue.ack(job); this.audit(now, job, `skipped, key ${key} already done`); return true; } try { this.handlers[f.type](parse(f), key, now); } catch (e) { const msg = (e as Error).message; if (e instanceof PermanentError) { // Retrying can't help, so park it at once instead of burning attempts. this.queue.ack(job); this.dead.push(job.id); this.audit(now, job, `dead-lettered (${msg})`); } else if (job.attempts >= MAX_ATTEMPTS) { // A poison job: it fails every time. Park it so it stops blocking the queue. this.queue.ack(job); this.dead.push(job.id); this.audit(now, job, `dead-lettered after ${MAX_ATTEMPTS} attempts (${msg})`); } else { const delay = BACKOFF_BASE_S * 2 ** (job.attempts - 1); this.queue.retryAt(job, now + delay); this.audit(now, job, `failed (${msg}), retry in ${delay}s`); } return true; } if (this.crashAfter.delete(job.id)) { // Simulation: the process dies between the side effect and the ack. Nothing below // runs, so the key isn't recorded, and the job is delivered again after its lease. this.audit(now, job, 'worker crashed before ack'); return true; } this.done.add(key); this.queue.ack(job); this.audit(now, job, 'done'); return true; }}
const mailer = new Mailer();const images = new ImageService();const queue = new Queue();const worker = new Worker(queue, { SendInvoice: (cmd, key, now) => mailer.sendInvoice(key, cmd as SendInvoice, now), ResizeImage: (cmd, _key, now) => images.resize(cmd as ResizeImage, now),});
// The idempotency key comes from what makes the request unique (one invoice per order), not// from a random id per enqueue, so a duplicate enqueue carries the same key.queue.enqueue('j1', { type: 'SendInvoice', v: '2', key: 'invoice:1001', orderId: '1001', currency: 'USD' });queue.enqueue('j2', { type: 'ResizeImage', v: '1', key: 'resize:img/42.jpg:640', image: 'img/42.jpg', width: '640' });queue.enqueue('j3', { type: 'SendInvoice', v: '1', key: 'invoice:1002', orderId: '1002' }); // from the old releasequeue.enqueue('j4', { type: 'SendInvoice', v: '1', key: 'invoice:1002', orderId: '1002' }); // enqueued twicequeue.enqueue('j5', { type: 'ResizeImage', v: '1', key: 'resize:img/bad.png:640', image: 'img/bad.png', width: '640' });queue.enqueue('j6', { type: 'SendInvoice', v: '3', key: 'invoice:1003', orderId: '1003', currency: 'USD' }); // a producer deployed too earlyconsole.log('wire j1: ' + queue.jobs[0].wire); // wire j1: {"currency":"USD","key":"invoice:1001","orderId":"1001","type":"SendInvoice","v":"2"}worker.crashAfter.add('j1');
for (let now = 0; now < 40; now++) { while (worker.poll(now)) { // drain everything visible at this second }}// t=0s mailer: invoice 1001 sent (USD)// t=0s j1 attempt 1: worker crashed before ack// t=0s j2 attempt 1: failed (image service 503), retry in 2s// t=0s mailer: invoice 1002 sent (EUR)// t=0s j3 attempt 1: done// t=0s j4 attempt 1: skipped, key invoice:1002 already done// t=0s j5 attempt 1: failed (corrupt image), retry in 2s// t=0s j6 attempt 1: dead-lettered (unknown command SendInvoice v3)// t=2s image: img/42.jpg resized to 640px// t=2s j2 attempt 2: done// t=2s j5 attempt 2: failed (corrupt image), retry in 4s// t=6s j5 attempt 3: dead-lettered after 3 attempts (corrupt image)// t=30s mailer: duplicate key invoice:1001, not sent again// t=30s j1 attempt 2: done
console.log(`emails sent: ${mailer.sent.size}`); // emails sent: 2console.log('dead letters: ' + worker.dead.join(', ')); // dead letters: j6, j5What to notice
Section titled “What to notice”- The key comes from the business, not from the enqueue.
invoice:1001means “the invoice for order 1001”. A random id per enqueue would give the duplicatej4a new key, and the customer two emails. - Two layers of idempotency. The worker records finished keys, and skips a job whose key is done
(
j4). That doesn’t cover a crash between the side effect and recording the key (j1). So the key also goes to the side effect itself. Many payment and email APIs accept one for this reason. Stripe’sIdempotency-Keyheader is a well-known example. When the side effect is a write to your own database, record the key in the same transaction as the write. - Some commands are idempotent by nature. Resizing an image to the same output file twice gives
the same result. So
ResizeImageneeds no key at the side effect. Sending, charging and incrementing do. - Retry what might succeed. Park what can’t. An unknown version is a permanent error, so
j6goes to the dead-letter queue at once. Other errors are retried with exponential backoff (2 s, then 4 s). Real systems add random jitter, so a thousand failed jobs don’t all retry in the same second. - A poison job is capped, not retried forever. The corrupt image fails every attempt. After 3 it moves to the dead-letter queue, which should alert someone and let them replay the job once the cause is fixed. SQS (a redrive policy with a maximum receive count) and RabbitMQ (dead-letter exchanges) have this built in.
- The audit log is every attempt, in order. Who enqueued what, each attempt, each outcome. It’s an append-only record, separate from the queue, and it’s how support answers “did the customer get the invoice?” weeks later.
- The lease must outlast the job. Say a job takes longer than its lease. The queue delivers it
to a second worker, while the first is still running. Both may pass the “is this key done?” check
at once. So extend the lease while working (SQS’s
ChangeMessageVisibility). And make recording the key an atomic claim, such as an insert that fails if the key exists. - Retries reorder jobs.
j2finishes afterj3although it was enqueued first. Handlers must not assume they see jobs in enqueue order.
After a deploy, some customers got their invoice email twice. The send happens in a background job. What happened, and how do you make sure it can’t happen again?
Whether you name at-least-once delivery. (The worker was stopped after sending, but before acking, so the job was delivered again.) Whether you fix it with an idempotency key derived from the order, checked by the worker and passed to the email provider. And whether you add a bounded retry policy and a dead-letter queue. The red flag is “make the queue exactly-once”, or “add a sleep before the ack”.
Each job is a serialized command: a type, a version, an idempotency key, and a payload of ids. Delivery is at least once, so handlers are idempotent. The key is derived from the order. It’s recorded when the job finishes, and passed to the email API. Temporary errors retry with exponential backoff and jitter. Permanent errors, and jobs that fail three times, go to a dead-letter queue. Every attempt goes to an audit log.
Tradeoffs
Section titled “Tradeoffs”Use a command when the request must outlive the call. That’s when it’s undone, logged, queued, retried, scheduled or sent to another process. Or when many triggers must share one action. Call the method directly, or pass a function, when the action runs once, right away, and nothing needs to remember it.
In each language
Section titled “In each language”| Language | Usual form | Built in or common |
|---|---|---|
| Java | An interface with execute(), or Runnable and Callable when no undo or data is needed |
Swing’s Action and javax.swing.undo (UndoableEdit, UndoManager), ExecutorService takes Runnable and Callable |
| Python | A class with execute and undo, or any callable (functools.partial binds arguments) |
Celery tasks: task.delay(...) serializes the task name and arguments onto a queue |
| C# | An interface, or Action and Func<T> delegates |
WPF’s ICommand with Execute, CanExecute and CanExecuteChanged; libraries such as MediatR send request objects to handlers |
| Go | A small interface, or a func() value; serialized jobs as a type name plus a []byte payload |
Job libraries such as asynq create tasks from a type name and a payload |
| TypeScript | An object with execute and undo, or a closure; serialized commands as plain objects with a type |
VS Code’s commands.registerCommand and executeCommand; Redux actions are plain objects with a type |
In languages with first-class functions, a command with only execute is a closure, and that’s
usually the right choice. A class earns its place when the command also needs undo, a label,
canExecute, merging, or a serializable form.
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Command because | The difference |
|---|---|---|
| Strategy | Both put behavior in an object behind an interface | A strategy is how to do a step the caller is already doing, chosen now and called for an answer. A command is what to do, a request that can be stored, undone or run later by someone else. |
| Memento | Both are used to build undo | A memento is a snapshot of state, with no behavior. A command is an action that knows how to reverse itself. Undo can use either, or both. |
| Observer and events | Both are messages that decouple a sender from a receiver | A command asks for something to happen (SendInvoice) and has one handler, which may refuse. An event says something already happened (InvoiceSent) and has any number of listeners. Redux’s style guide recommends naming actions as events for this reason. |
| A plain callback or lambda | A callback is code passed to run later | A command with only execute and no data to keep is a callback. Use a function until you need undo, a label, canExecute, or a serializable form. |
| Chain of responsibility | Both pass a request object to a handler | In a chain, several handlers are tried in turn until one takes the request. A command usually goes to one known handler. They combine: a request object travelling down a chain is a command. |
| Event sourcing | Both keep a log of things to replay | Event sourcing stores the events that happened, and rebuilds state from them. Commands are the requests that produced those events; a command can be rejected, an event can’t. |
Where it shows up
Section titled “Where it shows up”On this site:
- Tic-tac-toe: each move is a small value that can be undone, and the page explains when a full Command class would be worth it.
- Text editor with undo: the Medium level, worked as a full design problem.
- Strategy, for the pattern most often confused with this one.
In real systems, you’ll find commands in:
- Menu and toolbar actions in desktop frameworks, and command palettes in editors such as VS Code.
- Undo in every document editor.
- Transaction logs, and database migrations. Each migration is a versioned step, applied in order.
- Background job systems (Celery, Sidekiq, asynq).
- The command side of CQRS systems.
Interview questions
Section titled “Interview questions”Interviewer asksHow would you implement undo and redo?
Make every action a command with execute and undo, where the command captures what it needs to
reverse itself when it runs. Keep an undo stack and a redo stack: run pushes and clears redo, undo
moves a command from undo to redo, redo moves it back. Merge consecutive typing into one step and
bound the stacks. The alternative is snapshots (Memento), which are simpler but store the whole
state per step.
Interviewer asksWhat's the difference between Command and Strategy?
A strategy answers “how should this step be done?” for code that is already running. It’s called for its result. A command is “do this”, captured as an object. So it can be stored, undone, logged, or run later by someone else. Strategies are usually swapped by configuration. Commands are usually created per request.
Interviewer asksHow do you make a queued command safe to retry?
Assume at-least-once delivery. Give every command an idempotency key, derived from what makes it unique (the order id for an invoice). Record finished keys durably, and skip duplicates. Pass the key to outside side effects that support one. Retry temporary errors with capped exponential backoff and jitter. Send permanent errors and repeated failures to a dead-letter queue. And version the payload, so old jobs still parse after a deploy.
Interviewer asksWhat about actions that can't be undone, like sending an email?
There are three options. Keep them off the undo stack, and say so in the UI. Delay them: Gmail’s “undo send” holds the email for a few seconds before sending. Or offer a compensating command. It does a new action, instead of reversing the old one: a refund for a charge, a correction for an email. Undo inside a document is a true inverse. Undo in the outside world is usually compensation.
Interviewer asksWhen is a command just a function?
When it runs once, right away, and has no undo, no label, no canExecute and no need to be stored
or sent anywhere. In Java that’s a Runnable, in C# an Action, in Python, Go and TypeScript a
function value. Promote it to a class when one of those needs appears.
| Decision | We chose | We gave up |
|---|---|---|
| Where an action’s logic lives | One command object, shared by every trigger | A class per action and one more hop |
| Enabling buttons | canExecute on the command, asked by every invoker |
Nothing; it removes drift |
| Undo | Commands with undo, data captured on execute |
Every inverse must be exactly right |
| History | Undo and redo stacks, redo cleared on a new edit, bounded | Old steps and the undone branch are lost |
| Typing | Consecutive keys merged into one step | A rule to tune (time, word boundaries) |
| Commands across processes | Type, version, key and payload, handled by type | Payloads are a contract between releases |
| Delivery | At least once, with idempotency keys from the business | Every handler and side effect must be idempotent |
| Failures | Capped backoff for transient errors, a dead-letter queue for the rest | Someone must watch and replay the DLQ |
Further reading
Section titled “Further reading”- Strategy, for behavior chosen now rather than requested for later
- Observer, for events: what happened, rather than what to do
- Message queues, for leases, acks and dead-letter queues in depth
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Command chapter
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.