GUIDE · Behavioral

Command

Encapsulate a request as an object, letting you parameterize clients with different requests, queue or log requests, and support undoable operations.

lesson2 sectionsruns in browserBehavioral15 asserts107 lines

At a glance

INTENT
Encapsulate a request as an object, letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
PROBLEM
You need undo/redo, a job queue, macro recording or an audit log — all of which need "the thing to do" to be a VALUE you can store, replay, and reverse.
SAY
Participants: Command (execute()/undo()), ConcreteCommand (binds Receiver + args), Receiver (does the actual work), Invoker (triggers commands, keeps history), Client (creates commands). Pitch: "turn a method call into an object so you can store, queue, undo and replay it."
SEEN IN
Redux / useReducer actions ({ type, payload } dispatched to a reducer); job queues (BullMQ jobs, SQS messages) and event sourcing; editor undo stacks (ProseMirror transactions, Monaco edit ops); DB migrations with up() / down(); CLI frameworks (commander/oclif command objects); callbacks handed to setTimeout/queueMicrotask — a function IS a command object in JS.
VS
Strategy = HOW to do one thing (interchangeable algorithm, no history); Command = WHAT to do (a request + its args, often undoable/queueable). Memento stores STATE to restore; Command stores the OPERATION and knows how to reverse it.

The idea#

Picture a restaurant ticket. The waiter does not shout "burger, no onion" and hope the kitchen heard. They write it on a slip. The slip is a thing. You can stack slips, hand the stack to the kitchen, void the top slip when somebody changes their mind, and send a voided slip back out if they change it again. The kitchen cooks. The pad remembers what was sent, and in what order.

A command is that slip. You encapsulate a request as an object. Then you can pass different requests around, queue them, log them, and undo them. The command interface is execute and undo. A concrete command binds a receiver and the arguments: which buffer, where, and what text. The receiver does the actual work. The invoker triggers commands and keeps history. It does not touch the text itself. The client is whoever writes the slip.

Undo, redo, a job queue, a macro, and an audit log all need "the thing to do" as a value you can store, replay, and reverse.

Why it matters#

The bug this pattern prevents is history you cannot rewind. A text box that inserts by calling the buffer directly has nothing to undo unless it also keeps a secret copy of every old string. The slip already knows how to reverse itself. An insert removes the same span. A delete remembers the characters it took, because "delete 6" is not enough to put those characters back.

You already pass slips around. A Redux action, { type, payload }, is a command. The reducer is the receiver. dispatch is the invoker. A BullMQ job or an SQS message is a slip sitting in a queue. Event sourcing stores the slips and rebuilds state by replaying them. Editor undo stacks, ProseMirror transactions, and Monaco edit ops are this pattern with a cursor. A database migration has up and down. CLI frameworks turn each verb into a command object. A function you hand to setTimeout or queueMicrotask is a command too. In JavaScript the slip can be a function.

A strategy is how to do one job. You swap the algorithm, and you do not keep a history of the swaps. A command is what to do: the request plus its arguments, often undoable and queueable. A memento stores state so you can restore a snapshot. A command stores the operation and knows how to reverse that operation.

A small example#

JS
function makePad() {  const done = [];  return {    write(ticket) {      ticket.do();      done.push(ticket);    },    voidLast() {      const ticket = done.pop();      if (ticket) ticket.undo();      return ticket;    },  };} const plate = { text: '' };const pad = makePad();pad.write({  do() { plate.text = 'burger'; },  undo() { plate.text = ''; },});pad.voidLast();

After write, the plate says burger and the pad holds one ticket. After voidLast, the plate is empty. The kitchen never stored the history. The ticket did.

What the code below is doing#

The sheet shows the same idea twice.

The first shape uses classes so the book names are visible. TextBuffer is the receiver. insert splices a string in at a position. remove cuts a span and returns the cut text. InsertCommand binds the buffer, the position, and the string. execute inserts. undo removes that many characters at that position. DeleteCommand also stores removed. execute fills it from the buffer. undo inserts those exact characters back. Editor is the invoker. It has an undo stack and a redo stack. run executes, pushes the command, and clears redo. undo pops, undoes, and pushes onto redo. redo does the reverse. Both return false when their stack is empty.

The buffer starts empty. Insert Hello World at 0. Delete 6 characters at position 5, which is the space and World. Insert , Ada at position 5. The text is Hello, Ada. One undo drops the insert and leaves Hello. The next undo reverses the delete using the remembered World, so the text is Hello World again. Redo runs that delete once more and leaves Hello. Depth is 2 undoable and 1 redoable. A new insert of ! at position 5 clears the redo stack, depth becomes 3 and 0, and redo returns false. Draining undo walks the buffer back to an empty string.

The second shape is a plain { do, undo } object plus a history, and then serializable actions. makeHistory is the pad: exec, undo, and redo. A cart is a Map. addItem returns a ticket that adds a quantity and, on undo, subtracts it, deleting the key when the count hits zero. Two of A, one of B, and one more A leave A at 3 and B at 1. Two undos leave only A at 2. One redo puts B back.

The Redux picture treats the action as the command, the reducer as the receiver, and dispatch as the invoker. Because the actions are plain data, a log of deposit 500, withdraw 120, and deposit 20 replays to 400 cents. A JSON round trip of that log still replays to 400, so the slips can sit in a queue or a database. Replaying only the first two leaves 380.

When you talk about it, say this: turn a method call into an object so you can store it, queue it, undo it, and replay it.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/command.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
class TextBuffer {                                        // Receiver  constructor(text = '') { this.text = text; }  insert(pos, str) { this.text = this.text.slice(0, pos) + str + this.text.slice(pos); }  remove(pos, len) {    const removed = this.text.slice(pos, pos + len);    this.text = this.text.slice(0, pos) + this.text.slice(pos + len);    return removed;  }}class InsertCommand {                                     // ConcreteCommand  constructor(buffer, pos, str) { Object.assign(this, { buffer, pos, str }); }  execute() { this.buffer.insert(this.pos, this.str); }  undo() { this.buffer.remove(this.pos, this.str.length); }}class DeleteCommand {                                     // ConcreteCommand — remembers what it removed  constructor(buffer, pos, len) { Object.assign(this, { buffer, pos, len, removed: '' }); }  execute() { this.removed = this.buffer.remove(this.pos, this.len); }  undo() { this.buffer.insert(this.pos, this.removed); }}class Editor {                                            // Invoker — owns history, never touches text itself  #undo = []; #redo = [];  run(cmd) { cmd.execute(); this.#undo.push(cmd); this.#redo.length = 0; }  undo() { const c = this.#undo.pop(); if (c) { c.undo(); this.#redo.push(c); } return !!c; }  redo() { const c = this.#redo.pop(); if (c) { c.execute(); this.#undo.push(c); } return !!c; }  get depth() { return { undo: this.#undo.length, redo: this.#redo.length }; }} const buf = new TextBuffer();const editor = new Editor();editor.run(new InsertCommand(buf, 0, 'Hello World'));editor.run(new DeleteCommand(buf, 5, 6));                 // remove ' World'editor.run(new InsertCommand(buf, 5, ', Ada')); editor.undo();editor.redo();editor.run(new InsertCommand(buf, 5, '!'));               // a new command clears the redo stackwhile (editor.undo());                                    // unwind everything
Verified by 9 asserts
JS
assert.equal(buf.text, 'Hello, Ada');assert.equal(editor.undo(), true);assert.equal(buf.text, 'Hello');assert.equal(buf.text, 'Hello World');                    // delete reversed using the remembered textassert.equal(buf.text, 'Hello');assert.deepEqual(editor.depth, { undo: 2, redo: 1 });assert.deepEqual(editor.depth, { undo: 3, redo: 0 });assert.equal(editor.redo(), false);assert.equal(buf.text, '');
02

Idiomatic JS shape: plain { do, undo } objects + a history; and Redux-style serializable actions#

JS
const makeHistory = () => {  const done = []; const undone = [];  return {    exec(cmd) { cmd.do(); done.push(cmd); undone.length = 0; },    undo() { const c = done.pop(); if (c) { c.undo(); undone.push(c); } },    redo() { const c = undone.pop(); if (c) { c.do(); done.push(c); } },  };};const cart = new Map();const addItem = (sku, qty) => ({  do: () => cart.set(sku, (cart.get(sku) ?? 0) + qty),  undo: () => { const n = cart.get(sku) - qty; if (n) cart.set(sku, n); else cart.delete(sku); },});const h = makeHistory();h.exec(addItem('A', 2)); h.exec(addItem('B', 1)); h.exec(addItem('A', 1));h.undo(); h.undo();h.redo(); // Redux: the action IS the command; the reducer is the receiver; dispatch is the invoker.// Serializable commands = loggable, replayable, time-travel-debuggable, shippable over a queue.const reducer = (state, action) => {  switch (action.type) {    case 'deposit': return { ...state, cents: state.cents + action.cents };    case 'withdraw': return { ...state, cents: state.cents - action.cents };    default: return state;  }};const log = [{ type: 'deposit', cents: 500 }, { type: 'withdraw', cents: 120 }, { type: 'deposit', cents: 20 }];
Verified by 6 asserts
JS
assert.deepEqual([...cart], [['A', 3], ['B', 1]]);assert.deepEqual([...cart], [['A', 2]]);assert.deepEqual([...cart], [['A', 2], ['B', 1]]);assert.equal(log.reduce(reducer, { cents: 0 }).cents, 400);                                // replay the logassert.equal(JSON.parse(JSON.stringify(log)).reduce(reducer, { cents: 0 }).cents, 400);    // survives a queue/DBassert.equal(log.slice(0, 2).reduce(reducer, { cents: 0 }).cents, 380);                    // replay to any point

See also