Lesson 23 / 30
Pattern Misuse
Comparing a version of five behavioral patterns applied with a single implementation against a version without patterns, using the same measures: the costs in file count, meaningful lines, import closure, most complex body, and call depth, against gains that stay at zero; a combined table of the break-even points measured across this topic's nine lessons.
Contents
Across nine lessons, both the gain and the cost of nine patterns were counted, and at the scales where the measurements were taken — strategy at four tariffs, the chain at seven rules, the mediator at four fields — the gain exceeded the cost every time. These numbers show that a threshold exists, but what lies below it has not yet been measured.
This lesson runs the same measures against the pattern’s favor. Five patterns — strategy, observer, template method, state, chain of responsibility — are applied with a single implementation and compared against a pattern-free version that produces the same behavior. The patterned version has one tariff, one observer, one varying step, two states, and one handler. The expected result: the cost items stay the same, the gain items go to zero.
Same Behavior, Two Versions
The pricing goes through these steps: the shipment must be approved, the base fee is computed from the weight, the zone coefficient is applied, the volume discount is subtracted if it applies, the minimum fee is enforced, and the result is written to the log.
mkdir -p plain patterned cat > patterned/states.mjs <<'EOF' // patterned/states.mjs — two-state state machine const state = (name, chargeable) => ({ name, chargeable }); export const STATES = { draft: state("draft", false), approved: state("approved", true) }; EOF cat > patterned/rules.mjs <<'EOF' // patterned/rules.mjs — single-handler chain export const volume = { name: "volume", stops: false, matches: (s) => s.weight >= 20, rate: () => 8 }; export const ORDER = [volume]; EOF cat > patterned/chain.mjs <<'EOF' // patterned/chain.mjs — chain runner export function discount(rules, shipment) { let rate = 0; for (const rule of rules) { if (rule.matches(shipment) === false) continue; rate += rule.rate(shipment); if (rule.stops) break; } return rate; } EOF cat > patterned/observer.mjs <<'EOF' // patterned/observer.mjs — single-observer event publishing export const publisher = () => { const subscribers = []; return { subscribe: (fn) => subscribers.push(fn), notify(event) { for (const fn of subscribers) fn(event); }, }; }; EOF
// depth.mjs — the number of frames in the current call stack that belong to our own files export const depth = () => new Error().stack.split("\n").filter((s) => s.includes(".mjs:")).length;
// data.mjs — shipments to be charged export const SHIPMENTS = [ { code: "GN-1", weight: 4, zone: "1", status: "approved" }, { code: "GN-2", weight: 25, zone: "2", status: "approved" }, { code: "GN-3", weight: 9, zone: "3", status: "draft" }, ];
The pattern-free version is a single file and performs the six steps in sequence.
// plain/charge.mjs — the same behavior in one file, without patterns import { depth } from "../depth.mjs"; const ZONE = { "1": 100, "2": 130, "3": 175 }; export function pricer(log) { return { charge(shipment) { if (shipment.status !== "approved") return { error: "not-approved" }; const d = depth(); let amount = Math.round(((2500 + 420 * Math.ceil(shipment.weight)) * ZONE[shipment.zone]) / 100); if (shipment.weight >= 20) amount -= Math.round((amount * 8) / 100); amount = Math.max(amount, 3900); log.push(`fee ${shipment.code} ${amount}`); return { amount, depth: d }; }, }; }
In the patterned version, each responsibility has its own object. The strategy carries a single implementation and records the call depth while computing the base fee.
// patterned/tariff.mjs — the strategy with a single implementation import { depth } from "../depth.mjs"; export const standard = { name: "standard", minimumFee: 3900, chargeableWeight: (s) => Math.ceil(s.weight), baseFee(w) { this.lastDepth = depth(); return 2500 + 420 * w; }, };
// patterned/template.mjs — template method whose single step varies const ZONE = { "1": 100, "2": 130, "3": 175 }; export function calculate(tariff, shipment, discountRate) { const w = tariff.chargeableWeight(shipment); let amount = Math.round((tariff.baseFee(w) * ZONE[shipment.zone]) / 100); amount -= Math.round((amount * discountRate) / 100); return Math.max(amount, tariff.minimumFee); }
// patterned/main.mjs — composition root: wires the five patterns together import { standard } from "./tariff.mjs"; import { calculate } from "./template.mjs"; import { STATES } from "./states.mjs"; import { ORDER } from "./rules.mjs"; import { discount } from "./chain.mjs"; import { publisher } from "./observer.mjs"; export function pricer(log) { const pub = publisher(); pub.subscribe((event) => log.push(`fee ${event.code} ${event.amount}`)); return { charge(shipment) { const state = STATES[shipment.status]; if (state === undefined || state.chargeable === false) return { error: "not-approved" }; const amount = calculate(standard, shipment, discount(ORDER, shipment)); pub.notify({ code: shipment.code, amount }); return { amount, depth: standard.lastDepth }; }, }; }
Behavior Equality and Call Depth
// run.mjs — runs both versions with the same shipments, compares amount and call depth import { SHIPMENTS } from "./data.mjs"; import { pricer as plainPricer } from "./plain/charge.mjs"; import { pricer as patternedPricer } from "./patterned/main.mjs"; const run = (build) => { const log = []; const p = build(log); const lines = SHIPMENTS.map((s) => { const r = p.charge(s); return r.error === undefined ? `${s.code}=${r.amount}` : `${s.code}=${r.error}`; }); const depth = p.charge(SHIPMENTS[0]).depth; return { lines, log, depth }; }; const a = run(plainPricer); const b = run(patternedPricer); console.log(`plain ${a.lines.join(" ")} log=${a.log.length} call depth=${a.depth}`); console.log(`patterned ${b.lines.join(" ")} log=${b.log.length} call depth=${b.depth}`); console.log(`deviation = ${a.lines.join(" ") === b.lines.join(" ") ? 0 : 1}`);
plain GN-1=4180 GN-2=15548 GN-3=not-approved log=3 call depth=4 patterned GN-1=4180 GN-2=15548 GN-3=not-approved log=3 call depth=6 deviation = 0
The deviation is zero: the two versions produce the same three results and the same number of log lines. Five patterns added nothing to the behavior. Call depth is four against six: reaching the line where the base fee is computed passed through two more calls.
Counting Cost and Gain in the Same Table
// measure.mjs — counts the pattern's cost and its single-implementation gain with the same measures import { readFileSync, readdirSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; import { ORDER } from "./patterned/rules.mjs"; import { STATES } from "./patterned/states.mjs"; const DECISION = /\bif\b|&&|\|\||\?|\bcase\b|\bwhile\b|\bfor\b|\bcontinue\b|\bbreak\b/g; const body = (path) => readFileSync(path, "utf8").replace(/^\/\/.*$/gm, ""); const imports = (path) => [...body(path).matchAll(/from "(\.[^"]+)"/g)].map((m) => normalize(join(dirname(path), m[1]))); function closure(entry) { const seen = new Set([entry]); const stack = [entry]; while (stack.length > 0) { for (const k of imports(stack.pop())) { if (seen.has(k) === false) { seen.add(k); stack.push(k); } } } return seen.size; } const measure = (dir, entry) => { const files = readdirSync(dir).sort(); let lines = 0; let mostComplex = 0; for (const f of files) { const m = body(`${dir}/${f}`); lines += m.split("\n").filter((s) => /[A-Za-z]/.test(s)).length; mostComplex = Math.max(mostComplex, (m.match(DECISION) ?? []).length + 1); } return { files: files.length, lines, mostComplex, closure: closure(entry) }; }; const a = measure("plain", "plain/charge.mjs"); const b = measure("patterned", "patterned/main.mjs"); console.log("measure plain patterned"); console.log(`file count ${String(a.files).padEnd(6)} ${b.files}`); console.log(`meaningful lines ${String(a.lines).padEnd(6)} ${b.lines}`); console.log(`import closure ${String(a.closure).padEnd(6)} ${b.closure}`); console.log(`most complex body ${String(a.mostComplex).padEnd(6)} ${b.mostComplex}`); console.log(`tariffs tried in one run 1 1`); console.log(`registered observer count 1 1`); console.log(`handlers in the chain 1 ${ORDER.length}`); console.log(`states in the state machine 2 ${Object.keys(STATES).length}`);
measure plain patterned file count 1 7 meaningful lines 12 47 import closure 2 8 most complex body 3 6 tariffs tried in one run 1 1 registered observer count 1 1 handlers in the chain 1 1 states in the state machine 2 2
The upper half of the table is cost, the lower half is gain. Cost: seven files against one, forty-seven lines against twelve, an eight-file import closure against two, six against three in the most complex body. The last figure is particularly notable: the patterned version’s most complex body is more complex than the pattern-free version’s single body. The chain runner carries a loop, a skip, and a stop condition to walk a single handler; in the pattern-free version, the same discount is a single condition.
The lower half shows zero gain. The “number of tariffs tried in one run” measured in the strategy lesson was 1 against 3 there; here it is 1 against 1. In the observer lesson, the outgoing link count dropped from 4 to 0; here there is nothing to drop because the link count is already 1, while the silent do-nothing path stays open when no subscription is made. In the state lesson, the representation space dropped from 32 to 6; at two states, it is two against two. In the chain of responsibility lesson, the most complex body dropped from 9 to 6 at seven rules; at a single rule, it rises from 3 to 6.
Threshold Table
The nine lessons’ measurements together, with the scale at which each pattern starts to pay off:
| Pattern | Measured gain | Scale at which it pays off |
|---|---|---|
| Strategy | algorithms tried in one run 1 → 3; files edited for a new tariff 2 → 0 | second algorithm |
| Observer | outgoing links 4 → 0, import closure 5 → 1 | second listener |
| Command | undone operations 0 → 3, audit-trail records 0 → 8 | an undo or logging requirement |
| Template method | duplicated lines 6 → 0, files for a new step 3 → 1 | second implementation |
| State | invalid transitions 3 → 0, complexity 22 → 2, representation space 32 → 6 | third state |
| Chain of responsibility | most complex body 9 → 6 (at seven rules), 6 → 6 (at four rules) | fifth rule |
| Iterator | sites carrying traversal code 5 → 1; files for an order change 5 → 1 | second traversal operation |
| Visitor | operations silently returning the wrong result 3 → 0 | second node type |
| Mediator | links between fields 6 → 3, update calls 8 → 4 | third field |
| Memento | internal fields accessed from outside 8 → 0, fields in the token 4 → 1 | a state-storage requirement |
| Interpreter | source files edited 1 → 0 | when someone other than a developer writes the rule |
| Null object | null checks 2 → 0, call sites throwing an error 1 → 0 | second call site |
The right column is one sentence in different forms: a pattern pays off only if a second instance genuinely exists on the axis it separates. Without a second algorithm, strategy is a file and a level of indirection; without a second listener, observer is a composition root and a silent failure path; without a second implementation, template method is an empty hook. What justifies a pattern is not its name or how well known it is, but whether the number in the left column is nonzero.
This criterion also makes it possible to apply a pattern later. The pattern-free version can be converted to the strategy version the day a second tariff arrives; the measurement in the first lesson showed that this conversion cost two files and thirteen lines. Undoing a pattern applied early, on the other hand, means collapsing seven files into one, and that work is more expensive than the conversion run in reverse. A pattern’s cost is paid up front, its gain arrives later; when the measure is not taken, the cost that was paid is not refunded.
Summary
- When five behavioral patterns were applied with a single implementation, they produced the identical result to the pattern-free version; the deviation came out at 0, meaning the patterns added nothing to the behavior.
- Cost: file count rose from 1 to 7, meaningful lines from 12 to 47, import closure from 2 to 8, and call depth from 4 to 6.
- The patterned version’s most complex body was 6, the pattern-free version’s single body was 3; the single-handler chain raised complexity instead of lowering it.
- The gain items went to zero: tariffs tried in one run 1 against 1, registered observers 1 against 1, state count 2 against 2.
- Each pattern pays off only when a second instance genuinely exists on the axis it separates; the cost is paid up front, the gain arrives later, and undoing a pattern applied early is more expensive than applying it.
Next Step
All the patterns up to this topic worked at the object level: a responsibility was taken, handed to a separate object, and its cost was paid in a level of indirection. The entire threshold table was built at this scale — one algorithm, one listener, one node type. The next topic moves the scale up by one step. The question is no longer which object a responsibility is handed to, but where an application’s business logic sits in its layering, and which patterns establish the boundary between that logic and persistence. The most basic question comes first: should the same business rule live inside a scenario procedure, or on top of the object that carries the data. The next lesson compares these two arrangements by counting how many separate places the rule is applied, and measures the second arrangement’s file, name, and indirection cost in the same way.
To keep your progress and take notes, Log in
My notes
Log in to take notes.