Episode 9 — System Design / 9.5 — Behavioral Design Patterns
9.5.c Command Pattern
Overview
The Command Pattern encapsulates a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
Think of it like a restaurant: you don't tell the chef directly what to cook. You write your order on a slip (the Command), give it to the waiter (the Invoker), who passes it to the kitchen (the Receiver). The slip can be queued, logged, or even cancelled.
+--------------------------------------------------------------+
| COMMAND PATTERN |
| |
| Client Invoker Command Receiver |
| +------+ +--------+ +----------+ +---------+ |
| | |----->| |----->| |--->| | |
| |Create | |Execute | | execute()| | Action | |
| |Command| |Command | | undo() | | Logic | |
| +------+ +--------+ +----------+ +---------+ |
| | |
| | History Stack |
| | [cmd1, cmd2, cmd3] |
| | ^ |
| | | undo() pops and reverses |
| |
+--------------------------------------------------------------+
The Problem: Direct Coupling
// BAD: UI directly calls business logic
class TextEditorUI {
constructor(document) {
this.document = document;
}
onBoldClick() {
// UI knows HOW to make text bold
this.document.selection.wrap('<b>', '</b>');
this.document.markDirty();
this.document.updateDisplay();
// No way to undo!
// No way to record for macros!
// No way to queue operations!
}
onCopyClick() {
// UI knows HOW to copy
const text = this.document.getSelectedText();
navigator.clipboard.writeText(text);
// Duplicated logic if keyboard shortcut does the same thing
}
}
Problems:
- UI is coupled to business logic
- No undo/redo capability
- Cannot queue, delay, or schedule operations
- Cannot create macros (sequences of operations)
- Cannot log/audit operations
Core Concepts
+-------------------------------------------------------+
| |
| COMMAND PATTERN PARTICIPANTS |
| |
| Command Interface: |
| + execute() -- perform the action |
| + undo() -- reverse the action |
| |
| Concrete Command: |
| - Stores the receiver and parameters |
| - Implements execute() and undo() |
| |
| Receiver: |
| - The object that actually does the work |
| - Has the business logic |
| |
| Invoker: |
| - Asks the command to execute |
| - Manages command history for undo/redo |
| - Doesn't know what the command does |
| |
| Client: |
| - Creates commands and configures them |
| - Associates commands with invoker |
| |
+-------------------------------------------------------+
Implementation 1: Text Editor with Full Undo/Redo
// ============================================
// COMMAND INTERFACE
// ============================================
class Command {
execute() {
throw new Error('Command.execute() must be implemented');
}
undo() {
throw new Error('Command.undo() must be implemented');
}
describe() {
return 'Unknown command';
}
}
// ============================================
// RECEIVER: Text Document
// ============================================
class TextDocument {
constructor(content = '') {
this.content = content;
this.cursorPosition = content.length;
this.selectionStart = null;
this.selectionEnd = null;
}
insertAt(position, text) {
this.content =
this.content.slice(0, position) +
text +
this.content.slice(position);
this.cursorPosition = position + text.length;
}
deleteRange(start, end) {
const deleted = this.content.slice(start, end);
this.content =
this.content.slice(0, start) +
this.content.slice(end);
this.cursorPosition = start;
return deleted;
}
replaceRange(start, end, newText) {
const old = this.content.slice(start, end);
this.content =
this.content.slice(0, start) +
newText +
this.content.slice(end);
this.cursorPosition = start + newText.length;
return old;
}
getContent() {
return this.content;
}
getLength() {
return this.content.length;
}
toString() {
return this.content;
}
}
// ============================================
// CONCRETE COMMANDS
// ============================================
class InsertTextCommand extends Command {
constructor(document, position, text) {
super();
this.document = document;
this.position = position;
this.text = text;
}
execute() {
this.document.insertAt(this.position, this.text);
console.log(`[Insert] "${this.text}" at position ${this.position}`);
}
undo() {
this.document.deleteRange(this.position, this.position + this.text.length);
console.log(`[Undo Insert] Removed "${this.text}" from position ${this.position}`);
}
describe() {
return `Insert "${this.text.slice(0, 20)}${this.text.length > 20 ? '...' : ''}"`;
}
}
class DeleteTextCommand extends Command {
constructor(document, start, end) {
super();
this.document = document;
this.start = start;
this.end = end;
this.deletedText = null; // Stored during execute for undo
}
execute() {
this.deletedText = this.document.deleteRange(this.start, this.end);
console.log(`[Delete] "${this.deletedText}" from ${this.start}-${this.end}`);
}
undo() {
this.document.insertAt(this.start, this.deletedText);
console.log(`[Undo Delete] Restored "${this.deletedText}" at ${this.start}`);
}
describe() {
return `Delete "${(this.deletedText || '').slice(0, 20)}"`;
}
}
class ReplaceTextCommand extends Command {
constructor(document, start, end, newText) {
super();
this.document = document;
this.start = start;
this.end = end;
this.newText = newText;
this.oldText = null; // Stored during execute
}
execute() {
this.oldText = this.document.replaceRange(this.start, this.end, this.newText);
console.log(`[Replace] "${this.oldText}" -> "${this.newText}"`);
}
undo() {
this.document.replaceRange(this.start, this.start + this.newText.length, this.oldText);
console.log(`[Undo Replace] "${this.newText}" -> "${this.oldText}"`);
}
describe() {
return `Replace "${(this.oldText || '').slice(0, 10)}" with "${this.newText.slice(0, 10)}"`;
}
}
class UpperCaseCommand extends Command {
constructor(document, start, end) {
super();
this.document = document;
this.start = start;
this.end = end;
this.originalText = null;
}
execute() {
const text = this.document.content.slice(this.start, this.end);
this.originalText = text;
this.document.replaceRange(this.start, this.end, text.toUpperCase());
console.log(`[UpperCase] "${this.originalText}" -> "${text.toUpperCase()}"`);
}
undo() {
this.document.replaceRange(this.start, this.start + this.originalText.length, this.originalText);
console.log(`[Undo UpperCase] Restored "${this.originalText}"`);
}
describe() {
return `UpperCase "${(this.originalText || '').slice(0, 20)}"`;
}
}
// ============================================
// INVOKER: Editor with Undo/Redo
// ============================================
class TextEditor {
constructor() {
this.document = new TextDocument();
this.undoStack = []; // Commands that have been executed
this.redoStack = []; // Commands that have been undone
this.maxHistory = 100;
}
executeCommand(command) {
command.execute();
this.undoStack.push(command);
this.redoStack = []; // Clear redo stack on new action
// Trim history if too long
if (this.undoStack.length > this.maxHistory) {
this.undoStack.shift();
}
this._printStatus();
}
undo() {
if (this.undoStack.length === 0) {
console.log('[Editor] Nothing to undo');
return;
}
const command = this.undoStack.pop();
command.undo();
this.redoStack.push(command);
this._printStatus();
}
redo() {
if (this.redoStack.length === 0) {
console.log('[Editor] Nothing to redo');
return;
}
const command = this.redoStack.pop();
command.execute();
this.undoStack.push(command);
this._printStatus();
}
// Convenience methods
type(text) {
const cmd = new InsertTextCommand(
this.document,
this.document.cursorPosition,
text
);
this.executeCommand(cmd);
}
deleteSelection(start, end) {
const cmd = new DeleteTextCommand(this.document, start, end);
this.executeCommand(cmd);
}
replace(start, end, newText) {
const cmd = new ReplaceTextCommand(this.document, start, end, newText);
this.executeCommand(cmd);
}
upperCase(start, end) {
const cmd = new UpperCaseCommand(this.document, start, end);
this.executeCommand(cmd);
}
getHistory() {
return this.undoStack.map((cmd, i) => `${i + 1}. ${cmd.describe()}`);
}
_printStatus() {
console.log(
` Content: "${this.document.getContent()}" | ` +
`Undo: ${this.undoStack.length} | Redo: ${this.redoStack.length}`
);
}
}
// ============================================
// USAGE
// ============================================
const editor = new TextEditor();
editor.type('Hello'); // "Hello"
editor.type(' World'); // "Hello World"
editor.type('!'); // "Hello World!"
editor.upperCase(0, 5); // "HELLO World!"
editor.undo(); // "Hello World!"
editor.undo(); // "Hello World"
editor.redo(); // "Hello World!"
editor.replace(6, 11, 'JavaScript'); // "Hello JavaScript!"
console.log('\nHistory:');
editor.getHistory().forEach(h => console.log(h));
Implementation 2: Macro Commands (Composite Commands)
// ============================================
// MACRO COMMAND: Execute multiple commands as one
// ============================================
class MacroCommand extends Command {
constructor(name = 'Macro') {
super();
this.name = name;
this.commands = [];
}
add(command) {
this.commands.push(command);
return this; // Chainable
}
execute() {
console.log(`[Macro: ${this.name}] Executing ${this.commands.length} commands...`);
for (const command of this.commands) {
command.execute();
}
}
undo() {
console.log(`[Macro: ${this.name}] Undoing ${this.commands.length} commands...`);
// Undo in reverse order!
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo();
}
}
describe() {
return `Macro "${this.name}" (${this.commands.length} steps)`;
}
}
// ============================================
// MACRO RECORDER
// ============================================
class MacroRecorder {
constructor() {
this.recording = false;
this.currentMacro = null;
this.savedMacros = new Map();
}
startRecording(name) {
this.recording = true;
this.currentMacro = new MacroCommand(name);
console.log(`[Recorder] Recording macro: "${name}"`);
}
recordCommand(command) {
if (this.recording && this.currentMacro) {
this.currentMacro.add(command);
console.log(`[Recorder] Recorded: ${command.describe()}`);
}
}
stopRecording() {
if (!this.recording) return null;
this.recording = false;
const macro = this.currentMacro;
this.savedMacros.set(macro.name, macro);
this.currentMacro = null;
console.log(
`[Recorder] Saved macro "${macro.name}" with ${macro.commands.length} commands`
);
return macro;
}
getMacro(name) {
return this.savedMacros.get(name);
}
listMacros() {
return [...this.savedMacros.entries()].map(
([name, macro]) => `${name}: ${macro.commands.length} commands`
);
}
}
// ============================================
// USAGE: Record and replay macros
// ============================================
const doc = new TextDocument('Hello World');
const recorder = new MacroRecorder();
// Record a "format title" macro
recorder.startRecording('Format Title');
const cmd1 = new UpperCaseCommand(doc, 0, doc.getLength());
cmd1.execute();
recorder.recordCommand(cmd1);
const cmd2 = new InsertTextCommand(doc, 0, '=== ');
cmd2.execute();
recorder.recordCommand(cmd2);
const cmd3 = new InsertTextCommand(doc, doc.getLength(), ' ===');
cmd3.execute();
recorder.recordCommand(cmd3);
const macro = recorder.stopRecording();
console.log(`Document: "${doc.getContent()}"`);
// "=== HELLO WORLD ==="
// Undo the entire macro in one step
macro.undo();
console.log(`After undo: "${doc.getContent()}"`);
// "Hello World"
Implementation 3: Task Queue / Job System
// ============================================
// COMMAND-BASED TASK QUEUE
// ============================================
class TaskCommand extends Command {
constructor(name, executeFn, undoFn = null) {
super();
this.name = name;
this.executeFn = executeFn;
this.undoFn = undoFn;
this.result = null;
this.executedAt = null;
this.status = 'pending'; // pending, running, completed, failed
}
async execute() {
this.status = 'running';
this.executedAt = Date.now();
try {
this.result = await this.executeFn();
this.status = 'completed';
return this.result;
} catch (error) {
this.status = 'failed';
this.error = error;
throw error;
}
}
async undo() {
if (this.undoFn) {
await this.undoFn(this.result);
}
}
describe() {
return `[${this.status}] ${this.name}`;
}
}
class TaskQueue {
constructor(options = {}) {
this.queue = [];
this.history = [];
this.running = false;
this.concurrency = options.concurrency || 1;
this.retryCount = options.retryCount || 0;
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || (() => {});
this.activeCount = 0;
}
enqueue(command, priority = 0) {
this.queue.push({ command, priority, addedAt: Date.now() });
this.queue.sort((a, b) => b.priority - a.priority); // Higher priority first
console.log(
`[Queue] Enqueued: ${command.describe()} (priority: ${priority}, ` +
`queue size: ${this.queue.length})`
);
if (this.running) {
this._processNext();
}
return this;
}
async start() {
console.log(`[Queue] Starting with concurrency: ${this.concurrency}`);
this.running = true;
const promises = [];
for (let i = 0; i < this.concurrency; i++) {
promises.push(this._processNext());
}
await Promise.all(promises);
console.log(`[Queue] All tasks completed. History: ${this.history.length}`);
}
async _processNext() {
while (this.running && this.queue.length > 0 && this.activeCount < this.concurrency) {
const { command } = this.queue.shift();
this.activeCount++;
try {
console.log(`[Queue] Executing: ${command.name}`);
await this._executeWithRetry(command);
this.history.push(command);
this.onComplete(command);
} catch (error) {
console.error(`[Queue] Failed: ${command.name} - ${error.message}`);
this.onError(command, error);
} finally {
this.activeCount--;
}
}
}
async _executeWithRetry(command, attempt = 0) {
try {
return await command.execute();
} catch (error) {
if (attempt < this.retryCount) {
console.log(`[Queue] Retrying ${command.name} (attempt ${attempt + 1}/${this.retryCount})`);
return this._executeWithRetry(command, attempt + 1);
}
throw error;
}
}
stop() {
this.running = false;
console.log(`[Queue] Stopped. ${this.queue.length} tasks remaining.`);
}
getStatus() {
return {
queued: this.queue.length,
active: this.activeCount,
completed: this.history.length,
running: this.running
};
}
// Undo last N commands
async undoLast(n = 1) {
const toUndo = this.history.splice(-n);
for (const cmd of toUndo.reverse()) {
console.log(`[Queue] Undoing: ${cmd.name}`);
await cmd.undo();
}
}
}
// ============================================
// USAGE
// ============================================
const queue = new TaskQueue({
concurrency: 2,
retryCount: 1,
onComplete: (cmd) => console.log(` Completed: ${cmd.name} in ${Date.now() - cmd.executedAt}ms`),
onError: (cmd, err) => console.log(` Error: ${cmd.name}: ${err.message}`)
});
// Create task commands
const fetchUsers = new TaskCommand(
'Fetch Users',
async () => {
// Simulate API call
await new Promise(r => setTimeout(r, 100));
return [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
}
);
const processData = new TaskCommand(
'Process Data',
async () => {
await new Promise(r => setTimeout(r, 50));
return { processed: true, count: 42 };
}
);
const sendEmail = new TaskCommand(
'Send Notification',
async () => {
await new Promise(r => setTimeout(r, 75));
return { sent: true, to: 'admin@example.com' };
},
async (result) => {
console.log(` [Undo] Would recall email sent to ${result.to}`);
}
);
queue.enqueue(fetchUsers, 10); // High priority
queue.enqueue(processData, 5); // Medium priority
queue.enqueue(sendEmail, 1); // Low priority
// queue.start();
Implementation 4: Smart Home Remote Control
// ============================================
// RECEIVERS: Smart Home Devices
// ============================================
class Light {
constructor(room) {
this.room = room;
this.isOn = false;
this.brightness = 100;
this.color = '#FFFFFF';
}
on() { this.isOn = true; console.log(` [${this.room} Light] ON`); }
off() { this.isOn = false; console.log(` [${this.room} Light] OFF`); }
dim(level) {
this.brightness = level;
console.log(` [${this.room} Light] Dimmed to ${level}%`);
}
setColor(color) {
this.color = color;
console.log(` [${this.room} Light] Color: ${color}`);
}
}
class Thermostat {
constructor() {
this.temperature = 72;
this.mode = 'auto';
}
setTemp(temp) {
const old = this.temperature;
this.temperature = temp;
console.log(` [Thermostat] ${old}F -> ${temp}F`);
return old;
}
setMode(mode) {
const old = this.mode;
this.mode = mode;
console.log(` [Thermostat] Mode: ${old} -> ${mode}`);
return old;
}
}
class Speaker {
constructor() {
this.volume = 50;
this.playing = false;
this.playlist = null;
}
play(playlist) {
this.playing = true;
this.playlist = playlist;
console.log(` [Speaker] Playing: ${playlist}`);
}
stop() {
this.playing = false;
console.log(' [Speaker] Stopped');
}
setVolume(level) {
const old = this.volume;
this.volume = level;
console.log(` [Speaker] Volume: ${old} -> ${level}`);
return old;
}
}
// ============================================
// CONCRETE COMMANDS
// ============================================
class LightOnCommand extends Command {
constructor(light) {
super();
this.light = light;
this.prevState = null;
}
execute() {
this.prevState = {
isOn: this.light.isOn,
brightness: this.light.brightness
};
this.light.on();
this.light.dim(100);
}
undo() {
if (this.prevState.isOn) {
this.light.on();
this.light.dim(this.prevState.brightness);
} else {
this.light.off();
}
}
describe() { return `Light ON (${this.light.room})`; }
}
class LightOffCommand extends Command {
constructor(light) {
super();
this.light = light;
this.prevBrightness = null;
}
execute() {
this.prevBrightness = this.light.brightness;
this.light.off();
}
undo() {
this.light.on();
this.light.dim(this.prevBrightness);
}
describe() { return `Light OFF (${this.light.room})`; }
}
class SetTemperatureCommand extends Command {
constructor(thermostat, temperature) {
super();
this.thermostat = thermostat;
this.temperature = temperature;
this.previousTemp = null;
}
execute() {
this.previousTemp = this.thermostat.setTemp(this.temperature);
}
undo() {
this.thermostat.setTemp(this.previousTemp);
}
describe() { return `Set temp to ${this.temperature}F`; }
}
// ============================================
// SCENE COMMANDS (Macros)
// ============================================
class MovieNightCommand extends Command {
constructor(livingRoomLight, speaker, thermostat) {
super();
this.commands = [
new LightOffCommand(livingRoomLight),
new SetTemperatureCommand(thermostat, 70),
];
this.speaker = speaker;
this.prevVolume = null;
}
execute() {
console.log('\n[Scene: Movie Night] Activating...');
for (const cmd of this.commands) cmd.execute();
this.prevVolume = this.speaker.setVolume(80);
this.speaker.play('Movie Soundtrack');
}
undo() {
console.log('\n[Scene: Movie Night] Reverting...');
this.speaker.stop();
this.speaker.setVolume(this.prevVolume);
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo();
}
}
describe() { return 'Movie Night Scene'; }
}
// ============================================
// INVOKER: Remote Control
// ============================================
class RemoteControl {
constructor() {
this.slots = new Map(); // buttonName -> command
this.history = [];
}
setCommand(buttonName, command) {
this.slots.set(buttonName, command);
console.log(`[Remote] Button "${buttonName}" -> ${command.describe()}`);
}
pressButton(buttonName) {
const command = this.slots.get(buttonName);
if (!command) {
console.log(`[Remote] No command assigned to "${buttonName}"`);
return;
}
console.log(`\n[Remote] Pressing "${buttonName}"...`);
command.execute();
this.history.push(command);
}
pressUndo() {
if (this.history.length === 0) {
console.log('[Remote] Nothing to undo');
return;
}
const lastCommand = this.history.pop();
console.log(`\n[Remote] Undoing: ${lastCommand.describe()}`);
lastCommand.undo();
}
showButtons() {
console.log('\n=== Remote Control ===');
for (const [name, cmd] of this.slots) {
console.log(` [${name}] -> ${cmd.describe()}`);
}
console.log(` [UNDO] -> Undo last action (${this.history.length} in history)`);
console.log('========================');
}
}
// ============================================
// USAGE
// ============================================
const livingLight = new Light('Living Room');
const bedroomLight = new Light('Bedroom');
const thermostat = new Thermostat();
const speaker = new Speaker();
const remote = new RemoteControl();
remote.setCommand('Living ON', new LightOnCommand(livingLight));
remote.setCommand('Living OFF', new LightOffCommand(livingLight));
remote.setCommand('Bedroom ON', new LightOnCommand(bedroomLight));
remote.setCommand('Temp 68', new SetTemperatureCommand(thermostat, 68));
remote.setCommand('Movie Night', new MovieNightCommand(livingLight, speaker, thermostat));
remote.showButtons();
remote.pressButton('Living ON');
remote.pressButton('Temp 68');
remote.pressButton('Movie Night');
remote.pressUndo(); // Reverts Movie Night scene
Command Pattern Flow Diagram
+--------------------------------------------------------------+
| |
| 1. CLIENT creates command: |
| const cmd = new InsertTextCommand(doc, 0, "Hello"); |
| |
| 2. CLIENT gives command to INVOKER: |
| editor.executeCommand(cmd); |
| |
| 3. INVOKER calls execute(): |
| cmd.execute() |
| --> stores cmd in undoStack |
| |
| 4. COMMAND delegates to RECEIVER: |
| this.document.insertAt(0, "Hello") |
| |
| 5. UNDO: INVOKER pops from undoStack: |
| cmd = undoStack.pop() |
| cmd.undo() |
| --> pushes to redoStack |
| |
| UNDO STACK: REDO STACK: |
| +----------+ +----------+ |
| | cmd 3 | pop-->| cmd 3 | |
| | cmd 2 | | | |
| | cmd 1 | | | |
| +----------+ +----------+ |
| |
+--------------------------------------------------------------+
When to Use Command Pattern
| Scenario | Example |
|---|---|
| Undo/Redo | Text editors, drawing apps, spreadsheets |
| Task Queues | Background job processing, print queues |
| Macro Recording | Record and replay sequences of actions |
| Transaction Logging | Audit trails, event sourcing |
| Remote Execution | Serialize commands, send over network |
| Deferred Execution | Schedule commands for later |
| Callback Replacement | When callbacks need undo capability |
Key Takeaways
- Command encapsulates actions as objects -- they can be stored, queued, logged, and undone
- Undo/redo requires storing inverse operations -- each command must know how to reverse itself
- Macro commands compose multiple commands -- undo reverses them in opposite order
- Task queues use commands as units of work -- with retry, priority, and concurrency control
- Command decouples the invoker from the receiver -- the button doesn't know what happens, only that it has a command
- In JavaScript, closures can serve as lightweight commands --
{ execute: () => light.on(), undo: () => light.off() }
Explain-It Challenge
You're building a collaborative document editor (like Google Docs). Multiple users can edit simultaneously. Design the command system that: (a) supports undo/redo per user, (b) can replay all commands to reconstruct the document from scratch, (c) resolves conflicts when two users edit the same section. What trade-offs would you make?