Lesson 19 / 19
Dependency Graph Health
Extracting the import graph from source files and auditing it at the component level: listing cycles by name with a depth-first search, counting edges that violate layer order, showing what a cycle looks like at runtime, and confirming both numbers drop to zero after the repair.
Contents
The previous lesson read from the release order’s breakdown that a cycle existed, but could not say where: which of the three unorderable components was inside the cycle, and which was only on the list because it was bound to it, was not visible. Every measure there also sat on a component graph written by hand.
In a real codebase the graph is not written by hand, it is extracted from import lines. This lesson writes a tool that does that extraction, lists cycles by name, and counts edges violating layer order; it first runs the tool on a broken version, then shows both numbers dropping to zero on the repaired version.
The Source Tree to Audit
Extracting the import graph at the file level and auditing the layer rule was established in the Layer Responsibilities lesson of the Data Access Layer and Business Logic course. The work here is different: the same graph is collapsed to the component level, and a cycle is searched for on it. The layer audit is also done by component rank, not by file path.
The following block sets up the library’s broken version. Nine files, five components, and
four layer ranks: money at the bottom (0), tariff and network one above it (1),
commercial-rule above those (2), and the entry file at the top (3).
mkdir -p broken cat > broken/money.mjs <<'EOF' import { TIERS } from "./tariff-table.mjs"; export const cents = (amount) => Math.round(amount * 100); export const MAX_WEIGHT = TIERS.at(-1).maxWeight; EOF cat > broken/tariff-table.mjs <<'EOF' import { cents } from "./money.mjs"; export const TIERS = [{ maxWeight: 5, fee: cents(84.9) }, { maxWeight: 30, fee: cents(249.9) }]; EOF cat > broken/factor.mjs <<'EOF' import { cents } from "./money.mjs"; export const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 }; export const MINIMUM = cents(39.9); EOF cat > broken/fee-rule.mjs <<'EOF' import { TIERS } from "./tariff-table.mjs"; import { ZONE_FACTOR, MINIMUM } from "./factor.mjs"; export const fee = (weight, zone) => Math.max((TIERS.find((t) => weight <= t.maxWeight)?.fee ?? 0) * ZONE_FACTOR[zone], MINIMUM); EOF cat > broken/discount.mjs <<'EOF' import { cents } from "./money.mjs"; export const discounted = (amount, rate) => Math.max(Math.round(amount * (1 - rate)), cents(39.9)); EOF cat > broken/selection.mjs <<'EOF' import { fee } from "./fee-rule.mjs"; import { discounted } from "./discount.mjs"; import { CARRIERS } from "./carrier-list.mjs"; export const cheapest = (weight, zone, rate) => CARRIERS .map((c) => ({ name: c.name, amount: discounted(fee(weight, zone) * c.multiplier, rate) })) .sort((a, b) => a.amount - b.amount)[0]; EOF cat > broken/carrier-list.mjs <<'EOF' import { cheapest } from "./selection.mjs"; export const CARRIERS = [{ name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }]; export const defaultCarrier = (weight) => cheapest(weight, "mid", 0).name; EOF cat > broken/route-building.mjs <<'EOF' import { CARRIERS } from "./carrier-list.mjs"; export const route = (zone) => [zone, CARRIERS[0].name]; EOF cat > broken/try.mjs <<'EOF' import { cheapest } from "./selection.mjs"; console.log("cheapest(3, 'mid', 0) =", JSON.stringify(cheapest(3, "mid", 0))); EOF ls broken | tr '\n' ' '
carrier-list.mjs discount.mjs factor.mjs fee-rule.mjs money.mjs route-building.mjs selection.mjs tariff-table.mjs try.mjs
Two import lines are placed in excess: money.mjs imports the tariff table, and
carrier-list.mjs imports the selection module. Looked at alone, both seem harmless — the
money module wants to know the top weight tier, the carrier list wants to compute the default
carrier.
What a Cycle Looks Like at Runtime
The cost of these two lines does not stay on the graph. When the entry file runs, the modules end up waiting on each other.
node broken/try.mjs 2>&1 | grep -m1 "^ReferenceError"
ReferenceError: Cannot access 'TIERS' before initialization
The error comes from one module in the cycle running its body while the constant the other one exports has not been defined yet. Which module gets evaluated first depends on the entry point, so the same cycle can pass under a different name at another entry point, or without throwing an error at all. This is the hardest part of a cycle: its symptom is not stable.
Cycle Detection and Layer Auditing
The tool does three steps. First, it reads every file’s import lines and extracts component edges; imports within the same component are dropped. Then it runs a depth-first search over these edges — the three-color scheme established in the Depth-First Search lesson of the Data Structures course: a gray node is still being processed, a black node is finished, and returning to a gray node is a cycle. Last, it compares every edge’s layer ranks.
// graph-health.mjs — extracts component cycles and layer violations from the import graph import { readdirSync, readFileSync } from "node:fs"; const COMPONENT = { "money.mjs": "money", "tariff-table.mjs": "tariff", "factor.mjs": "tariff", "fee-rule.mjs": "commercial-rule", "discount.mjs": "commercial-rule", "selection.mjs": "commercial-rule", "carrier-list.mjs": "network", "route-building.mjs": "network", "try.mjs": "entry", }; const LAYER = { money: 0, tariff: 1, network: 1, "commercial-rule": 2, entry: 3 }; function componentEdges(dir) { const edge = new Set(); for (const file of readdirSync(dir).filter((d) => d.endsWith(".mjs")).sort()) { const text = readFileSync(`${dir}/${file}`, "utf8"); for (const m of text.matchAll(/^import\s.*?from\s+"\.\/([\w.-]+)"/gm)) { const [a, b] = [COMPONENT[file], COMPONENT[m[1]]]; if (a !== b) edge.add(`${a} ${b}`); } } return [...edge].map((k) => k.split(" ")); } function cycles(edges) { const adjacency = new Map(); for (const [a, b] of edges) adjacency.set(a, [...(adjacency.get(a) ?? []), b]); const state = new Map(), path = [], found = []; const depthFirstSearch = (d) => { state.set(d, "gray"); path.push(d); for (const k of adjacency.get(d) ?? []) { if (state.get(k) === "gray") found.push([...path.slice(path.indexOf(k)), k]); else if (state.get(k) !== "black") depthFirstSearch(k); } path.pop(); state.set(d, "black"); }; for (const d of [...new Set(edges.flat())].sort()) if (!state.has(d)) depthFirstSearch(d); return found; } for (const dir of process.argv.slice(2)) { const edges = componentEdges(dir); const cycle = cycles(edges); const violation = edges.filter(([a, b]) => LAYER[a] < LAYER[b]); console.log(`${dir}/ component edges = ${edges.length}`); console.log(` cycles = ${cycle.length}`); for (const c of cycle) console.log(` ${c.join(" -> ")}`); console.log(` layer violations = ${violation.length}`); for (const [a, b] of violation) console.log(` ${a}(${LAYER[a]}) -> ${b}(${LAYER[b]})`); }
node graph-health.mjs broken
broken/ component edges = 7
cycles = 2
money -> tariff -> money
commercial-rule -> network -> commercial-rule
layer violations = 2
network(1) -> commercial-rule(2)
money(0) -> tariff(1)
The previous lesson’s release order had given three components as “unorderable”; the output
here names the components inside the cycle separately, and shows that commercial-rule is not
in the cycle, it is bound to it.
The two numbers point at the same two edges but do not measure the same thing. A layer violation is a rule violation: the ranks were declared beforehand, and the edge did not follow them. A cycle is independent of the rule; even where no layer has been declared, a cycle is still a defect, because the release order and the evaluation order break regardless of it. In a library with no layer rule, the second number cannot be computed, the first can.
The Fix
The two imports are removed by two different moves. money.mjs takes the tiers as a parameter
instead of importing the top weight tier — the same move as the first lesson’s: the rule does
not import the table. The default-carrier calculation inside carrier-list.mjs is moved into
the selection module instead; the calculation was already bound to selection, not to the list.
cp -r broken fixed cat > fixed/money.mjs <<'EOF' export const cents = (tl) => Math.round(tl * 100); export const maxWeight = (tiers) => tiers.at(-1).maxWeight; EOF cat > fixed/carrier-list.mjs <<'EOF' export const CARRIERS = [{ name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }]; EOF cat >> fixed/selection.mjs <<'EOF' export const defaultCarrier = (weight) => cheapest(weight, "mid", 0).name; EOF node fixed/try.mjs node graph-health.mjs fixed
cheapest(3, 'mid', 0) = {"name":"economy","amount":9742}
fixed/ component edges = 5
cycles = 0
layer violations = 0
The entry file now runs without an error and produces a result. Component edges dropped from seven to five, cycles from two to zero, layer violations from two to zero. No capability is lost: the default-carrier calculation still exists, only in a different file.
The Numbers as a Threshold
The value of these two numbers is that they are not meant to be a one-time measurement. Cycles do not build up from one big decision, they build up import line by import line, each looking harmless on its own; both lines in the broken version had a reasonable justification at the moment they were written. When the audit runs on every change and the threshold for cycle count is set to zero, the justification gets argued before the line is written, not after.
The graph the tool extracts is also the input to the previous lesson’s instability metric. Three numbers are read from the same import graph: cycle count, layer violation count, and per-component instability. Once all three settle at zero or at the expected direction, the component boundaries are working.
Summary
- The component graph is not written by hand; it is extracted from import lines, and imports within the same component are dropped down to component edges.
- A cycle is found with the depth-first search’s three-color scheme: returning to a gray node is a cycle, and the path on the stack names the cycle’s members.
- In the broken version, two import lines produced two cycles and two layer violations; the same cycle turned into an initialization error at runtime, tied to the entry point.
- A layer violation is a violation of a declared rule; a cycle is a defect independent of the rule — a cycle is countable even in a library with no layer rule.
- After the repair, component edges dropped from 7 to 5, cycles and layer violations from 2 to 0; the moved calculation was not lost, it only moved to the side it already depended on.
Course Wrap-Up
The course measured whether a design was good not by looking inside the unit but at the bond between units, and at every part it moved the measure one level up.
The SOLID part established five criteria at the class and module level: counting responsibility not as amount of work but as number of reasons to change, extending behavior without editing existing code, a subtype standing in for its supertype without breaking its contract, limiting interfaces to what the client actually uses, and binding dependency to an abstraction instead of a concrete detail.
The Coupling and Cohesion part moved to module pairs: measuring the strength of the bond between two modules, how related the names inside a module are to each other, limiting how many objects a call passes through, telling the decision to the data instead of pulling the data to make the decision, separating state-changing operations from ones that only read, encapsulating axes of change separately, the cost of binding to a concrete type, and handing control over to the callee.
The Boundaries and Component Principles part moved the scale from file to component: separating stable policy from changeable detail by frequency of change, measuring and narrowing the contract surface by name and field count, putting a tool whose contract is under someone else’s control behind an adapter, extracting the component boundary from files that change together, counting whether links flow in the stable direction, and finding cycles and layer violations in the dependency graph.
The course kept one assumption from beginning to end, and it needs naming at the close: the principles say how a design should be, not what to do. None of the sentences “keep the boundary narrow,” “let the bond flow in the stable direction,” “keep what changes together in one place” hand over a solution; they hand over a criterion. Yet the same boundary problem has been solved the same way, over and over, across different codebases — and a solution like that has a name, a known way of applying it, and known consequences.
The next course, Design Patterns, takes this up: classifying the classic patterns by the problem they solve, applying a pattern without producing needless complexity — that is, without the principles’ measures getting worse — and using enterprise application patterns in data access design. This course’s measures still hold there; a pattern is a pattern to the extent that it improves the measure.
To keep your progress and take notes, Log in
My notes
Log in to take notes.