Lesson 05 / 19
Dependency Inversion
Turning which side an abstraction is defined on into a number: counting the direction of the import edges between the rule and carrier packages across three versions, measuring the contract's owner and its number of incoming edges, and testing with a run whether the rule module can load without the carrier package.
Contents
The split contracts stood next to the clients, but that placement was a separate decision. How many pieces an interface is split into and which package those pieces are defined in are independent of each other: a small contract can also live next to the implementation. In that case, the client binds to a small interface but still imports the package where the implementation lives.
Dependency inversion is SOLID’s fifth item, and it takes on exactly this question: the high-level rule should bind not to the low-level implementation but to an abstraction both sides bind to; and the abstraction should be something the rule defines, not the implementation. The Data Access Layer and Business Logic course audited this rule through the import graph between layers, and measured the violation count and the size of the import closure. The question here is narrower and its measure is different: which direction do the edges between the two packages point, and which package does the contract file stand in?
Three Placements
The codebase is split into two packages. rule/ holds the fee rule, and carrier/
holds the carrier-specific base fee calculations. The same behavior is written with
three different placements.
mkdir -p direct/rule direct/carrier partial/rule partial/carrier mkdir -p inverted/rule inverted/carrier
In the first placement, there is no abstraction at all; the rule directly imports the two concrete modules and chooses between them itself.
// direct/carrier/domestic.mjs — domestic carrier's base fee export const domesticBase = (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800);
// direct/carrier/express.mjs — express carrier's base fee export const expressBase = (s) => 5200 + Math.ceil(s.weight) * 900;
// direct/rule/fee.mjs — high-level rule imports the two concrete carriers itself import { domesticBase } from "../carrier/domestic.mjs"; import { expressBase } from "../carrier/express.mjs"; const ZONE = { "34": 100, "06": 115, "65": 140 }; const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160; export function fee(shipment) { const base = shipment.carrier === "express" ? expressBase(shipment) : domesticBase(shipment); return Math.round((base * zoneFactor(shipment.address)) / 100); }
In the second placement, there is a contract, but it is defined in the carrier package.
// partial/carrier/contract.mjs — contract exists, but defined in the carrier package export function validateCarrier(c) { if (typeof c?.base !== "function") throw new TypeError("carrier contract missing: base"); return c; }
// partial/carrier/domestic.mjs — domestic carrier satisfying the contract import { validateCarrier } from "./contract.mjs"; export const domestic = validateCarrier({ name: "domestic", base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800), });
// partial/carrier/express.mjs — express carrier satisfying the contract import { validateCarrier } from "./contract.mjs"; export const express = validateCarrier({ name: "express", base: (s) => 5200 + Math.ceil(s.weight) * 900, });
// partial/rule/fee.mjs — rule binds to the contract, but the contract lives in the carrier package import { validateCarrier } from "../carrier/contract.mjs"; const ZONE = { "34": 100, "06": 115, "65": 140 }; const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160; export function fee(carrier, shipment) { validateCarrier(carrier); return Math.round((carrier.base(shipment) * zoneFactor(shipment.address)) / 100); }
In the third placement, the contract file is moved into the rule package exactly as it is. The file’s content does not change, only its location does.
// inverted/rule/contract.mjs — contract defined in the rule package export function validateCarrier(c) { if (typeof c?.base !== "function") throw new TypeError("carrier contract missing: base"); return c; }
// inverted/rule/fee.mjs — rule imports only its own contract import { validateCarrier } from "./contract.mjs"; const ZONE = { "34": 100, "06": 115, "65": 140 }; const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160; export function fee(carrier, shipment) { validateCarrier(carrier); return Math.round((carrier.base(shipment) * zoneFactor(shipment.address)) / 100); }
// inverted/carrier/domestic.mjs — implementation, imports the rule's contract import { validateCarrier } from "../rule/contract.mjs"; export const domestic = validateCarrier({ name: "domestic", base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800), });
// inverted/carrier/express.mjs — implementation, imports the rule's contract import { validateCarrier } from "../rule/contract.mjs"; export const express = validateCarrier({ name: "express", base: (s) => 5200 + Math.ceil(s.weight) * 900, });
All three versions produce the same fee.
// same-result.mjs — all three versions produce the same fee; the difference is not in behavior const SHIPMENT = { carrier: "express", weight: 3.0, address: "06500" }; const { fee: directFee } = await import("./direct/rule/fee.mjs"); const { fee: partialFee } = await import("./partial/rule/fee.mjs"); const { express: partialExpress } = await import("./partial/carrier/express.mjs"); const { fee: invertedFee } = await import("./inverted/rule/fee.mjs"); const { express: invertedExpress } = await import("./inverted/carrier/express.mjs"); console.log("direct =", directFee(SHIPMENT), "cents"); console.log("partial =", partialFee(partialExpress, SHIPMENT), "cents"); console.log("inverted =", invertedFee(invertedExpress, SHIPMENT), "cents");
node same-result.mjs
direct = 9085 cents partial = 9085 cents inverted = 9085 cents
Counting the Edges by Direction
The difference is visible only in the import graph. The script below reads every module in both packages, resolves the relative import paths, and counts the edges that cross the package boundary by direction. Its second measure concerns the abstraction itself: which package does the contract file stand in, and how many modules bind to it?
// direction-measure.mjs — counts the import edges between the two packages and the abstraction's owner import { readdirSync, readFileSync } from "node:fs"; import { join, relative, resolve, dirname } from "node:path"; const IMPORT = /^\s*(?:import|export)[^;'"]*from\s+["'](\.[^"']+)["']/gm; const pkg = (path) => path.split("/")[0]; const root = process.argv[2]; const edges = []; for (const p of ["rule", "carrier"]) { for (const name of readdirSync(join(root, p)).filter((a) => a.endsWith(".mjs")).sort()) { const source = `${p}/${name}`; for (const m of readFileSync(join(root, source), "utf8").matchAll(IMPORT)) { edges.push([source, relative(root, resolve(root, dirname(source), m[1]))]); } } } const outward = edges.filter(([a, b]) => pkg(a) !== pkg(b)); for (const [a, b] of outward) console.log(` ${a} -> ${b}`); const count = (p) => outward.filter(([a]) => pkg(a) === p).length; console.log(`${root.padEnd(9)} rule -> carrier = ${count("rule")}` + ` carrier -> rule = ${count("carrier")}`); const abstractions = [...new Set(edges.map(([, b]) => b))].filter((b) => b.endsWith("contract.mjs")); if (abstractions.length === 0) { console.log(`${root.padEnd(9)} no abstraction defined`); } for (const a of abstractions) { const incoming = edges.filter(([, b]) => b === a).length; console.log(`${root.padEnd(9)} abstraction owner = ${pkg(a)}, incoming edges = ${incoming}`); }
node direction-measure.mjs direct node direction-measure.mjs partial node direction-measure.mjs inverted
rule/fee.mjs -> carrier/domestic.mjs rule/fee.mjs -> carrier/express.mjs direct rule -> carrier = 2 carrier -> rule = 0 direct no abstraction defined rule/fee.mjs -> carrier/contract.mjs partial rule -> carrier = 1 carrier -> rule = 0 partial abstraction owner = carrier, incoming edges = 3 carrier/domestic.mjs -> rule/contract.mjs carrier/express.mjs -> rule/contract.mjs inverted rule -> carrier = 0 carrier -> rule = 2 inverted abstraction owner = rule, incoming edges = 3
The three lines separate three distinct situations. The first version has two edges from the rule to the implementation and no abstraction. The second version has an abstraction, with three incoming edges — meaning the contract genuinely does its job — but the number of boundary-crossing edges dropped from two to one, not to zero: the rule still imports the carrier package. The third version has the same contract, with the same three incoming edges, standing in the rule package, and every boundary-crossing edge points in the reverse direction.
The second version is the principle’s most common half-implementation. It is not enough for the abstraction to exist; who defines it is the measure’s real determinant.
The Rule Without an Implementation
Edge direction looks like an abstract measure; its payoff is concrete. From each version, a copy containing only the rule package is extracted, and an attempt is made to load the rule module on its own.
// try-rule.mjs — can the rule module load without the carrier package const root = process.argv[2]; try { const m = await import(`./${root}/rule/fee.mjs`); console.log(`${root.padEnd(16)} -> loaded, exports: ${Object.keys(m).join(", ")}`); } catch (e) { console.log(`${root.padEnd(16)} -> failed to load, ${e.code}`); }
for k in direct partial inverted; do mkdir -p "$k-only" cp -r "$k/rule" "$k-only/rule" node try-rule.mjs "$k-only" done
direct-only -> failed to load, ERR_MODULE_NOT_FOUND partial-only -> failed to load, ERR_MODULE_NOT_FOUND inverted-only -> loaded, exports: fee
The second version, which has a contract, also failed to load. Whether the rule can be resolved independently of the implementation depends not on the contract’s existence but on the contract being defined on the rule’s side. This is the measure’s numeric payoff: the rule cannot stand on its own until the number of edges going from the rule to the implementation is zero.
The Limit and Cost of the Principle
In the third version, the fee module does not know which carrier it will work with; the carrier is given to it from outside. This is the dependency injection built in the Application Architecture: Routing, State and Data and Unit Testing and Test-Driven Development courses, and it appears here as a consequence of the principle: a module that does not construct its own dependency has to get it from somewhere. That place is the composition root.
The cost of inverting can also be counted: in the third version, one extra contract file stands between the two packages, and the carrier choice is now outside the fee module instead of inside it. Where the implementation is known to be singular and unchanging, this surplus has no payoff. The principle does not ask for every dependency to be inverted; it asks for the side that changes faster to bind to the side that changes slower. If the carrier list changes more often than the fee rule, the direction is inverted; if the reverse is true, the measurement says the reverse too.
Summary
- Dependency inversion asks for the high-level rule to bind to an abstraction rather than the implementation, and for that abstraction to be defined by the rule.
- The measure is the direction of the import edges that cross the package boundary: 2, 1, and 0 edges were counted from the rule to the carrier; 0, 0, and 2 edges were counted from the carrier to the rule.
- The abstraction’s existence is not enough: in the version whose contract stood in the carrier package, the rule still imported the carrier package even though the number of incoming edges was 3.
- The payoff of edge direction was seen with a run: without the carrier package, only
the third version’s rule module loaded; the first two gave
ERR_MODULE_NOT_FOUND. - The principle inverts not every dependency but the dependencies that point at the faster-changing side; the contract file and the outside-injection apparatus are its cost.
Next Step
The five principles said whom a unit binds to: how many actors, which axis of extension, which contract, how large an interface, which side the abstraction is on. None of them tied the bond itself to a measure. How is the strength of the bond between two modules counted — the number of shared names, the number of parameters passed, state changed in common? How is it understood whether the parts inside a module truly belong to each other? The next topic builds these two measures and turns the judgments the principles have so far left qualitative into numbers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.