---
title: Simplifying
source: 'https://academia.sh/en/courses/architect-role/simplifying'
course: "The Architect's Role"
language: en
updated: '2026-08-23T07:01:03+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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