Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close