Lesson 16 / 30
Command
Comparing writing the operations on a shipment as direct calls with turning them into objects: the number of operations that can be undone, the number of audit trail entries, the number of operation bodies edited to produce the same trail in the direct version and the common coupling that results, and the pattern's cost as the file count and the call depth.
Contents
The Observer announced something that had already happened: by the moment the notification was sent, the state had already changed, and there was no going back. The library’s operator side needs the opposite. Applying a discount to a shipment, changing its route, and canceling it are done by operator decision, and a wrongly applied operation needs to be undone. Today, these three operations are direct calls that change the domain, and once the call finishes, the old value is lost.
The Command pattern turns an operation from a call into an object: what to do and with what parameter stays inside an object, can be held before it is invoked, can be queued, can be logged, and its reverse can be requested from the same object. The numbers to measure are the number of operations that can be undone, the number of audit trail entries, and the number of operation bodies edited when a cross-cutting job is added. On the cost side stand the file count and the call depth.
The Domain Object and Two Designs
The domain object is shared by both versions. On every write, it records the number of call frames belonging to our own files; this number is the run-time counterpart of the level of indirection.
mkdir -p direct command
// shipment.mjs — domain object; records call depth on every write export const shipment = (code) => { const state = { code, discount: 0, route: "R1", cancelled: "no" }; const depths = []; return { state, depths, write(field, value) { state[field] = value; depths.push(new Error().stack.split("\n").filter((s) => s.includes(".mjs:")).length); }, summary: () => `${state.code} discount=${state.discount} route=${state.route} cancelled=${state.cancelled}`, }; };
The first design writes the three operations as three functions. No operation’s reverse is
defined anywhere; once applyDiscount is called, nobody knows the old rate anymore.
// direct/operation.mjs — operations write the field directly, there is no reverse export const applyDiscount = (s, rate) => s.write("discount", rate); export const changeRoute = (s, route) => s.write("route", route); export const cancel = (s) => s.write("cancelled", "yes");
In the second design, each operation produces an object. The object stores the value from
before it was applied in its own closure and writes it back through the undo method.
// command/discount.mjs export const discountCommand = (rate) => { let previous; return { name: `discount(${rate})`, apply(s) { previous = s.state.discount; s.write("discount", rate); }, undo(s) { s.write("discount", previous); }, }; };
// command/route.mjs export const routeCommand = (route) => { let previous; return { name: `route(${route})`, apply(s) { previous = s.state.route; s.write("route", route); }, undo(s) { s.write("route", previous); }, }; };
// command/cancel.mjs export const cancelCommand = () => { let previous; return { name: "cancel", apply(s) { previous = s.state.cancelled; s.write("cancelled", "yes"); }, undo(s) { s.write("cancelled", previous); }, }; };
The three objects’ shared contract is the name, apply, and undo triple. This contract is
enough for the object running the command to manage it without knowing what the command does.
The invoker keeps a history stack and writes every call to the audit trail.
// command/invoker.mjs — runs commands, keeps the history stack and the audit trail export const invoker = () => { const history = []; const trail = []; return { trail, run(command, s) { command.apply(s); history.push([command, s]); trail.push(`apply ${command.name}`); }, undo(count) { let n = 0; while (n < count && history.length > 0) { const [command, s] = history.pop(); command.undo(s); trail.push(`undo ${command.name}`); n += 1; } return n; }, }; };
Five Operations, Three Undos
The driver script applies the same five operations to both versions, then tries undoing three of them and compares the final states.
// run.mjs — apply five operations, undo three; compare both versions import { shipment } from "./shipment.mjs"; import { applyDiscount, changeRoute, cancel } from "./direct/operation.mjs"; import { discountCommand } from "./command/discount.mjs"; import { routeCommand } from "./command/route.mjs"; import { cancelCommand } from "./command/cancel.mjs"; import { invoker } from "./command/invoker.mjs"; const a = shipment("GN-7"); applyDiscount(a, 10); changeRoute(a, "R2"); applyDiscount(a, 15); changeRoute(a, "R3"); cancel(a); console.log(`direct after five operations : ${a.summary()}`); console.log(`direct undone operations : 0`); const b = shipment("GN-7"); const inv = invoker(); for (const command of [discountCommand(10), routeCommand("R2"), discountCommand(15), routeCommand("R3"), cancelCommand()]) { inv.run(command, b); } console.log(`command after five operations : ${b.summary()}`); const undone = inv.undo(3); console.log(`command undone operations : ${undone}`); console.log(`command after undo : ${b.summary()}`); console.log(`call depth (direct/command): ${a.depths[0]} / ${b.depths[0]}`); console.log(`audit trail entries (direct/command): 0 / ${inv.trail.length}`);
direct after five operations : GN-7 discount=15 route=R3 cancelled=yes direct undone operations : 0 command after five operations : GN-7 discount=15 route=R3 cancelled=yes command undone operations : 3 command after undo : GN-7 discount=10 route=R2 cancelled=no call depth (direct/command): 3 / 4 audit trail entries (direct/command): 0 / 8
Two lines give the measurement’s validity condition: after five operations, the two versions’
summaries are exactly identical. What comes after is the gain. The number of undone operations
is 0 versus 3; the state after undo has returned exactly to the state produced by the first
two operations. The audit trail carries 0 versus 8 entries — five applications and three
undos. None of these eight entries required touching an operation body; the entry was produced
by the invoker, which reads the command’s name field.
Adding an Audit Trail to the Direct Version
Producing the same eight-entry trail in the direct version requires editing all three operation bodies; the place to hold the entries also has to be a module-level array, because the three functions have no other place to share.
// audit.mjs — adds an audit trail to the direct version and measures the change import { cpSync, readFileSync, writeFileSync } from "node:fs"; cpSync("direct", "direct-new", { recursive: true }); writeFileSync( "direct-new/operation.mjs", `// direct/operation.mjs — operations write the field directly, there is no reverse export const trail = []; export const applyDiscount = (s, rate) => { trail.push(\`apply discount(\${rate})\`); s.write("discount", rate); }; export const changeRoute = (s, route) => { trail.push(\`apply route(\${route})\`); s.write("route", route); }; export const cancel = (s) => { trail.push("apply cancel"); s.write("cancelled", "yes"); }; `, ); console.log(`edited operation bodies = ${(readFileSync("direct-new/operation.mjs", "utf8").match(/trail\.push/g) ?? []).length}`); console.log(`new module-level shared state = ${(readFileSync("direct-new/operation.mjs", "utf8").match(/^export const trail = \[\];$/m) ?? []).length}`); console.log(`edited operation bodies in the command version = 0`);
edited operation bodies = 3 new module-level shared state = 1 edited operation bodies in the command version = 0
Three bodies versus zero bodies. Alongside the count there is also a difference in kind: the
trail array added to the direct version is module-level shared mutable state — the strongest
form measured in the Design Principles course as common coupling. Three functions change
this array together; when a second trail is needed, or when the trail must be reset in a test,
all three are affected together. In the command version, the trail belongs to the invoker
instance, each run carries its own trail, and the count of shared module state stays zero.
Tallying the Cost
echo "file count: direct=$(ls direct | wc -l | tr -d ' ') command=$(ls command | wc -l | tr -d ' ')" echo "files that know the reverse: direct=0 command=$(grep -l '^ undo(s) {$' command/*.mjs | wc -l | tr -d ' ')" echo "file holding the history stack: command=$(grep -l 'history.push' command/*.mjs | wc -l | tr -d ' ')"
file count: direct=1 command=4 files that know the reverse: direct=0 command=3 file holding the history stack: command=1
One file versus four files. The pattern’s cost is one file and one type per operation; its gain, by contrast, is independent of the operation count, because the place holding the history stack is single. When a fourth operation is added, the cost grows by one file, while undo and the audit trail work on their own. The three lines also show a limit: the number of files that know the reverse must equal the number of operations, so if an operation’s reverse cannot be defined, the pattern does not deliver the undo gain.
Summary
- The Command pattern turns an operation into an object; the object carries what to do, its parameter, and its reverse, so it can be held before being applied and undone afterward.
- The same five operations produced exactly the same state in both versions; the number of undone operations came out to 0 versus 3, and the state after undo returned exactly to the state after the first two operations.
- The audit trail produced 8 entries in the command version without editing a single operation body; producing the same trail in the direct version required editing 3 bodies and adding 1 module-level shared state, that is, common coupling.
- Cost: the file count rose from 1 to 4, the call depth rose from 3 to 4; the number of files that know the reverse must equal the number of operations.
- The pattern’s gain is independent of the operation count; its cost grows linearly with it.
Next Step
The three commands do the same three steps in the same order: save the previous value, write the new value, keep the entry. The order is the same in every command; only the middle step changes. The same repetition exists at a larger scale on the library’s fee calculation side: pricing for the domestic, international, and express carriers goes through six steps, and three files sequence these six steps separately. Adding a step to the sequence means editing all three files. The next lesson measures the number of duplicated lines and the number of files edited when the step order changes, then moves the skeleton to a single place and recalculates the same numbers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.