Lesson 19 / 30
Chain of Responsibility
Comparing sequencing discount rules in one body against passing a request along a sequence of handlers: the number of lines that change when rule order changes, how the most complex body's cyclomatic complexity and file count grow when three new rules are added, and the pattern's cost — a request that matches no rule cannot be told apart from one that does.
Contents
The state object answered one question from a single place: is this event valid here. On the fee-adjustment side, the question cannot be asked in a single place. A shipment can receive a contracted-customer discount, a volume discount, a campaign discount, and a manually entered adjustment, applied in sequence; some rules do not match the shipment and are skipped, and some stop the chain once applied. Today these rules sit in one body as nested conditions, and their order is embedded in the body’s flow.
Chain of responsibility hands the request to a sequence of handlers. Each handler decides for itself whether the request matches, does its work if it does, then passes the request to the next handler or stops the chain. The numbers to measure are the number of lines that change when rule order changes, the cyclomatic complexity of the most complex body when the rule count grows, and the file count.
Four Rules
The rules, in order: twelve percent for a contracted customer, eight percent for a shipment heavier than twenty kilograms, fifteen percent for a light shipment in the third zone (stops the chain when applied), and an adjustment equal to a manually entered rate (stops the chain when applied).
mkdir -p embedded chain cat > chain/contracted.mjs <<'EOF' // chain/contracted.mjs export const contracted = { name: "contracted", stops: false, matches: (s) => s.contracted === true, rate: () => 12, }; EOF cat > chain/volume.mjs <<'EOF' // chain/volume.mjs export const volume = { name: "volume", stops: false, matches: (s) => s.weight >= 20, rate: () => 8, }; EOF cat > chain/campaign.mjs <<'EOF' // chain/campaign.mjs — stops the chain when applied export const campaign = { name: "campaign", stops: true, matches: (s) => s.zone === "3" && s.weight < 5, rate: () => 15, }; EOF cat > chain/manual.mjs <<'EOF' // chain/manual.mjs — stops the chain when applied export const manual = { name: "manual", stops: true, matches: (s) => typeof s.manualRate === "number", rate: (s) => s.manualRate, }; EOF
// data.mjs — shipments the discount rules will be tried against export const SHIPMENTS = [ { code: "GN-1", weight: 2, zone: "1", contracted: false }, { code: "GN-2", weight: 6, zone: "2", contracted: true }, { code: "GN-3", weight: 22, zone: "3", contracted: false }, { code: "GN-4", weight: 3, zone: "3", contracted: true }, { code: "GN-5", weight: 25, zone: "2", contracted: false, manualRate: 5 }, ];
In the first version, the four rules and their order live in one body. The body also counts how many rules were checked; this count makes it possible to compare the two versions.
// embedded/discount.mjs — four rules and their order live in one body export function discount(shipment) { const applied = []; let rate = 0; let visits = 1; if (shipment.contracted === true) { rate += 12; applied.push("contracted"); } visits += 1; if (shipment.weight >= 20) { rate += 8; applied.push("volume"); } visits += 1; if (shipment.zone === "3" && shipment.weight < 5) { rate += 15; applied.push("campaign"); return { rate, visits, applied }; } visits += 1; if (typeof shipment.manualRate === "number") { rate += shipment.manualRate; applied.push("manual"); return { rate, visits, applied }; } return { rate, visits, applied }; }
In the second version, the order is an array and sits on a single line.
// chain/order.mjs — the chain's order lives on one line import { contracted } from "./contracted.mjs"; import { volume } from "./volume.mjs"; import { campaign } from "./campaign.mjs"; import { manual } from "./manual.mjs"; export const ORDER = [contracted, volume, campaign, manual];
// chain/chain.mjs — the request is passed along until it is stopped or the chain ends export function discount(rules, shipment) { const applied = []; let rate = 0; let visits = 0; for (const rule of rules) { visits += 1; if (rule.matches(shipment) === false) continue; rate += rule.rate(shipment); applied.push(rule.name); if (rule.stops) break; } return { rate, visits, applied }; }
The chain runner does not know what any rule does; it uses only the trio matches, rate, and
stops. Whatever the rule count, this body does not change.
Behavior Equality
// run.mjs — runs both versions against the same shipments and counts the deviation import { SHIPMENTS } from "./data.mjs"; import { discount as embeddedDiscount } from "./embedded/discount.mjs"; import { discount as chainDiscount } from "./chain/chain.mjs"; import { ORDER } from "./chain/order.mjs"; let deviation = 0; for (const s of SHIPMENTS) { const a = embeddedDiscount(s); const b = chainDiscount(ORDER, s); if (a.rate !== b.rate || a.applied.join() !== b.applied.join()) deviation += 1; console.log( `${s.code} embedded rate=${a.rate} visits=${a.visits} [${a.applied.join(" ")}] chain rate=${b.rate} visits=${b.visits} [${b.applied.join(" ")}]`, ); } console.log(`deviation between the two versions = ${deviation}`);
GN-1 embedded rate=0 visits=4 [] chain rate=0 visits=4 [] GN-2 embedded rate=12 visits=4 [contracted] chain rate=12 visits=4 [contracted] GN-3 embedded rate=8 visits=4 [volume] chain rate=8 visits=4 [volume] GN-4 embedded rate=27 visits=3 [contracted campaign] chain rate=27 visits=3 [contracted campaign] GN-5 embedded rate=13 visits=4 [volume manual] chain rate=13 visits=4 [volume manual] deviation between the two versions = 0
The deviation is zero: the rates, the lists of applied rules, and the visit counts are identical. GN-4’s visit count of three shows the stop behavior working; once the campaign rule applied, the manual adjustment was never checked.
When the Order Changes
The manually entered adjustment now needs to take priority over everything else: the rate the operator enters should override the other discounts. In the embedded version, this means moving a block within the body; in the chain version, it means reordering the array.
cp -r embedded embedded-new cp -r chain chain-new cat > embedded-new/discount.mjs <<'EOF' // embedded/discount.mjs — four rules and their order live in one body export function discount(shipment) { const applied = []; let rate = 0; let visits = 1; if (typeof shipment.manualRate === "number") { rate += shipment.manualRate; applied.push("manual"); return { rate, visits, applied }; } visits += 1; if (shipment.contracted === true) { rate += 12; applied.push("contracted"); } visits += 1; if (shipment.weight >= 20) { rate += 8; applied.push("volume"); } visits += 1; if (shipment.zone === "3" && shipment.weight < 5) { rate += 15; applied.push("campaign"); return { rate, visits, applied }; } return { rate, visits, applied }; } EOF sed -i.y 's/\[contracted, volume, campaign, manual\]/[manual, contracted, volume, campaign]/' chain-new/order.mjs rm -f chain-new/*.y for k in embedded chain; do echo "$k: edited file=$(diff -rq $k $k-new | grep -c '^Files') changed line=$(diff -r -u $k $k-new | grep '^[+-][^+-]' | grep -vc '^[+-]//')" done cat > run-new.mjs <<'EOF' import { SHIPMENTS } from "./data.mjs"; import { discount as embeddedDiscount } from "./embedded-new/discount.mjs"; import { discount as chainDiscount } from "./chain-new/chain.mjs"; import { ORDER } from "./chain-new/order.mjs"; let deviation = 0; for (const s of SHIPMENTS) { const a = embeddedDiscount(s); const b = chainDiscount(ORDER, s); if (a.rate !== b.rate || a.applied.join() !== b.applied.join()) deviation += 1; } const s5 = SHIPMENTS[4]; console.log(`GN-5 in the new order: embedded rate=${embeddedDiscount(s5).rate} chain rate=${chainDiscount(ORDER, s5).rate}`); console.log(`deviation between the two versions in the new order = ${deviation}`); EOF node run-new.mjs
embedded: edited file=1 changed line=12 chain: edited file=1 changed line=2 GN-5 in the new order: embedded rate=5 chain rate=5 deviation between the two versions in the new order = 0
Twelve lines against two. GN-5’s rate dropped from 13 to 5: once the manual adjustment moved to the front, the volume discount was never checked. This shows that order is not a matter of style but part of the behavior — which is why where the order is written matters. In the embedded version, the order is the placement of the conditions within the body; reading it means following the whole body. In the chain version, the order is a single-line array.
When the Rule Count Grows
Whether the pattern pays off shows up when the rule count grows. Three more rules are added: a three-percent fuel discount for a shipment heavier than ten kilograms, ten percent for a customer’s first shipment, and four percent for the third zone.
// growth.mjs — how body complexity grows when three new rules are added import { cpSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; const DECISION = /\bif\b|&&|\|\||\?|\bcase\b|\bwhile\b|\bfor\b|\bcontinue\b|\bbreak\b/g; const mostComplex = (dir) => Math.max( ...readdirSync(dir).map((f) => (readFileSync(`${dir}/${f}`, "utf8").replace(/^\/\/.*$/gm, "").match(DECISION) ?? []).length + 1), ); const before = { embedded: mostComplex("embedded"), chain: mostComplex("chain") }; cpSync("embedded", "embedded-three", { recursive: true }); cpSync("chain", "chain-three", { recursive: true }); const ADDED = ` visits += 1; if (shipment.weight >= 10) { rate += 3; applied.push("fuel"); } visits += 1; if (shipment.isFirst === true) { rate += 10; applied.push("firstShipment"); } visits += 1; if (shipment.zone === "3") { rate += 4; applied.push("remoteZone"); } visits += 1;`; writeFileSync( "embedded-three/discount.mjs", readFileSync("embedded/discount.mjs", "utf8").replace(' visits += 1;\n if (shipment.zone === "3" && shipment.weight < 5) {', `${ADDED}\n if (shipment.zone === "3" && shipment.weight < 5) {`), ); const NEW = [ ["fuel", "(s) => s.weight >= 10", "() => 3"], ["first-shipment", "(s) => s.isFirst === true", "() => 10"], ["remote-zone", '(s) => s.zone === "3"', "() => 4"], ]; for (const [name, condition, rate] of NEW) { const variable = name.replace(/-(\w)/g, (_, c) => c.toUpperCase()); writeFileSync( `chain-three/${name}.mjs`, `// chain/${name}.mjs\nexport const ${variable} = {\n name: "${variable}",\n stops: false,\n matches: ${condition},\n rate: ${rate},\n};\n`, ); } writeFileSync( "chain-three/order.mjs", `${readFileSync("chain/order.mjs", "utf8") .replace('import { manual } from "./manual.mjs";', 'import { manual } from "./manual.mjs";\nimport { fuel } from "./fuel.mjs";\nimport { firstShipment } from "./first-shipment.mjs";\nimport { remoteZone } from "./remote-zone.mjs";') .replace("[contracted, volume, campaign, manual]", "[contracted, volume, fuel, firstShipment, remoteZone, campaign, manual]")}`, ); const after = { embedded: mostComplex("embedded-three"), chain: mostComplex("chain-three") }; console.log(`most complex body embedded: ${before.embedded} -> ${after.embedded} chain: ${before.chain} -> ${after.chain}`); console.log(`file count embedded: ${readdirSync("embedded").length} -> ${readdirSync("embedded-three").length} chain: ${readdirSync("chain").length} -> ${readdirSync("chain-three").length}`); const { SHIPMENTS } = await import("./data.mjs"); const e = await import("./embedded-three/discount.mjs"); const c = await import("./chain-three/chain.mjs"); const o = await import("./chain-three/order.mjs"); let deviation = 0; let droppedRequest = 0; for (const shipment of SHIPMENTS) { const a = e.discount(shipment); const b = c.discount(o.ORDER, shipment); if (a.rate !== b.rate) deviation += 1; if (b.applied.length === 0) droppedRequest += 1; } console.log(`deviation with seven rules=${deviation} shipments matching no rule=${droppedRequest}`);
most complex body embedded: 6 -> 9 chain: 6 -> 6 file count embedded: 1 -> 1 chain: 6 -> 9 deviation with seven rules=0 shipments matching no rule=1
At four rules, the pattern gave no complexity gain: six against six. The chain runner itself is as complex as the body that sequences the four rules. At seven rules, the paths diverge. The embedded body climbs from 6 to 9, while the chain runner stays at 6 because every new rule is written into its own file rather than into the body. The cost shows up in the same line: the file count climbs from 6 to 9, while the embedded version stays at 1.
The pattern’s break-even point is where these two curves cross, and this measurement places it around four rules: below four, the chain only adds files.
The Second Cost Item
The last figure in the last line is a separate cost: one shipment matched no rule. When the chain ends, the rate is zero, but “no discount” and “no rule handled the request” are represented by the same result. The embedded body carries the same ambiguity, but in the chain it is easier to miss: if a handler is accidentally dropped from the list, the result still looks valid — only the rate drops. Placing a handler at the end of the chain that never fails to apply is the known way to close this ambiguity; that is the null object pattern, measured in this topic’s ninth lesson.
Summary
- Chain of responsibility hands the request to a sequence of handlers; each handler decides for itself whether it matches, does its work, and either passes the request on or stops the chain.
- With four rules, the two versions produced the identical rate, the identical list of applied rules, and the identical visit count; the deviation came out at 0.
- When rule order changed, the embedded version changed 12 lines and the chain version changed 2; order is part of the behavior — once the manual adjustment moved to the front, GN-5’s rate dropped from 13 to 5.
- At four rules the most complex body was 6 in both versions; at seven rules the embedded version climbed to 9 while the chain stayed at 6, and in exchange the file count rose from 6 to 9.
- Cost: one file per rule, and a request that no handler covers is represented by the same result as a request with no discount.
Next Step
The chain walks a request through an array in sequence: the traversal is flat, one-directional, and the order lives in the array itself. On the library’s route side, the structure to traverse is not flat. A route is a tree of handoff points: hub, regional depot, distribution branch, delivery point. Five independent operations run over this tree — total distance, longest wait, capacity check, label list, total cost — and each operation traverses the tree with its own traversal code. The next lesson counts how many times the traversal code is repeated, then separates traversal from the operation and measures, in both directions, how many files are touched when a new operation and a new node type are added.
To keep your progress and take notes, Log in
My notes
Log in to take notes.