Lesson 09 / 10
Evolutionary Architecture
Checking a change-resistant structure: the same three changes touching 18 modules in one structure versus 5 in the isolation-pointed structure, isolation permanently costing 5 nodes and 4 edges, the fitness function catching 7 of 8 violations while giving 1 false alarm, and the same rule set flagging 5 correct changes as violations once a dependency is brought in-house.
Contents
The previous lesson turned dependency and layer rules into an executable check: a rule is no longer a sentence in a document but a predicate that runs over the import graph. But all of those rules protect today’s structure: they freeze today’s layer separation, and when the structure itself has to change, they either keep the right change cheap or flag it as a violation.
Evolutionary architecture is the effort to make the structural decisions that keep change cheap themselves auditable. The course’s question shifts by one step: can the structural decision that makes change cheap be checked? Four measures are taken: modules touched and reversal steps; the fitness function’s catches, misses, and false alarms; the rule’s own aging; and the untranslatable remainder.
The Same Change Set, Two Structures
The example is again the course’s fictional regional library network. GV12: six internal modules
(branch front, branch terminal, loan, fee, reporting, notification) and three external dependencies
(an outside catalog, a separate membership system, the municipality’s identity service); today’s
structure A links them with 16 edges. GV13: an edge is a shape coupling if it depends on internal
shape, or a contract coupling if it depends on a declared contract. GV14 — leaking module: in A,
loan and fee re-emit the shape they receive unchanged; in B, an isolation point (gate) that maps
shape stands in front of every external dependency, and no module leaks. GV15 — change set: adding
a field to the catalog record, changing the membership system, splitting fee assessment in two.
The two files build a graph, its propagation, and five rules as an in-process model; there is no network or store.
// evolution/structure.mjs — the fictional library network's module graph and rule checker (model). // Edge notation is "dependent>dependency:type"; "b" is shape coupling, "s" is contract coupling. export const parseEdges = (s) => s.trim().split(/\s+/).map((e) => e.split(/[>:]/)); export const STRUCTURE_A = parseEdges(` branch-front>catalog:b branch-front>loan:b branch-front>identity:b branch-terminal>catalog:b branch-terminal>loan:b branch-terminal>identity:b loan>catalog:b loan>membership:b loan>fee:b fee>loan:b fee>membership:b report>loan:b report>fee:b report>catalog:b notification>loan:b notification>membership:b`); export const STRUCTURE_B = parseEdges(` branch-front>catalog-gate:s branch-front>loan-gate:s branch-front>identity-gate:s branch-terminal>catalog-gate:s branch-terminal>loan-gate:s branch-terminal>identity-gate:s loan>catalog-gate:s loan>membership-gate:s loan>fee-gate:s fee>membership-gate:s report>loan-gate:s report>fee:b report>catalog-gate:s notification>loan-gate:s notification>membership-gate:s catalog-gate>catalog:b membership-gate>membership:b identity-gate>identity:b loan-gate>loan:b fee-gate>fee:b`); // Modules that re-emit the shape they receive, unchanged (rough marker) export const LEAKING = { A: new Set(["loan", "fee"]), B: new Set() }; export const EDGE_LAYER = ["branch-front", "branch-terminal"]; // "external" and "gate" are not the structure's to declare — the configuration declares them. export const OLD = { external: ["catalog", "membership", "identity"], gate: { "catalog-gate": "catalog", "membership-gate": "membership", "identity-gate": "identity", "loan-gate": "loan", "fee-gate": "fee" }, edgeAllow: ["catalog-gate", "loan-gate", "identity-gate"], exceptions: [["report", "fee"]], }; const { "membership-gate": _, ...NEW_GATES } = OLD.gate; // after membership is brought in-house export const NEW = { ...OLD, external: ["catalog", "identity"], gate: NEW_GATES, edgeAllow: [...OLD.edgeAllow, "membership"] }; export const modules = (k) => [...new Set(k.flatMap(([a, b]) => [a, b]))]; export const apply = (k, o) => { const s = (o.remove ?? []).map((x) => `${x[0]}>${x[1]}`); return [...k.filter(([a, b]) => !s.includes(`${a}>${b}`)), ...(o.add ?? [])]; }; // A shape edge carries the shape that changed; a contract edge only crosses when the contract changes. export function touched(edges, leaking, external, change) { const set = new Set([change.root]); for (let added = true; added; ) { added = false; for (const [a, b, type] of edges) { if (set.has(a) || !set.has(b)) continue; if (type === "b" ? b === change.root || leaking.has(b) : change.contractChanged.includes(b)) { set.add(a); added = true; } } } return [...set].filter((m) => !external.includes(m)); } // A change that stays behind a single gate is reversed with one key. export const reversalSteps = (touched, gate) => (touched.length === 1 && touched[0] in gate ? 1 : touched.length); export function cycleMembers(edges) { const mods = modules(edges), reach = new Map(mods.map((x) => [x, new Set()])); for (const [a, b] of edges) reach.get(a).add(b); for (let changed = true; changed; ) { // transitive closure changed = false; for (const a of mods) for (const b of [...reach.get(a)]) for (const c of reach.get(b) ?? []) if (!reach.get(a).has(c)) { reach.get(a).add(c); changed = true; } } return mods.filter((x) => reach.get(x).has(x)); } // Fitness function: openness to change export function fitness(edges, config) { const gateNames = Object.keys(config.gate); const fullyIsolated = config.external.filter((x) => { const sources = edges.filter(([, b]) => b === x).map(([a]) => a); return sources.length === 1 && gateNames.includes(sources[0]); }); return { node: modules(edges).length, edge: edges.length, "cyclic module": cycleMembers(edges).length, "unisolated external edge": edges.filter(([a, b]) => config.external.includes(b) && !gateNames.includes(a)).length, "fully isolated external": `${fullyIsolated.length}/${config.external.length}` }; } // Five architectural rules; each returns a violation count export function rules(config) { const gateNames = Object.keys(config.gate); const internalTargets = Object.values(config.gate).filter((m) => !config.external.includes(m)); return { K1: (k) => k.filter(([a, b]) => config.external.includes(b) && !gateNames.includes(a)).length, K2: (k) => cycleMembers(k).length, K3: (k) => k.filter(([a, b, t]) => t === "b" && internalTargets.includes(b) && !gateNames.includes(a) && !config.exceptions.some(([x, z]) => x === a && z === b)).length, K4: (k) => k.filter(([a, b]) => EDGE_LAYER.includes(a) && !config.edgeAllow.includes(b)).length, K5: (k) => config.external.filter((x) => k.filter(([, b]) => b === x).length !== 1 || !gateNames.includes((k.find(([, b]) => b === x) ?? [])[0])).length, }; }
// evolution/measure.mjs — the same change set on two structures, the known violation set, the rules aging. import { STRUCTURE_A, STRUCTURE_B, LEAKING, OLD, NEW, parseEdges, apply, touched, reversalSteps, fitness, rules } from "./structure.mjs"; const printRow = (w, ...s) => console.log(s.map((v, i) => (w[i] < 0 ? String(v).padEnd(-w[i]) : String(v).padStart(w[i]))).join("")); const fitA = fitness(STRUCTURE_A, OLD), fitB = fitness(STRUCTURE_B, OLD); printRow([-24, 8, 8], "fitness function", "A", "B"); for (const k of Object.keys(fitA)) printRow([-24, 8, 8], k, fitA[k], fitB[k]); const CHANGES = [ { name: "new field (catalog)", root: "catalog", contractChanged: [] }, { name: "change dependency (membership)", root: "membership", contractChanged: [] }, { name: "split the fee in two", root: "fee", contractChanged: ["fee"] }, ]; const CHANGE_W = [-30, 14, 14, 14, 14], TOTAL = { A: [0, 0], B: [0, 0] }; console.log(); printRow(CHANGE_W, "change", "A: touched", "A: reversal", "B: touched", "B: reversal"); for (const change of CHANGES) { const res = {}; for (const [id, structure, leaking] of [["A", STRUCTURE_A, LEAKING.A], ["B", STRUCTURE_B, LEAKING.B]]) { const t = touched(structure, leaking, OLD.external, change), r = reversalSteps(t, OLD.gate); res[id] = [t.length, r]; TOTAL[id][0] += t.length; TOTAL[id][1] += r; } printRow(CHANGE_W, change.name, res.A[0], res.A[1], res.B[0], res.B[1]); } printRow(CHANGE_W, "total", TOTAL.A[0], TOTAL.A[1], TOTAL.B[0], TOTAL.B[1]); // Known violation set: each proposal is hand-labeled (model input). const PROPOSALS = [ ["fast search", "branch-front>catalog:b", "violation"], ["nightly transfer", "report>catalog:b", "violation"], ["balance lookup", "fee>loan:b", "violation"], ["fee in notification", "notification>fee:b", "violation"], ["gate to catalog", "loan-gate>catalog:b", "violation"], ["new kiosk", "branch-kiosk>loan:b", "violation"], ["notification identity", "notification>identity-gate:s", "clean"], ["report membership", "report>membership-gate:s", "clean"], ["archive module", "archive>loan-gate:s archive>catalog-gate:s", "clean"], ["gate chain", "catalog-gate>membership-gate:s", "violation"], ["terminal membership", "branch-terminal>membership-gate:s", "clean"], ["notification membership", "notification>membership:b", "violation"], ].map(([name, e, actual]) => ({ name, add: parseEdges(e), actual })); function run(base, proposals, config, disabled = []) { const K = rules(config), names = Object.keys(K).filter((x) => !disabled.includes(x)); const baseline = Object.fromEntries(names.map((x) => [x, K[x](base)])); const rows = proposals.map((p) => { const fired = names.filter((x) => K[x](apply(base, p)) > baseline[x]); const outcome = p.actual === "violation" ? (fired.length ? "caught" : "missed") : fired.length ? "false alarm" : "clean pass"; return { name: p.name, actual: p.actual, fired, outcome }; }); const count = (s) => rows.filter((x) => x.outcome === s).length; return { rows, baseline, caught: count("caught"), missed: count("missed"), falseAlarm: count("false alarm"), cleanPass: count("clean pass") }; } const R = run(STRUCTURE_B, PROPOSALS, OLD), violations = PROPOSALS.filter((p) => p.actual === "violation").length; console.log(`\nB baseline: ${Object.entries(R.baseline).map(([a, b]) => `${a}=${b}`).join(" ")}`); const PROPOSAL_W = [-25, 11, 14, 14]; printRow(PROPOSAL_W, "proposal", "actual", "fired", "outcome"); for (const row of R.rows) printRow(PROPOSAL_W, row.name, row.actual, row.fired.join(",") || "-", row.outcome); console.log(`caught ${R.caught}/${violations}, missed ${R.missed}, ` + `false alarm ${R.falseAlarm}/${PROPOSALS.length - violations} clean`); // The rules aging: the structure is unchanged, the configuration changed. const AFTER = [ ["loan direct", "loan>membership:b", "", "clean"], ["remove the gate", "loan>membership:s fee>membership:s notification>membership:s", "membership-gate>membership loan>membership-gate fee>membership-gate notification>membership-gate", "clean"], ["notification direct", "notification>membership:b", "", "clean"], ["front membership", "branch-front>membership:s", "", "clean"], ["membership to identity", "membership>identity:b", "", "violation"], ["report membership", "report>membership:s", "", "clean"], ].map(([name, e, s, actual]) => ({ name, add: parseEdges(e), remove: s ? parseEdges(s) : [], actual })); const DISABLED = ["K1", "K5"]; // the two rules that read the external list console.log("\nafter membership is brought in-house, the same six proposals (5 clean, 1 violation)"); const AGING_W = [-22, 11, 8, 14, 13]; printRow(AGING_W, "configuration", "caught", "missed", "false alarm", "clean pass"); for (const [name, config, closed] of [["old (unchanged)", OLD, []], ["updated", NEW, []], [`old, ${DISABLED.join("+")} disabled`, OLD, DISABLED]]) { const r = run(STRUCTURE_B, AFTER, config, closed); printRow(AGING_W, name, r.caught, r.missed, r.falseAlarm, r.cleanPass); } const oldRules = rules(OLD), newRules = rules(NEW); const changed = Object.keys(OLD).filter((a) => JSON.stringify(OLD[a]) !== JSON.stringify(NEW[a])); const differ = Object.keys(oldRules).filter((x) => AFTER.some((p) => oldRules[x](apply(STRUCTURE_B, p)) !== newRules[x](apply(STRUCTURE_B, p)))); console.log(`update: ${changed.length} of ${Object.keys(OLD).length} configuration entries change ` + `(${changed.join(", ")}); ${differ.length} of ${Object.keys(oldRules).length} rules ` + `(${differ.join(", ")}) give a different result on the same proposal`); const relaxed = run(STRUCTURE_B, PROPOSALS, OLD, DISABLED); console.log(`disabling ${DISABLED.join("+")}, on the first violation set: caught ${R.caught} -> ` + `${relaxed.caught}, missed ${R.missed} -> ${relaxed.missed}, false alarm ${R.falseAlarm} -> ${relaxed.falseAlarm}`); // Untranslatable part: the cheapness of a change that has not yet been written down. const [SCENARIOS, REVIEWS, INCOMING, COVERED] = [6, 4, 12, 5]; // model input console.log(`\nchange scenarios ${SCENARIOS}, ${REVIEWS} reviews a year -> ` + `${SCENARIOS * REVIEWS} manual-review items`); console.log(`${COVERED} of the ${INCOMING} changes that arrived in a year matched a written scenario ` + `(${((100 * COVERED) / INCOMING).toFixed(2)}%); the remaining ${INCOMING - COVERED} were not counted until they arrived`);
fitness function A B node 9 14 edge 16 20 cyclic module 2 0 unisolated external edge 9 0 fully isolated external 0/3 3/3 change A: touched A: reversal B: touched B: reversal new field (catalog) 6 6 1 1 change dependency (membership) 6 6 1 1 split the fee in two 6 6 3 3 total 18 18 5 5 B baseline: K1=0 K2=0 K3=0 K4=0 K5=0 proposal actual fired outcome fast search violation K1,K4,K5 caught nightly transfer violation K1,K5 caught balance lookup violation K2,K3 caught fee in notification violation K3 caught gate to catalog violation K5 caught new kiosk violation K3 caught notification identity clean - clean pass report membership clean - clean pass archive module clean - clean pass gate chain violation - missed terminal membership clean K4 false alarm notification membership violation K1,K5 caught caught 7/8, missed 1, false alarm 1/4 clean after membership is brought in-house, the same six proposals (5 clean, 1 violation) configuration caught missed false alarm clean pass old (unchanged) 1 0 5 0 updated 1 0 0 5 old, K1+K5 disabled 0 1 1 4 update: 3 of 4 configuration entries change (external, gate, edgeAllow); 3 of 5 rules (K1, K4, K5) give a different result on the same proposal disabling K1+K5, on the first violation set: caught 7 -> 4, missed 1 -> 4, false alarm 1 -> 1 change scenarios 6, 4 reviews a year -> 24 manual-review items 5 of the 12 changes that arrived in a year matched a written scenario (41.67%); the remaining 7 were not counted until they arrived
The numbers belong to the measurement class; their inputs are the assumptions above. In A, all three
changes touch all six of the six internal modules: because the shape coupling leaks through loan and
fee, adding a field to the catalog record propagates all the way to notification. In B, the same
three changes touch 1, 1, and 3 modules — 18 against 5. The reversal column gives a second distinction:
in A, every touched module is reverted one by one; in B, the first two changes stay behind a single
gate, so each is reverted with one key turn. This is a structural reversibility, not the migration
round of the strangler fig from the Service Architectures course. The cost of isolation also shows in
the table: B carries 14 and 20 against A’s 9 nodes and 16 edges. The third change touching three
modules in B shows that the isolation is partial: report is linked to fee assessment by a shape
coupling, and that edge sits on the exception list.
Fitness Function and the Known Violation Set
A fitness function measures openness to change here and counts three quantities: modules in a
cycle, unisolated external edges, fully isolated external dependencies. In A, loan and fee depend
on each other, nine edges go straight to an external dependency, and no external dependency has a
single gate; in B, the figures are 0, 0, and 3/3.
Five architectural rules are the thresholds on these quantities: K1 forbids an internal module connecting directly to an external dependency, K2 forbids a cycle, K3 forbids a shape coupling to a gated internal module, K4 forbids the edge layer stepping outside the allow list, K5 forbids an external dependency lacking a single gate; all five return zero on the B baseline. GV16 — known violation set: twelve proposals are hand-labeled, eight of which raise the cost of change.
The check catches seven of the eight violations, misses one, and gives a false alarm on one of the four clean proposals. The missed proposal is the gate chain: a gate connecting to another gate. The gate stops being a shape-mapping layer and turns back into a domain module, but no rule forbids this — the check only sees what is written. The false alarm is terminal membership; the flaw belongs not to the proposal but to K4’s allow list. Catching and the false alarm are the same phenomenon as the false-pass/false-fail pair in the testing courses; what is tested here is not code but a graph.
The Rule Itself Ages
GV17 — evolution event: the separate membership system is brought in-house and becomes the
network’s own module. Not a single edge in the graph changes; what changes is the “external” list in
the rule configuration. The same five rules, applied to the six proposals that are now correct after
membership is internalized, flag all five clean proposals as violations. Removing the gate, loan
calling membership directly, report reading from membership — all of them trip K1 and K5, because
both still assume membership is external. The rule did not break; its world changed.
The fix is in the configuration: three of the four inputs (the external, gate, and edge-allow lists) are updated, and three of the five rules give a different result on the same proposal. Afterward the false alarm rate drops to zero, and the one real violation — the internalized membership calling the identity service without a gate — keeps being caught.
The second route is relaxing: disabling the two rules that read the external list; the bottom row gives the price. In the post-evolution set, the false alarm falls from five to one but the one real violation escapes; on the first violation set, caught falls from seven to four, missed rises from one to four, and the false alarm stays at one — the remaining false alarm was K4’s, and K4 stayed on. What a false alarm produces in a team is the habit of disabling rules; disabling does not take away the rule that caused the false alarm, but the rule that catches the most violations.
The Untranslatable Part
What cannot be tied to a number is the cheapness of a change that has not yet been requested: the fitness function measures the structure against a written change set, and produces no number for what is not in that set. What takes its place is a periodically reviewed change scenario. GV18: six scenarios and four reviews a year come to 24 manual-review items a year; of the twelve changes that arrived in a year, five matched a written scenario (41.67 percent), and the remaining seven were priced only after they arrived. This ratio is counted in hindsight; the cost of the thirteenth, unwritten change cannot be counted in advance.
Summary
- The same three changes touch all six of A’s six internal modules (18 touches, 18 reversal steps); in the isolation-pointed B, they touch 1, 1, and 3 modules (5 and 5).
- The fitness function counts openness with three quantities (cyclic module 2 → 0, unisolated external edge 9 → 0, fully isolated external dependency 0/3 → 3/3); the permanent cost of isolation is B’s 14 nodes and 20 edges against A’s 9 nodes and 16 edges.
- The check catches seven of eight violations, misses a gate-to-gate edge, and gives a false alarm on one clean proposal; both come from how the rule is written.
- When a dependency is brought in-house, the rule ages without the graph changing: all five correct changes get flagged as violations; when three of the four configuration inputs are updated, the false alarm rate drops to zero, and if two rules are disabled instead of updating, caught falls from 7 to 4.
- The untranslatable part is the cheapness of a change that has not yet been requested; in its place, six scenarios and four reviews a year (24 items) cover five of the twelve changes that arrived.
Next Step
All of this lesson’s rules came from the inside: the architecture itself chose the gate, allow, and exception lists, and updated them itself when they aged; relaxing them was also in its own hands. Some constraints, though, come from the outside and are not open to negotiation — where data must stay, how long it must be kept, how an erasure request must be honored. The next lesson takes these up: what happens when a constraint that offers no option to remove it is turned into a check, and what takes the place of the part that cannot be translated?
To keep your progress and take notes, Log in
My notes
Log in to take notes.