Lesson 06 / 30
Dependency Injection
Moving the creation responsibility from the using code to the composition root: comparing a fee module that builds its own dependencies against a version that takes them at call time by direct dependency count and import closure size, counting the file edited when the discount policy changes, and showing that the closure does not disappear but moves.
Contents
The previous lesson took the instance-producing version of the registry through tests, but it
never said where the module using that registry gets the instance from. If a module calls
newRegistry inside its own body, every module builds its own registry; if it imports the
singleton version, global state comes back. The third option is to never build the object at
all.
Dependency injection is a mechanism already defined in the Application Architecture: Routing, State and Data and Unit Testing and Test-Driven Development courses; it is not redefined here. What this lesson asks is narrower: which numbers show the difference between a module building its own dependency and one taking it from outside. The measures come from the Design Principles course: a module’s direct dependency count and its import closure size.
Provider Modules
Both versions use the same three pieces: tariff objects, a registry holding them, and a discount policy. These pieces are shared between the two versions.
// provider/tariff.mjs — two tariff objects export const STANDARD = { name: "standard", baseFee: (s) => 3900 + Math.ceil(s.weight) * 900 }; export const EXPRESS = { name: "express", baseFee: (s) => 6400 + Math.ceil(s.weight) * 1500 };
// provider/registry.mjs — tariff registry; produces an instance loaded with two tariffs import { STANDARD, EXPRESS } from "./tariff.mjs"; export class TariffRegistry { #tariffs = new Map(); register(tariffObject) { this.#tariffs.set(tariffObject.name, tariffObject); return this; } tariff(name) { const t = this.#tariffs.get(name); if (t === undefined) throw new RangeError(`unknown tariff: ${name}`); return t; } } export const newRegistry = () => new TariffRegistry().register(STANDARD).register(EXPRESS);
// provider/discount.mjs — two discount policies export const contractedDiscount = (shipment, raw) => shipment.contractNumber === null ? 0 : Math.round(raw * 0.12); export const noDiscount = () => 0;
A Module That Builds Its Own Dependency
In the first version, the fee module imports everything it needs and builds the registry itself.
The caller sees only the fee function.
// own/fee.mjs — imports its own dependencies and builds them itself import { newRegistry } from "../provider/registry.mjs"; import { contractedDiscount } from "../provider/discount.mjs"; const ZONE = { "34": 100, "06": 115, "65": 140 }; const REGISTRY = newRegistry(); export function fee(shipment) { const baseFee = REGISTRY.tariff(shipment.tariff).baseFee(shipment); const raw = Math.round((baseFee * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100); return raw - contractedDiscount(shipment, raw); }
// own/main.mjs — the caller imports only the fee function import { fee } from "./fee.mjs"; export const EXAMPLES = [ { tariff: "standard", weight: 2.4, address: "34100", contractNumber: "S1042" }, { tariff: "express", weight: 2.4, address: "06500", contractNumber: null }, ]; for (const s of EXAMPLES) console.log(`own ${s.tariff.padEnd(9)} ${fee(s)} cents`);
node own/main.mjs
own standard 5808 cents own express 12535 cents
This version’s call site is the shortest of all: a single import and a single call. Its cost is not at the call site; it is in whom the fee module is bound to.
A Module That Takes Its Dependency at Call Time
In the second version, the fee module imports nothing. The registry and the discount policy are given at call time; the module produces a calculating function.
// injected/fee.mjs — takes dependencies at call time; imports no module const ZONE = { "34": 100, "06": 115, "65": 140 }; export const makeFeeCalculator = ({ registry, discount }) => (shipment) => { const baseFee = registry.tariff(shipment.tariff).baseFee(shipment); const raw = Math.round((baseFee * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100); return raw - discount(shipment, raw); };
The wiring moves to the composition root. It builds the pieces, picks which discount policy to use, and produces the calculator.
// injected/main.mjs — composition root: builds the pieces, picks a policy, injects them import { newRegistry } from "../provider/registry.mjs"; import { contractedDiscount, noDiscount } from "../provider/discount.mjs"; import { makeFeeCalculator } from "./fee.mjs"; const POLICY = { contracted: contractedDiscount, none: noDiscount }; const choice = process.argv[2] ?? "contracted"; const fee = makeFeeCalculator({ registry: newRegistry(), discount: POLICY[choice] }); export const EXAMPLES = [ { tariff: "standard", weight: 2.4, address: "34100", contractNumber: "S1042" }, { tariff: "express", weight: 2.4, address: "06500", contractNumber: null }, ]; for (const s of EXAMPLES) console.log(`injected ${choice.padEnd(11)} ${s.tariff.padEnd(9)} ${fee(s)} cents`);
node injected/main.mjs node injected/main.mjs none
injected contracted standard 5808 cents injected contracted express 12535 cents injected none standard 6600 cents injected none express 12535 cents
The first two lines match the first version’s output exactly. The last two show what happens when the discount policy changes: the contracted shipment’s fee rises from 5808 to 6600 cents, and the shipment with no contract does not change. No file was edited between the two runs.
Counting Dependencies and the Closure
The measurer below gives the direct import count of the given modules and their import closure’s size. The closure is the transitive set of every module a module reaches.
// closure.mjs — direct dependency count and import closure size of the given modules import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; const IMPORT = /(?:from|import)\s*["'](\.[^"']+)["']/g; const deps = (path) => [...readFileSync(path, "utf8").matchAll(IMPORT)].map((m) => m[1]); function closure(path, seen = new Set()) { for (const dep of deps(path)) { const target = resolve(dirname(path), dep); if (!seen.has(target)) { seen.add(target); closure(target, seen); } } return seen; } for (const path of process.argv.slice(2)) console.log(`${path.padEnd(18)} direct-deps=${deps(path).length} ` + `import-closure=${closure(path).size}`);
node closure.mjs own/fee.mjs injected/fee.mjs own/main.mjs injected/main.mjs
own/fee.mjs direct-deps=2 import-closure=3 injected/fee.mjs direct-deps=0 import-closure=0 own/main.mjs direct-deps=1 import-closure=4 injected/main.mjs direct-deps=3 import-closure=4
The first two lines give the gain. The fee module’s direct dependency count fell from 2 to 0, and its import closure from 3 to 0. A closure of zero means no concrete name appears in the module’s text at all: the registry class’s name, the tariff objects’ names, and the discount function’s name are all absent from the fee module.
The last two lines say what the pattern actually does. Both versions’ total closure is 4. The closure did not disappear; it moved: in the first version it sat above the fee module, in the second above the composition root, whose direct dependency count rose from 1 to 3. This is exactly what moving the creation responsibility looks like in the measure — one module’s gained independence is another module’s gained dependency count.
The File Edited When Policy Changes
The second measure comes from this request: remove the discount policy. The script below copies
both trees, applies the request to each, runs the results, and counts the files edited. The
in-place edit is given a backup extension; GNU and BSD sed behave the same way here.
cp -r own own-no-discount cp -r injected injected-no-discount sed -i.y 's/contractedDiscount/noDiscount/g' own-no-discount/fee.mjs rm -f own-no-discount/*.y node own-no-discount/main.mjs node injected-no-discount/main.mjs none for p in own injected; do echo "$p edited file = $(diff -rq $p $p-no-discount | grep -c '^Files ')" echo "$p fee module changed = $(diff -q $p/fee.mjs $p-no-discount/fee.mjs > /dev/null && echo no || echo yes)" done
own standard 6600 cents own express 12535 cents injected none standard 6600 cents injected none express 12535 cents own edited file = 1 own fee module changed = yes injected edited file = 0 injected fee module changed = no
The first four lines show both trees produce the same fees; the own label in the first two
lines is fixed in the module’s text, so it stays that way in the copy too. The last four lines
give the measure: in the first version, satisfying the request edited one file, and that file
was the fee module itself. In the second version, no file was edited; the policy choice was
already outside the composition root, a value given at run time.
This is the same split found in the Open–Closed Principle lesson, and the last line is the deciding one: the module computing the fee changed in the first version, and did not change in the second.
Cost
The pattern’s cost sits in three places. First, call-site length: getting a fee calculator is no longer a single import but three imports plus a construction call. Second, the composition root’s direct dependency count rising from 1 to 3; the composition root is unavoidably the one module that knows every concrete piece, and its growth is proportional to the component count.
The third is not readability but the number of calls that must be traced. In the first version,
which discount policy fee uses is written in the same file, on the import line. In the second,
that information is absent from the fee module; finding it means reading the composition root
and tracing which entry of the POLICY table got chosen. The answer to one question has spread
from one file to two.
This is why dependency injection is applied where a dependency is genuinely subject to change. The zone table is left inside the fee module in both versions: it is a fixed table, does not change during the process, and taking it from outside would not lower the closure — it would only grow the composition root.
Summary
- Dependency injection takes the creation responsibility away from the code using the object and moves it to the composition root; the pattern’s question is not how the object gets built but who builds it.
- The fee module’s direct dependency count fell from 2 to 0 and its import closure from 3 to 0; no concrete part’s name appears in the module’s text.
- Total closure is 4 in both versions: the closure did not disappear, it moved to the composition root, whose direct dependency count rose from 1 to 3.
- Removing the discount policy edited 1 file in the first version, and that file was the fee module; 0 files were edited in the second.
- The cost is a longer call site, a composition root that grows with component count, and a dependency’s concrete identity only findable by reading the composition root.
Next Step
The five mechanisms in this topic solved the question of who builds an object and how: gathering type selection in one place, producing matching pieces together, sequencing a multi-step construction, copying an existing object, and where to hold a single instance, and moving the building itself to the composition root. All five shared one assumption: the object being built was the library’s own object, and the library wrote its interface too, so making the pieces fit was something that could be arranged. The next topic addresses how objects get combined, and its first problem sits exactly where that assumption fails: working with an interface whose shape the library does not decide. Every outside carrier provider brings its own vocabulary, its own units, and its own way of reporting errors; the next lesson takes up resolving that mismatch in a single place, and measures its gain, again, by the number of files touched.
To keep your progress and take notes, Log in
My notes
Log in to take notes.