Lesson 04 / 19
Interface Segregation Principle
Counting the cost a bloated carrier interface imposes on clients: scanning and comparing the number of methods each client genuinely calls against the number of methods the interface imposes, measuring the total method count in fake dependencies, and finding with a run how many fakes break when a ninth method is added to the interface.
Contents
The contract test ran through two methods, and every tariff genuinely used both. This balance breaks once the discussion moves to the carrier side: carrier selection, route planning, and delivery tracking are gathered onto the same object. Once a single carrier interface is built, a client that only calculates the fee also becomes bound to the route and tracking methods.
The interface segregation principle forbids this: no client should be forced to depend on methods it does not use. The principle’s measure is a comparison of two separate numbers — the number of methods a client calls and the number of methods the interface imposes.
A Single, Wide Contract
In the first version, all eight methods are gathered into a single contract. The contract is written as a list and a validator that checks it; clients call the validator to make sure the object they received satisfies the contract.
// v1/carrier.mjs — single, wide carrier contract export const CARRIER_CONTRACT = [ "zoneFactor", "baseFee", "transferPoints", "estimatedDuration", "state", "received", "printLabel", "cancel", ]; export function validateContract(c) { const missing = CARRIER_CONTRACT.filter((name) => typeof c?.[name] !== "function"); if (missing.length > 0) throw new TypeError(`carrier contract missing: ${missing.join(", ")}`); return c; }
Three clients bind to this contract. Each exports the contract it binds to under the
name REQUIRED; the measurement script will read this name.
// v1/fee-calculation.mjs — fee client: calls two methods, requires eight import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs"; export const REQUIRED = CARRIER_CONTRACT; export function fee(carrier, shipment) { validateContract(carrier); return Math.round((carrier.baseFee(shipment) * carrier.zoneFactor(shipment.address)) / 100); }
// v1/route-planner.mjs — route client import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs"; export const REQUIRED = CARRIER_CONTRACT; export function route(carrier, source, destination) { validateContract(carrier); const points = carrier.transferPoints(source, destination); return { points, hours: carrier.estimatedDuration(points) }; }
// v1/tracking.mjs — tracking client import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs"; export const REQUIRED = CARRIER_CONTRACT; export function deliver(carrier, trackingNumber, signature) { validateContract(carrier); carrier.received(trackingNumber, signature); return carrier.state(trackingNumber); }
Measuring the Two Numbers
The measurement script scans each client’s text for calls that begin with carrier.
and counts the distinct method names it finds. The imposed count is the length of the
contract the client binds to.
// interface-measure.mjs — number of methods a client calls vs. what the interface imposes import { readFileSync } from "node:fs"; const CALL = /\bcarrier\.(\w+)\(/g; const root = process.argv[2]; const clients = process.argv.slice(3); let usedTotal = 0; let imposedTotal = 0; for (const name of clients) { const text = readFileSync(`${root}/${name}`, "utf8"); const used = new Set([...text.matchAll(CALL)].map((m) => m[1])); const { REQUIRED } = await import(`./${root}/${name}`); usedTotal += used.size; imposedTotal += REQUIRED.length; console.log(`${name.padEnd(20)} used = ${used.size} imposed = ${REQUIRED.length}` + ` surplus = ${REQUIRED.length - used.size}`); } console.log(`${root}: used total = ${usedTotal}, imposed total = ${imposedTotal}`);
node interface-measure.mjs v1 fee-calculation.mjs route-planner.mjs tracking.mjs
fee-calculation.mjs used = 2 imposed = 8 surplus = 6 route-planner.mjs used = 2 imposed = 8 surplus = 6 tracking.mjs used = 2 imposed = 8 surplus = 6 v1: used total = 6, imposed total = 24
Six calls correspond to twenty-four dependencies. The surplus is not conceptual: each client is bound to the name and existence of six methods it does not use.
The Cost of the Fake Dependency
The first concrete bill for the surplus arrives in fake dependencies. For each client, fakes are written that carry only the methods that client calls.
// fake-minimal.mjs — each fake carries only the methods its own client calls export const FAKE = { fee: { zoneFactor: () => 115, baseFee: () => 6400 }, route: { transferPoints: (s, d) => [s, "41", d], estimatedDuration: (p) => p.length * 6 }, tracking: { state: () => "delivered", received: () => undefined }, };
// try-fake.mjs — runs the three clients with the given fakes, counts what breaks and its cost const root = process.argv[2]; const { FAKE } = await import(process.argv[3]); const { fee } = await import(`./${root}/fee-calculation.mjs`); const { route } = await import(`./${root}/route-planner.mjs`); const { deliver } = await import(`./${root}/tracking.mjs`); const jobs = [ ["fee", () => fee(FAKE.fee, { weight: 3.0, address: "06500" })], ["route", () => route(FAKE.route, "34", "06")], ["tracking", () => deliver(FAKE.tracking, "TK-1", "A.Y.")], ]; let broken = 0; for (const [name, f] of jobs) { try { console.log(`${name.padEnd(9)} -> ${JSON.stringify(f())}`); } catch (e) { broken += 1; console.log(`${name.padEnd(9)} -> ${e.message}`); } } const methods = Object.values(FAKE).reduce((t, s) => t + Object.keys(s).length, 0); console.log(`${root} + ${process.argv[3]}: fake methods = ${methods}, broken clients = ${broken}`);
node try-fake.mjs v1 ./fake-minimal.mjs
fee -> carrier contract missing: transferPoints, estimatedDuration, state, received, printLabel, cancel route -> carrier contract missing: zoneFactor, baseFee, state, received, printLabel, cancel tracking -> carrier contract missing: zoneFactor, baseFee, transferPoints, estimatedDuration, printLabel, cancel v1 + ./fake-minimal.mjs: fake methods = 6, broken clients = 3
All three clients broke. For it to work, every fake must carry all eight methods; six of them are padding that will never be called.
// fake-full.mjs — padded fakes that satisfy the wide contract const NOOP = () => undefined; export const FAKE = { fee: { zoneFactor: () => 115, baseFee: () => 6400, transferPoints: NOOP, estimatedDuration: NOOP, state: NOOP, received: NOOP, printLabel: NOOP, cancel: NOOP, }, route: { transferPoints: (s, d) => [s, "41", d], estimatedDuration: (p) => p.length * 6, zoneFactor: NOOP, baseFee: NOOP, state: NOOP, received: NOOP, printLabel: NOOP, cancel: NOOP, }, tracking: { state: () => "delivered", received: NOOP, zoneFactor: NOOP, baseFee: NOOP, transferPoints: NOOP, estimatedDuration: NOOP, printLabel: NOOP, cancel: NOOP, }, };
node try-fake.mjs v1 ./fake-full.mjs
fee -> 7360
route -> {"points":["34","41","06"],"hours":18}
tracking -> "delivered"
v1 + ./fake-full.mjs: fake methods = 24, broken clients = 0
The fake apparatus rose from six methods to twenty-four. The same load shows up in real implementations too: a carrier that provides only tracking service must also fill in all eight methods.
Splitting the Contract by Client
In the second version, the eight methods are split into as many contracts as there are clients. The criterion for the split is not the methods’ subject matter but the client that calls them.
// v2/contracts.mjs — three small, client-specific contracts export const FEE_CONTRACT = ["zoneFactor", "baseFee"]; export const ROUTE_CONTRACT = ["transferPoints", "estimatedDuration"]; export const TRACKING_CONTRACT = ["state", "received"]; export const validate = (name, list) => (c) => { const missing = list.filter((m) => typeof c?.[m] !== "function"); if (missing.length > 0) throw new TypeError(`${name} contract missing: ${missing.join(", ")}`); return c; };
// v2/fee-calculation.mjs — binds only to the fee contract import { validate, FEE_CONTRACT } from "./contracts.mjs"; export const REQUIRED = FEE_CONTRACT; const validateFee = validate("fee", FEE_CONTRACT); export function fee(carrier, shipment) { validateFee(carrier); return Math.round((carrier.baseFee(shipment) * carrier.zoneFactor(shipment.address)) / 100); }
// v2/route-planner.mjs — binds only to the route contract import { validate, ROUTE_CONTRACT } from "./contracts.mjs"; export const REQUIRED = ROUTE_CONTRACT; const validateRoute = validate("route", ROUTE_CONTRACT); export function route(carrier, source, destination) { validateRoute(carrier); const points = carrier.transferPoints(source, destination); return { points, hours: carrier.estimatedDuration(points) }; }
// v2/tracking.mjs — binds only to the tracking contract import { validate, TRACKING_CONTRACT } from "./contracts.mjs"; export const REQUIRED = TRACKING_CONTRACT; const validateTracking = validate("tracking", TRACKING_CONTRACT); export function deliver(carrier, trackingNumber, signature) { validateTracking(carrier); carrier.received(trackingNumber, signature); return carrier.state(trackingNumber); }
A carrier that offers all eight methods satisfies all three contracts; a split interface does not require a split implementation. What changes is only what the client knows.
node interface-measure.mjs v2 fee-calculation.mjs route-planner.mjs tracking.mjs node try-fake.mjs v2 ./fake-minimal.mjs
fee-calculation.mjs used = 2 imposed = 2 surplus = 0
route-planner.mjs used = 2 imposed = 2 surplus = 0
tracking.mjs used = 2 imposed = 2 surplus = 0
v2: used total = 6, imposed total = 6
fee -> 7360
route -> {"points":["34","41","06"],"hours":18}
tracking -> "delivered"
v2 + ./fake-minimal.mjs: fake methods = 6, broken clients = 0
The imposed total dropped from twenty-four to six, and the fake apparatus dropped from twenty-four methods to six.
The Ninth Method
The real difference appears once the interface grows. An insurance premium calculation
is needed, and the new method belongs to the fee side. Both trees are copied and the
change is applied to both. The in-place edit is given a backup extension; GNU and BSD
sed behave the same this way.
cp -r v1 v1-new cp -r v2 v2-new sed -i.y 's/"printLabel", "cancel",/"printLabel", "cancel", "insurancePremium",/' v1-new/carrier.mjs sed -i.y 's/FEE_CONTRACT = \["zoneFactor", "baseFee"\]/FEE_CONTRACT = ["zoneFactor", "baseFee", "insurancePremium"]/' v2-new/contracts.mjs rm -f v1-new/*.y v2-new/*.y node try-fake.mjs v1-new ./fake-full.mjs node try-fake.mjs v2-new ./fake-minimal.mjs
fee -> carrier contract missing: insurancePremium
route -> carrier contract missing: insurancePremium
tracking -> carrier contract missing: insurancePremium
v1-new + ./fake-full.mjs: fake methods = 24, broken clients = 3
fee -> fee contract missing: insurancePremium
route -> {"points":["34","41","06"],"hours":18}
tracking -> "delivered"
v2-new + ./fake-minimal.mjs: fake methods = 6, broken clients = 1
The single method added to the fee side broke all three clients at once in the wide interface; in the split interface it broke only the relevant one. The route planner’s test, which had no knowledge of the insurance premium at all, broke in the first version and did not break in the second.
The Criterion and Limit of Splitting
The correct axis for splitting an interface is the client. Grouping methods by subject matter — “the ones related to calculation,” “the ones related to registration” — does not give the same result; two different clients can use different subsets of the same subject. The criterion is the scan result: which client calls which method names.
Splitting also has a cost. Three contracts mean three validators and three names; and if a client genuinely calls all four of four methods, splitting it in two does not reduce the surplus, it only raises the file count. The principle’s threshold is in the measurement: if the surplus is zero, the gain from splitting is also zero.
Summary
- The interface segregation principle forbids a client from staying dependent on methods it does not use; its measure is the difference between the number of methods called and the number imposed.
- In the wide contract, the three clients’ total calls came to 6 and the imposed dependencies to 24; in the split contracts, both came to 6.
- The first bill for the surplus is in fake dependencies: to satisfy the wide contract, the fake apparatus rose from 6 methods to 24, 18 of them padding that is never called.
- When a ninth method was added to the contract, 3 clients broke in the wide version and 1 client broke in the split version.
- The axis of the split is not the methods’ subject matter but the client that calls them; where the surplus measures zero, splitting only raises the file count.
Next Step
The split contracts stood in the v2/contracts.mjs file, next to the clients. This
placement is not an accident: who defines the contract is a decision separate from how
many pieces the interface is split into. Had the contract been defined next to the
carrier implementation, clients would still bind to a split interface, but they would
still import the side where the implementation lives. The next lesson compares these two
placements by extracting the import direction, and turns which side the abstraction is
defined on into a number.
To keep your progress and take notes, Log in
My notes
Log in to take notes.