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

ScenarioExample
Undo/RedoText editors, drawing apps, spreadsheets
Task QueuesBackground job processing, print queues
Macro RecordingRecord and replay sequences of actions
Transaction LoggingAudit trails, event sourcing
Remote ExecutionSerialize commands, send over network
Deferred ExecutionSchedule commands for later
Callback ReplacementWhen callbacks need undo capability

Key Takeaways

  1. Command encapsulates actions as objects -- they can be stored, queued, logged, and undone
  2. Undo/redo requires storing inverse operations -- each command must know how to reverse itself
  3. Macro commands compose multiple commands -- undo reverses them in opposite order
  4. Task queues use commands as units of work -- with retry, priority, and concurrency control
  5. Command decouples the invoker from the receiver -- the button doesn't know what happens, only that it has a command
  6. 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?