Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close