Lesson 07 / 11
Simplifying
The deliberate reduction of complexity: separating accidental from essential complexity, splitting a solution's module graph's total complexity into two components against the essential floor read from the requirement graph, counting the accidental share simplification removes and the share it cannot, and measuring what grows in return for simplification by running the same change set on both graphs.
Contents
The previous lesson measured which alternative the decision went to. The selected alternative brings out a set of pieces: a cache layer, a synchronization path, an invalidation rule. Over time these pieces accumulate and the system starts being called “complex.” As long as complexity is used as an adjective, no decision can be made about it, because it does not show the gap between the desire to reduce and the ability to reduce.
This lesson’s question is: how much of a solution’s complexity can be removed, and how much comes from the work itself? Simplification undertaken without this separation fails in one of two ways — it either deletes something needed, or deletes nothing and merely rearranges. Coupling and cohesion measures were established in prerequisite design courses; they are not repeated here. What gets measured is complexity’s split into two components.
Accidental and Essential Complexity
Essential complexity is the share that comes from the work being solved itself and that does not disappear in any implementation. A borrowed book having a return date, a delay turning into a fee, an extension depending on both the loan record and the fee — these come from the library network’s rules. However the code is written, these bonds remain.
Accidental complexity is the share that comes from how the solution is built. A second module covering the same behavior, a wrapper that has stepped in without covering any behavior, a layer built just to read a single setting. These are properties of the solution, not of the work.
The distinction sounds obvious but is open to argument in practice, because everyone considers the layer they added essential. The only thing that closes the argument is tying the distinction to a measure: the essential share is read from the requirement, not from the solution. The lowest floor that any solution covering the same set of behaviors can descend to is the requirements’ own graph.
Two Graphs: Solution and Requirement
The model holds two graphs. The first is the behaviors the library network’s rules impose and the mandatory links between them. The second is the in-house loan services’ present-day module graph. It is a fictional model; no real institution or product is described.
// simplify/graph.mjs — the module graph of loan services and the requirement graph; this is a fictional model // REQUIREMENT: the behaviors the library network's rules impose and the mandatory links between them (AP4) export const REQUIREMENT = { r1: { name: "lend", links: ["r7"] }, r2: { name: "accept-return", links: ["r4"] }, r3: { name: "extend", links: ["r1", "r4"] }, r4: { name: "late-fee", links: [] }, r5: { name: "reserve", links: [] }, r6: { name: "inter-branch-request", links: ["r5"] }, r7: { name: "membership-verification", links: [] }, r8: { name: "penalty-waiver", links: ["r4"] }, r9: { name: "shelf-status-notification", links: [] }, }; // MODULE: the solution's present state. readsConfig = a module that reads a per-branch setting directly (AP5) export const MODULE = [ { name: "loan-flow", needs: ["r1"], readsConfig: false }, { name: "return-flow", needs: ["r2"], readsConfig: false }, { name: "extension-flow", needs: ["r3"], readsConfig: false }, { name: "fee-calculation", needs: ["r4"], readsConfig: false }, { name: "reservation-flow", needs: ["r5"], readsConfig: false }, { name: "inter-branch", needs: ["r6"], readsConfig: false }, { name: "membership-gate", needs: ["r7"], readsConfig: false }, { name: "waiver-flow", needs: ["r8"], readsConfig: false }, { name: "shelf-notification", needs: ["r9"], readsConfig: false }, { name: "loan-helper", needs: ["r1"], readsConfig: false }, { name: "return-helper", needs: ["r2"], readsConfig: false }, { name: "record-bridge", needs: [], readsConfig: false }, { name: "event-bridge", needs: [], readsConfig: false }, { name: "config-wrapper", needs: [], readsConfig: true }, ]; export const LINK = [ ["loan-flow", "loan-helper"], ["loan-flow", "membership-gate"], ["loan-flow", "record-bridge"], ["loan-flow", "config-wrapper"], ["return-flow", "return-helper"], ["return-flow", "fee-calculation"], ["return-flow", "record-bridge"], ["return-flow", "config-wrapper"], ["extension-flow", "loan-helper"], ["extension-flow", "fee-calculation"], ["extension-flow", "config-wrapper"], ["fee-calculation", "record-bridge"], ["fee-calculation", "config-wrapper"], ["reservation-flow", "record-bridge"], ["reservation-flow", "event-bridge"], ["reservation-flow", "config-wrapper"], ["inter-branch", "reservation-flow"], ["inter-branch", "event-bridge"], ["inter-branch", "config-wrapper"], ["waiver-flow", "fee-calculation"], ["waiver-flow", "record-bridge"], ["shelf-notification", "event-bridge"], ["membership-gate", "record-bridge"], ]; export const clone = () => ({ module: MODULE.map((m) => ({ ...m, needs: [...m.needs] })), link: LINK.map((b) => [...b]), }); export const measure = (g) => g.module.length + g.link.length;
The complexity measure was deliberately kept crude: module count plus link count. The measure’s refinement does not matter here, because what is measured is not an absolute magnitude, but the difference between two graphs.
Simplification consists of two steps, and both rest on a single rule: a module can be removed only if it does not distinguish any behavior.
// simplify/simplify.mjs — two simplification steps: merge modules covering the same requirement, // remove a wrapper that covers no requirement (its work passes to its callers) function merge(g, drop, keep) { g.link = g.link.map(([a, b]) => [a === drop ? keep : a, b === drop ? keep : b]) .filter(([a, b]) => a !== b); const k = g.module.find((m) => m.name === keep); k.readsConfig ||= g.module.find((m) => m.name === drop).readsConfig; g.module = g.module.filter((m) => m.name !== drop); } function remove(g, target) { const before = g.link.filter(([, b]) => b === target).map(([a]) => a); const after = g.link.filter(([a]) => a === target).map(([, b]) => b); const reads = g.module.find((m) => m.name === target).readsConfig; g.link = g.link.filter(([a, b]) => a !== target && b !== target); for (const o of before) { if (reads) g.module.find((m) => m.name === o).readsConfig = true; for (const s of after) if (o !== s && !g.link.some(([a, b]) => a === o && b === s)) g.link.push([o, s]); } g.module = g.module.filter((m) => m.name !== target); } export function simplify(g) { merge(g, "loan-helper", "loan-flow"); merge(g, "return-helper", "return-flow"); remove(g, "config-wrapper"); return g; }
One line in the remove function carries half the lesson: if the removed module readsConfig,
that attribute passes to the modules that call it. When the wrapper is removed, the work it did
does not disappear, it disperses.
Measuring the Split
// simplify/split.mjs — splits total complexity into essential and accidental components, then runs simplification import { REQUIREMENT, clone, measure } from "./graph.mjs"; import { simplify } from "./simplify.mjs"; const g = clone(); const total = measure(g); const essentialModule = Object.keys(REQUIREMENT).length; const essentialLink = Object.values(REQUIREMENT).reduce((t, r) => t + r.links.length, 0); const essential = essentialModule + essentialLink; console.log(`module graph : ${g.module.length} modules + ${g.link.length} links = ${total}`); console.log(`requirement graph: ${essentialModule} behaviors + ${essentialLink} mandatory links = ${essential}`); console.log(`essential = ${essential}, accidental = ${total - essential} (${(100 * (total - essential) / total).toFixed(1)}%)`); simplify(g); const simplified = measure(g); console.log(`\nafter simplification: ${g.module.length} modules + ${g.link.length} links = ${simplified}`); console.log(`accidental share removed: ${total - simplified}`); console.log(`accidental share remaining: ${simplified - essential}`); console.log(`floor that cannot be lowered: ${essential} (essential)`); console.log(`modules that read config directly: before 1, after ${g.module.filter((m) => m.readsConfig).length}`);
module graph : 14 modules + 23 links = 37 requirement graph: 9 behaviors + 6 mandatory links = 15 essential = 15, accidental = 22 (59.5%) after simplification: 11 modules + 15 links = 26 accidental share removed: 11 accidental share remaining: 11 floor that cannot be lowered: 15 (essential) modules that read config directly: before 1, after 6
Total complexity is 37; 15 of it is essential, 22 is accidental. That is, more than half of the solution comes not from the requirement, but from how the solution is built. This ratio alone is not a call to action; what shows what the distinction is good for is the lines that follow.
Simplification brought 37 down to 26, meaning it removed 11 of the 22-unit accidental share. The
remaining 11 is also accidental but could not be removed: record-bridge and event-bridge cover
no behavior, yet they do real work — one gathers record writing, the other event publishing, in a
single place. Removing them does not eliminate the work, it copies it into six separate modules.
Being accidental is not the same thing as being removable.
The last line is simplification’s price. Before, a single module read the per-branch setting; after the wrapper is removed, six modules read it. The complexity measure dropped by 11, but the number of places an attribute is scattered across rose sixfold. The measure does not see this; the next run does.
What Simplification Costs In Return
The comparison runs the same set of changes on both graphs. The changes are the ones expected over the next twelve months (AP6) and are of two types: those that change a behavior’s rule, and the spreading change that differentiates a rule per branch.
// simplify/change.mjs — the same set of changes on two graphs: how many modules are touched import { clone } from "./graph.mjs"; import { simplify } from "./simplify.mjs"; // AP6: changes expected over the next twelve months. "spreading" = a rule that differs per branch. const CHANGE = [ { name: "late fee rate", type: "requirement", target: "r4" }, { name: "extension allowance count", type: "requirement", target: "r3" }, { name: "reservation duration", type: "requirement", target: "r5" }, { name: "loan rule", type: "requirement", target: "r1" }, { name: "new step in the return flow", type: "requirement", target: "r2" }, { name: "per-branch late fee", type: "spreading", target: null }, ]; function touched(g, d) { if (d.type === "spreading") return g.module.filter((m) => m.readsConfig).map((m) => m.name); const carrying = g.module.filter((m) => m.needs.includes(d.target)).map((m) => m.name); const calling = g.link.filter(([, b]) => carrying.includes(b)).map(([a]) => a); return [...new Set([...carrying, ...calling])]; } const graphs = { original: clone(), simplified: simplify(clone()) }; console.log("change | original | simplified | diff"); console.log("----------------------------|----------|------------|-----"); const total = { original: 0, simplified: 0 }; for (const d of CHANGE) { const o = touched(graphs.original, d).length, s = touched(graphs.simplified, d).length; total.original += o; total.simplified += s; const diff = s - o; console.log(`${d.name.padEnd(27)} | ${String(o).padStart(8)} | ${String(s).padStart(10)} | ` + `${diff > 0 ? "+" : ""}${diff}`); } console.log("----------------------------|----------|------------|-----"); console.log(`${"total touched modules".padEnd(27)} | ${String(total.original).padStart(8)} | ` + `${String(total.simplified).padStart(10)} | ${total.simplified - total.original}`); const spread = CHANGE.filter((d) => d.type === "spreading"); console.log(`in the ${CHANGE.length - spread.length} known changes the simplified graph touched ` + `${total.original - touched(graphs.original, spread[0]).length - (total.simplified - touched(graphs.simplified, spread[0]).length)} fewer modules`); console.log(`in the 1 spreading change the simplified graph touched ` + `${touched(graphs.simplified, spread[0]).length - touched(graphs.original, spread[0]).length} more modules`);
change | original | simplified | diff ----------------------------|----------|------------|----- late fee rate | 4 | 4 | 0 extension allowance count | 1 | 1 | 0 reservation duration | 2 | 2 | 0 loan rule | 3 | 2 | -1 new step in the return flow | 2 | 1 | -1 per-branch late fee | 1 | 6 | +5 ----------------------------|----------|------------|----- total touched modules | 13 | 16 | 3 in the 5 known changes the simplified graph touched 2 fewer modules in the 1 spreading change the simplified graph touched 5 more modules
Simplification gained 2 modules across the five known changes; for the two behaviors whose second module was removed, the change collapsed into a single place, and nothing changed for the remaining three. In the single spreading change, however, it cost 5 modules. The total number of touched modules rose from 13 to 16.
This does not mean simplification was wrong; it means simplification has a price. The removed wrapper really was an accidental layer — it did not distinguish any behavior — but it held a point of flexibility. When that point was deleted, the flexibility did not vanish, it scattered across six modules. This is what grows in return for simplification: the number of places a change has to pass through.
The decision therefore rests not on a single measure, but on comparing measures. If the spreading change is believed to really be coming, the wrapper is kept despite being accidental; if it is believed not to be coming, it is removed, and the cost of touching 6 modules on the day it does arrive is accepted in advance. Both are defensible; the only thing that is not defensible is adding or deleting a layer without ever computing this number.
Summary
- Essential complexity comes from the work itself and is read from the requirement graph; accidental complexity comes from how the solution is built and is found by subtracting the essential share from the total.
- In the model, total complexity is 37 (14 modules + 23 links), the essential floor is 15 (9 behaviors + 6 mandatory links), and the accidental share is 22 — that is, 59.5% of the total.
- Simplification removed 11 of the accidental share and brought the total down to 26; the remaining 11 is also accidental but cannot be removed, because the two bridge modules do real work without covering any behavior.
- Being accidental is not the same thing as being removable; the essential floor of 15 is the limit no simplification can descend below.
- When the same six changes are run on both graphs, the simplified graph gained 2 modules across the five known changes and cost 5 modules on the single spreading change; the total rose from 13 to 16.
- What grows in return for simplification is the number of places a change has to pass through.
Next Step
Simplification’s price has been computed, but one thing has not: can the wrapper be brought back once it is removed? Gathering the config reading scattered across six modules back into a single point is not a job of the same size as removing the wrapper. Some decisions are like this — reversing them after they are made costs many times more than making them; others can be reversed the next day. When both classes are handled with the same method, two mistakes get made at once: weeks are spent on reversible decisions, and irreversible decisions are made in a single meeting. The next lesson computes the reversal cost on the same decision set, splits the decisions into two classes, and runs two decision methods on both classes to compare waiting time against the cost of a wrong decision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.