Lesson 18 / 19
Component Coupling Principles
Measuring the direction of the bond between components: computing the instability metric from fan-in and fan-out, counting edges that do not flow in the stable direction, deriving a release order with topological sort, and showing how a single import into the stable core makes ordering impossible.
Contents
The previous lesson pulled the component boundary from files that change together and compared two splits by republish count. Those measures looked inside components: which files should stand together. Edges between components were counted, but their direction was not questioned.
An inter-component edge is the component-scale counterpart of the coupling measured for module pairs in the Coupling and Cohesion topic. The difference is direction: there, the strength of the bond was counted; here, which way it points will be counted. There is a criterion for which direction an edge should point, and it is again about change: a component bound to a frequently changing one gets dragged along behind those changes even if it never changes itself. This lesson computes a stability measure for every component, counts whether links flow in the right direction, and shows what stability buys in the release order.
Stability Is Something Measurable
A component’s stability is not that it never changes, it is that changing it is hard. Two numbers determine this: how many components are bound to it (fan-in) and how many components it is bound to (fan-out). A component with a high fan-in is hard to change, because changing it breaks others. A component with a high fan-out is easy to change, because changing it breaks no one — but that same component is exposed to being affected by others’ changes.
The two reduce to a single ratio. The instability metric is the ratio of fan-out to total links:
A component with is the most stable: things bind to it, it binds to nothing. A component with is the most unstable: nothing is bound to it, it is bound to others. The measure reports position, not quality; a library needs components at both ends.
The stable dependencies principle is built on top of this measure: an edge must flow in the direction where drops. A stable component binding to an unstable one turns its own stability into a fiction — the component looks closed to change, but it gets republished every time the side it is bound to changes.
Three Graphs to Measure
The two splits from the previous lesson give the same library’s two component graphs. A third graph is produced by adding a single import to the second: the money component starts formatting amounts by zone and imports the tariff component.
// component-graph.mjs — the same library's three component graphs export const DIRECTORY = { components: ["fee", "zone", "carrier", "route", "delivery", "shared"], edges: [["fee", "zone"], ["fee", "shared"], ["carrier", "fee"], ["route", "carrier"], ["delivery", "route"]], }; export const COCHANGE = { components: ["tariff", "commercial-rule", "network", "tracking", "money"], edges: [["commercial-rule", "tariff"], ["commercial-rule", "money"], ["commercial-rule", "network"], ["tariff", "money"], ["tracking", "network"]], }; export const BROKEN = { components: COCHANGE.components, edges: [...COCHANGE.edges, ["money", "tariff"]], };
The measuring tool computes two things. First, the instability metric and the edges not flowing in the stable direction. Second, the release order: a component can be released only after everything it is bound to has been released. This is the component-level counterpart of the topological sort established in the Data Structures course; if a component is left unsorted, there is a cycle in the graph.
// stability.mjs — instability metric, stable-direction violations, and release order export function measure(components, edges) { const fanIn = Object.fromEntries(components.map((c) => [c, 0])); const fanOut = Object.fromEntries(components.map((c) => [c, 0])); for (const [a, b] of edges) { fanOut[a] += 1; fanIn[b] += 1; } const I = Object.fromEntries(components.map((c) => { const total = fanIn[c] + fanOut[c]; return [c, total === 0 ? 0 : fanOut[c] / total]; })); return { fanIn, fanOut, I, violations: edges.filter(([a, b]) => I[a] < I[b]) }; } export function releaseOrder(components, edges) { const remaining = new Set(components); const order = []; for (;;) { const ready = [...remaining] .filter((c) => edges.every(([a, r]) => a !== c || !remaining.has(r))).sort(); if (ready.length === 0) return { order, remaining: [...remaining].sort() }; for (const c of ready) { order.push(c); remaining.delete(c); } if (remaining.size === 0) return { order, remaining: [] }; } }
// stability-report.mjs — reports all three graphs with the same measures import { DIRECTORY, COCHANGE, BROKEN } from "./component-graph.mjs"; import { measure, releaseOrder } from "./stability.mjs"; for (const [name, graph] of [["directory", DIRECTORY], ["co-change", COCHANGE], ["broken", BROKEN]]) { const { fanIn, fanOut, I, violations } = measure(graph.components, graph.edges); console.log(`${name} graph — ${graph.components.length} components, ${graph.edges.length} edges`); for (const c of [...graph.components].sort((x, y) => I[x] - I[y] || x.localeCompare(y))) { console.log(` ${c.padEnd(16)} fan-in ${fanIn[c]} fan-out ${fanOut[c]} I = ${I[c].toFixed(2)}`); } console.log(` stable-direction violations = ${violations.length} / ${graph.edges.length}` + (violations.length ? ` ${violations.map(([a, b]) => `${a} -> ${b}`).join(", ")}` : "")); const { order, remaining } = releaseOrder(graph.components, graph.edges); console.log(` release order: ${order.join(" -> ")}` + (remaining.length ? ` || unorderable: ${remaining.join(", ")}` : "")); }
directory graph — 6 components, 5 edges shared fan-in 1 fan-out 0 I = 0.00 zone fan-in 1 fan-out 0 I = 0.00 carrier fan-in 1 fan-out 1 I = 0.50 route fan-in 1 fan-out 1 I = 0.50 fee fan-in 1 fan-out 2 I = 0.67 delivery fan-in 0 fan-out 1 I = 1.00 stable-direction violations = 1 / 5 carrier -> fee release order: shared -> zone -> fee -> carrier -> route -> delivery co-change graph — 5 components, 5 edges money fan-in 2 fan-out 0 I = 0.00 network fan-in 2 fan-out 0 I = 0.00 tariff fan-in 1 fan-out 1 I = 0.50 commercial-rule fan-in 0 fan-out 3 I = 1.00 tracking fan-in 0 fan-out 1 I = 1.00 stable-direction violations = 0 / 5 release order: money -> network -> tariff -> tracking -> commercial-rule broken graph — 5 components, 6 edges network fan-in 2 fan-out 0 I = 0.00 money fan-in 2 fan-out 1 I = 0.33 tariff fan-in 2 fan-out 1 I = 0.33 commercial-rule fan-in 0 fan-out 3 I = 1.00 tracking fan-in 0 fan-out 1 I = 1.00 stable-direction violations = 0 / 6 release order: network -> tracking || unorderable: commercial-rule, money, tariff
Where the Direction Is Wrong
In the directory graph, one of five edges does not flow in the stable direction: carrier -> fee. The carrier component’s instability is 0.50, the fee component’s is 0.67. The link points
toward the more unstable one.
What this means concretely: because the route component is bound to carrier, and carrier to fee, every commercial-rule change reaching the fee component walks all the way to the route component. The fee component was the most frequently touched spot in the previous lesson’s log; the most-changing component has settled in the middle of the chain.
In the co-change graph, that count is zero. All five edges flow from unstable to stable: the two
most unstable components (commercial-rule and tracking) feed into no component, and the two
most stable ones (network and money) bind to nothing. The measure confirms the previous
lesson’s split with an independent criterion.
What Stability Buys
If the stable direction were only an aesthetic preference, it would not be worth measuring. What
it buys shows up in the release order: in the co-change graph, the five components release in
the order money -> network -> tariff -> tracking -> commercial-rule, guaranteeing that
everything each component is bound to was released before it. Which components need retesting
once a new version ships is also read from the same order.
The condition for the order to exist is the acyclic dependencies principle: the component graph must not contain a cycle. If there is a cycle, there is no release order, because every component in the cycle is waiting for another one to be released first.
A Single Import Into the Stable Core
The broken graph shows exactly this. The one edge added is money -> tariff. The results:
The money component’s instability went from 0.00 to 0.33. The library’s most stable part is now bound to the tariff table; every time the price list is refreshed, the money component gets republished too. The tariff component symmetrically dropped from 0.50 to 0.33 — the number went down, but the situation did not improve, because the two components are now entangled in each other.
The stable-direction violation count came out zero. Because the two components’ instability is equal, no edge trips the “does not flow from unstable to stable” criterion. This is proof that the instability metric alone is not enough: a cycle does not violate the measure, it makes the measure meaningless.
The release order does not hide the situation, though. Only two of the five components could be
ordered; money, tariff, and commercial-rule came out unorderable. commercial-rule is on
the list even though it is not inside the cycle: every component bound to a cycle also becomes
unorderable. A single import line in one component made a third component, bound to it from a
distance, impossible to release.
Breaking the Cycle
There are two known ways to remove a cycle, and both are moves already established earlier.
The first is reversing the direction: instead of importing the tariff, the money component takes the information it needs for formatting as a parameter. This is the component-scale counterpart of the first lesson’s move — the rule does not import the table, the table is given to the rule. The dependency inversion item of SOLID and the layer rule from M16/K04 describe this same move.
The second is inserting a component in between: a shared need of two components is split off into a third component, and both bind to it. The cycle is broken because both edges now point the same way.
Once the first way is applied, the graph becomes the co-change graph again: money returns to
, the edge count drops from six to five, and all five components get ordered. All three
measures recover at once.
Summary
- A component’s instability is computed as ; 0 marks the most stable position, 1 the most unstable.
- The stable dependencies principle asks edges to flow in the direction where drops; 1 of 5 edges did not flow this way under the directory split, 0 of 5 under the co-change split.
- Stability’s payoff is the release order: in the co-change graph, all five components can be released in a single order, and once a version ships, which components need retesting is read from that same order.
- A single import added to the stable core (
money -> tariff) raised the money component’s instability from 0.00 to 0.33 and made three of the five components unorderable. - The same broken graph produced zero stable-direction violations; the instability metric does not catch a cycle — a cycle makes the measure itself meaningless.
Next Step
The release order said a cycle exists, but not where it was: which of the three unorderable components is inside the cycle and which is only on the list because it is bound to it could not be read from the output. Also, every measure in this section sat on a component graph written by hand; in a real codebase, the graph is extracted from import lines. The next lesson writes a tool that reads the import graph from source files, lists cycles by name with a depth-first search, and counts imports violating the layer rule; it first runs the tool on a broken version, then shows both numbers dropping to zero on the fixed version.
To keep your progress and take notes, Log in
My notes
Log in to take notes.