---
title: 'Chain of Responsibility'
source: 'https://academia.sh/en/courses/design-patterns/chain-of-responsibility'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:13+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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).

```sh
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
```

```js
// 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.

```js
// 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.

```js
// 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];
```

```js
// 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

```js
// 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.

```sh
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.

```js
// 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.
