Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close