---
title: 'Dependency Graph Health'
source: 'https://academia.sh/en/courses/design-principles/dependency-graph-health'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:16+00:00'
license: 'CC BY-SA 4.0'
---

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

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

```sh
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.

```sh
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.

```js
// 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]})`);
}
```

```sh
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.

```sh
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.
