Lesson 12 / 19
Programming to Abstractions
Counting the cost of binding to a concrete type: comparing the number of files edited and lines touched in two layouts when a second price list is added, finding the number of places that make the choice, and showing that a fake price list only takes effect for the client bound to an abstraction.
Contents
In the previous lesson’s isolated layout, all three clients imported the price module by name and called its function directly. As long as there was only one price list, this stayed invisible. When a second list arrives for contracted customers, the question sharpens: how will the client know which list to use?
Programming to abstractions means the client binds to a contract that multiple implementations can satisfy, not to a concrete module. The dependency inversion lesson measured which package the contract is defined in; the measure here is on the client side: how many lines are touched when the implementation changes, or when a second one is added?
Layout Bound to a Concrete Module
mkdir -p concrete abstract
// records.mjs — sample shipments used by both layouts export const RECORDS = [ { code: "GN-1", weight: 0.8, address: "34100", contracted: false }, { code: "GN-2", weight: 3.0, address: "06500", contracted: true }, { code: "GN-3", weight: 12.0, address: "65200", contracted: true }, ];
// concrete/price.mjs — the standard price list const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export function calculateCents(shipment) { const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000; return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100); }
// concrete/web.mjs — imports the concrete price module by name import { calculateCents } from "./price.mjs"; export const line = (shipment) => `${shipment.code} ${calculateCents(shipment)} cents`;
// concrete/batch.mjs — imports the concrete price module by name import { calculateCents } from "./price.mjs"; export const lines = (shipments) => shipments.map((s) => `${s.code} ${calculateCents(s)} cents`);
// concrete/report.mjs — imports the concrete price module by name import { calculateCents } from "./price.mjs"; export const totalLine = (shipments) => `TOTAL ${shipments.reduce((t, s) => t + calculateCents(s), 0)} cents`;
// concrete/setup.mjs — composition root import { RECORDS } from "../records.mjs"; import { line } from "./web.mjs"; import { lines } from "./batch.mjs"; import { totalLine } from "./report.mjs"; console.log(line(RECORDS[1])); console.log(lines(RECORDS).join(" | ")); console.log(totalLine(RECORDS));
Layout Bound to an Abstraction
In the second layout, the price list is an object given to the clients from outside. The
clients know that a method named calculateCents exists; they do not know which list is
arriving.
// abstract/price-standard.mjs — a standard list that satisfies the contract const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export const standard = { name: "standard", calculateCents(shipment) { const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000; return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100); }, };
// abstract/web.mjs — takes the price from outside, does not know the implementation's name export const line = (price, shipment) => `${shipment.code} ${price.calculateCents(shipment)} cents`;
// abstract/batch.mjs — takes the price from outside export const lines = (selectPrice, shipments) => shipments.map((s) => `${s.code} ${selectPrice(s).calculateCents(s)} cents`);
// abstract/report.mjs — takes the price from outside export const totalLine = (selectPrice, shipments) => `TOTAL ${shipments.reduce((t, s) => t + selectPrice(s).calculateCents(s), 0)} cents`;
// abstract/setup.mjs — composition root: only this file knows which price list is used import { RECORDS } from "../records.mjs"; import { standard } from "./price-standard.mjs"; import { line } from "./web.mjs"; import { lines } from "./batch.mjs"; import { totalLine } from "./report.mjs"; const selectPrice = () => standard; console.log(line(selectPrice(RECORDS[1]), RECORDS[1])); console.log(lines(selectPrice, RECORDS).join(" | ")); console.log(totalLine(selectPrice, RECORDS));
node concrete/setup.mjs node abstract/setup.mjs
GN-2 7360 cents GN-1 3900 cents | GN-2 7360 cents | GN-3 16520 cents TOTAL 27780 cents GN-2 7360 cents GN-1 3900 cents | GN-2 7360 cents | GN-3 16520 cents TOTAL 27780 cents
Second Implementation
A contracted customer list arrives: the tier fees and zone factors differ. Both trees are copied and the requirement is applied to both.
cp -r concrete concrete-new cp -r abstract abstract-new
// concrete-new/price-contracted.mjs — contracted customer price list const TIER = [[1, 3200], [5, 5300], [20, 9700]]; const ZONE = { "34": 100, "06": 110, "65": 130 }; export function calculateCents(shipment) { const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 13000; return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 150)) / 100); }
// concrete-new/web.mjs — imports both concrete modules and chooses between them import { calculateCents } from "./price.mjs"; import { calculateCents as contractedCents } from "./price-contracted.mjs"; export const line = (shipment) => `${shipment.code} ${shipment.contracted ? contractedCents(shipment) : calculateCents(shipment)} cents`;
// concrete-new/batch.mjs — the same choice a second time import { calculateCents } from "./price.mjs"; import { calculateCents as contractedCents } from "./price-contracted.mjs"; export const lines = (shipments) => shipments.map((s) => `${s.code} ${s.contracted ? contractedCents(s) : calculateCents(s)} cents`);
// concrete-new/report.mjs — the same choice a third time import { calculateCents } from "./price.mjs"; import { calculateCents as contractedCents } from "./price-contracted.mjs"; export const totalLine = (shipments) => `TOTAL ${shipments.reduce((t, s) => t + (s.contracted ? contractedCents(s) : calculateCents(s)), 0)} cents`;
The clients are not opened in the abstract layout. The new list is a second object that satisfies the same contract, and the choice is made in the composition root.
// abstract-new/price-contracted.mjs — a second list satisfying the same contract const TIER = [[1, 3200], [5, 5300], [20, 9700]]; const ZONE = { "34": 100, "06": 110, "65": 130 }; export const contracted = { name: "contracted", calculateCents(shipment) { const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 13000; return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 150)) / 100); }, };
// abstract-new/setup.mjs — composition root: only this file knows which price list is used import { RECORDS } from "../records.mjs"; import { standard } from "./price-standard.mjs"; import { contracted } from "./price-contracted.mjs"; import { line } from "./web.mjs"; import { lines } from "./batch.mjs"; import { totalLine } from "./report.mjs"; const selectPrice = (s) => (s.contracted ? contracted : standard); console.log(line(selectPrice(RECORDS[1]), RECORDS[1])); console.log(lines(selectPrice, RECORDS).join(" | ")); console.log(totalLine(selectPrice, RECORDS));
Counting the Cost
node concrete-new/setup.mjs node abstract-new/setup.mjs for k in concrete abstract; do echo "$k: files edited = $(diff -rq $k $k-new | grep -c '^Files')" \ " new files = $(diff -rq $k $k-new | grep -c '^Only in')" \ " lines touched = $(diff -rU0 $k $k-new | grep -cE '^[+-][^+-]')" \ " files making the choice = $(grep -l 'contracted ?' $k-new/*.mjs | wc -l | tr -d ' ')" done
GN-2 5830 cents GN-1 3900 cents | GN-2 5830 cents | GN-3 12610 cents TOTAL 22340 cents GN-2 5830 cents GN-1 3900 cents | GN-2 5830 cents | GN-3 12610 cents TOTAL 22340 cents concrete: files edited = 3 new files = 1 lines touched = 18 files making the choice = 3 abstract: files edited = 1 new files = 1 lines touched = 5 files making the choice = 1
Same requirement, same result, different cost: eighteen lines against five, three files edited against one. The last column gives the reason for the difference — in the concrete layout, the choice is made in three separate places. When a third list is added, all three of those places will be edited again; in the abstract layout, the place to edit will remain the composition root.
Substitutability
The second measure is whether the implementation can be swapped out during testing. The same client is given a fake price list that produces a fixed value.
// try-fake.mjs — can clients work with a fake price list const FAKE = { name: "fake", calculateCents: () => 1000 }; const G = { code: "GN-9", weight: 3.0, address: "06500", contracted: false }; const concreteWeb = await import("./concrete/web.mjs"); const abstractWeb = await import("./abstract/web.mjs"); console.log("concrete ->", concreteWeb.line(G)); console.log("abstract ->", abstractWeb.line(FAKE, G));
node try-fake.mjs
concrete -> GN-9 7360 cents abstract -> GN-9 1000 cents
In the concrete layout, there is no place to hand the fake list to; the client ignored it and calculated the real price. In the abstract layout, the same client used the fake. The difference is not a convenience, it is structural: a concrete import leaves no point of choice.
The Cost of the Principle
Abstraction is not free. In the abstract layout, all three clients’ signatures grew by one
parameter, and the selectPrice function built an extra layer of indirection in the
composition root. If the implementation stays single, this extra cost is never recovered: the
measurement paid off once a second implementation arrived; had none arrived, it would only
have increased the parameter count.
The criterion is therefore the history of implementation count. If a module has never needed a second implementation, and testing does not require swapping it either, a concrete import is the right choice. If testing requires swapping it, the implementation count is already two; a fake is an implementation too.
Summary
- Programming to abstractions means the client binds to a contract that multiple implementations can satisfy, not to a concrete module.
- When the second price list was added, the concrete layout touched 3 files and 18 lines, the abstract layout touched 1 file and 5 lines.
- The source of the difference is the point of choice: the concrete layout makes the choice in 3 files, the abstract layout in 1.
- A fake price list took effect only in the abstract layout; a concrete import leaves no point at which the implementation can be swapped.
- The cost of the abstraction is the parameter count and a layer of indirection; if a second implementation never arrives, this cost is never recovered.
Next Step
In the abstract layout, the composition root decides which price list is used, but it still
calls the clients itself: first line, then lines, then totalLine. The order and count of
the steps live in the caller’s body. Adding a new step to this flow — a contracted customer
discount, an insurance premium, an audit line — requires editing the caller every time. The
next lesson reverses the direction between caller and callee: it moves the order of the steps
into a skeleton, moves the steps themselves into parts handed to the skeleton, and compares
the cost of adding a new step in the two arrangements.
To keep your progress and take notes, Log in
My notes
Log in to take notes.