Lesson 15 / 19
Drawing Boundaries
Measuring the contract surface between modules: the number of names exposed, the number of fields crossing the boundary, and the number of names the other side must know; comparing a wide and a narrow boundary definition with the same measure, and testing which side breaks when internal naming changes.
Contents
The previous lesson separated the stable rule from the changeable detail, and showed that the rule expects three names from the tariff object: finding the tier, rounding, the minimum fee. These three names are the contract between the two modules, and the contract’s width had not been measured up to that point.
The boundary is the point where two modules talk to each other, and its width is given by a concrete number: how many names it exposes, how many fields the values crossing those names carry, and how many of those the other side must know. This lesson compares two designs doing the same job with these three measures.
Two Designs to Measure
The boundary between the library’s fee module and its carrier-selection module is the subject. The selection module finds the cheapest of three carrier options for a shipment and applies the contracted customer’s discount.
mkdir -p wide narrow
In the first design, the fee module also exposes its tables and its intermediate steps.
// wide/fee-module.mjs — wide boundary: tables, intermediate steps, and the breakdown are all exported export const TIERS = [ { maxWeight: 1, fee: 4990 }, { maxWeight: 5, fee: 8490 }, { maxWeight: 15, fee: 14990 }, { maxWeight: 30, fee: 24990 }, ]; export const MINIMUM_FEE = 3990; export const ROUNDING_STEP = 50; export const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 }; export const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; export const findTier = (weight) => TIERS.find((t) => weight <= t.maxWeight) ?? null; export const findZone = (postalCode) => POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"; export const round = (t) => Math.round(t / ROUNDING_STEP) * ROUNDING_STEP; export function feeBreakdown(shipment) { const tier = findTier(shipment.weight); if (tier === null) throw new RangeError("no weight tier"); const zone = findZone(shipment.postalCode); const factor = ZONE_FACTOR[zone]; const rawAmount = tier.fee * factor; const rounded = round(rawAmount); return { tierMax: tier.maxWeight, tierFee: tier.fee, zone, factor, rawAmount, rounded, minimumApplied: rounded < MINIMUM_FEE, total: Math.max(rounded, MINIMUM_FEE), }; }
To apply the discount, the selection module reaches into the breakdown’s intermediate field and repeats the rounding and minimum-fee steps on its own side.
// wide/carrier-selection.mjs — repeats the other side's internal steps to apply the discount import { feeBreakdown, round, MINIMUM_FEE } from "./fee-module.mjs"; const CARRIERS = [ { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }, ]; export function cheapestCarrier(shipment, discountRate) { const breakdown = feeBreakdown(shipment); return CARRIERS .map((c) => ({ carrier: c.name, amount: Math.max(round(breakdown.rawAmount * c.multiplier * (1 - discountRate)), MINIMUM_FEE), zone: breakdown.zone, })) .sort((a, b) => a.amount - b.amount)[0]; }
In the second design, intent crosses the boundary: the carrier multiplier and the discount rate are given to the fee module as parameters, and the intermediate steps do not leave it.
// narrow/fee-module.mjs — narrow boundary: one name exported, tables stay inside const TIERS = [ { maxWeight: 1, fee: 4990 }, { maxWeight: 5, fee: 8490 }, { maxWeight: 15, fee: 14990 }, { maxWeight: 30, fee: 24990 }, ]; const MINIMUM_FEE = 3990; const ROUNDING_STEP = 50; const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 }; const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; export const feeCalculator = { priceFor(shipment, { speedMultiplier = 1, discountRate = 0 } = {}) { const tier = TIERS.find((t) => shipment.weight <= t.maxWeight); if (tier === undefined) throw new RangeError("no weight tier"); const factor = ZONE_FACTOR[POSTAL_ZONE[shipment.postalCode.slice(0, 2)] ?? "far"]; const rawAmount = tier.fee * factor * speedMultiplier * (1 - discountRate); const rounded = Math.round(rawAmount / ROUNDING_STEP) * ROUNDING_STEP; return { amount: Math.max(rounded, MINIMUM_FEE), currency: "cents" }; }, };
// narrow/carrier-selection.mjs — only intent crosses the boundary, no intermediate step does import { feeCalculator } from "./fee-module.mjs"; const CARRIERS = [ { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }, ]; export function cheapestCarrier(shipment, discountRate) { return CARRIERS .map((c) => { const price = feeCalculator.priceFor(shipment, { speedMultiplier: c.multiplier, discountRate }); return { carrier: c.name, amount: price.amount }; }) .sort((a, b) => a.amount - b.amount)[0]; }
For the comparison to mean anything, the two designs have to produce the same result.
// compare-selection.mjs — do the two boundary definitions give the same result import { cheapestCarrier as wide } from "./wide/carrier-selection.mjs"; import { cheapestCarrier as narrow } from "./narrow/carrier-selection.mjs"; const TRIALS = [ [{ weight: 3, postalCode: "06800" }, 0], [{ weight: 12, postalCode: "65100" }, 0.1], [{ weight: 0.4, postalCode: "34710" }, 0.25], ]; for (const [s, discount] of TRIALS) { const a = wide(s, discount), b = narrow(s, discount); console.log(`${String(s.weight).padStart(4)} kg ${s.postalCode} discount ${discount} ${a.carrier}/${a.amount} ${b.carrier}/${b.amount}`); }
3 kg 06800 discount 0 economy/9750 economy/9750 12 kg 65100 discount 0.1 economy/20650 economy/20650 0.4 kg 34710 discount 0.25 standard/3990 standard/3990
The same carrier and the same amount in all three trials. The difference is not in the behavior, it is in the boundary.
Measuring the Contract Surface
The measuring tool counts three things: the names the module exposes, the fields of the value crossing the boundary, and the names the consumer actually references from those two.
// surface-measure.mjs — a measurement tool that counts a boundary's contract surface import { readFileSync } from "node:fs"; const EXPORTED_NAME = /^export\s+(?:const|let|function|class)\s+(\w+)/gm; export const exportedNames = (path) => [...readFileSync(path, "utf8").matchAll(EXPORTED_NAME)].map((m) => m[1]); export function importedNames(consumer, sourceSuffix) { const pattern = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*"[^"]*${sourceSuffix}"`, "g"); return [...readFileSync(consumer, "utf8").matchAll(pattern)] .flatMap((m) => m[1].split(",").map((s) => s.trim())).filter(Boolean); } export const readFields = (consumer, variable) => [...new Set([...readFileSync(consumer, "utf8") .matchAll(new RegExp(`\\b${variable}\\.(\\w+)`, "g"))].map((m) => m[1]))];
The number of fields crossing the boundary is taken not from the source text but from the call’s actual return value; that way, fields added indirectly are counted too.
// surface-report.mjs — compares two boundary definitions with the same measures import { exportedNames, importedNames, readFields } from "./surface-measure.mjs"; import { feeBreakdown } from "./wide/fee-module.mjs"; import { feeCalculator } from "./narrow/fee-module.mjs"; const SAMPLE = { weight: 3, postalCode: "06800" }; const BOUNDARIES = [ { name: "wide ", module: "wide/fee-module.mjs", consumer: "wide/carrier-selection.mjs", variable: "breakdown", crossing: feeBreakdown(SAMPLE) }, { name: "narrow", module: "narrow/fee-module.mjs", consumer: "narrow/carrier-selection.mjs", variable: "price", crossing: feeCalculator.priceFor(SAMPLE) }, ]; for (const b of BOUNDARIES) { const exported = exportedNames(b.module); const crossing = Object.keys(b.crossing); const imported = importedNames(b.consumer, "fee-module.mjs"); const read = readFields(b.consumer, b.variable); console.log(`${b.name} boundary`); console.log(` exported names ${String(exported.length).padStart(2)} ${exported.join(", ")}`); console.log(` fields crossing ${String(crossing.length).padStart(2)} ${crossing.join(", ")}`); console.log(` consumer imports ${String(imported.length).padStart(2)} ${imported.join(", ")}`); console.log(` consumer reads ${String(read.length).padStart(2)} ${read.join(", ")}`); console.log(` must-know names ${String(imported.length + read.length).padStart(2)}`); }
wide boundary exported names 9 TIERS, MINIMUM_FEE, ROUNDING_STEP, ZONE_FACTOR, POSTAL_ZONE, findTier, findZone, round, feeBreakdown fields crossing 8 tierMax, tierFee, zone, factor, rawAmount, rounded, minimumApplied, total consumer imports 3 feeBreakdown, round, MINIMUM_FEE consumer reads 2 rawAmount, zone must-know names 5 narrow boundary exported names 1 feeCalculator fields crossing 2 amount, currency consumer imports 1 feeCalculator consumer reads 1 amount must-know names 2
Exported names dropped from nine to one, fields crossing the boundary from eight to two, and the names the other side must know from five to two. All three measures point the same way.
The gap between the number of exported names and the number of must-know names also matters. In the wide boundary, nine names are exposed but the consumer references only five; the remaining four are part of the contract though unused, because some other consumer could bind to them. The contract is not the surface actually used, it is the surface that is usable.
What the Boundary Protects
What the measure corresponds to is how free the module’s owner is to rename an internal name.
The command below takes a copy of both designs and renames rawAmount in the fee module to
subtotal. The consumer files are not touched.
cp -r wide wide-v2 && cp -r narrow narrow-v2 sed -i.bak 's/rawAmount/subtotal/g' wide-v2/fee-module.mjs sed -i.bak 's/rawAmount/subtotal/g' narrow-v2/fee-module.mjs grep -c subtotal wide-v2/fee-module.mjs narrow-v2/fee-module.mjs
wide-v2/fee-module.mjs:3 narrow-v2/fee-module.mjs:2
The spelling of the in-place edit flag differs between BSD and GNU sed; the -i.bak form
works on both and leaves a backup file next to it.
// breakage-check.mjs — which side breaks when the module owner renames an internal field import { cheapestCarrier as wideV2 } from "./wide-v2/carrier-selection.mjs"; import { cheapestCarrier as narrowV2 } from "./narrow-v2/carrier-selection.mjs"; const S = { weight: 3, postalCode: "06800" }; console.log("wide-v2 ->", JSON.stringify(wideV2(S, 0))); console.log("narrow-v2 ->", JSON.stringify(narrowV2(S, 0)));
wide-v2 -> {"carrier":"fast","amount":null,"zone":"mid"}
narrow-v2 -> {"carrier":"economy","amount":9750}
A three-line internal rename broke the consumer in the wide boundary — and it broke it without
throwing an error. The unread field came back undefined, the multiplication produced NaN,
and it turned into null once converted to JSON. Under the narrow boundary, the same change
touched nothing, because rawAmount never left the boundary in the first place.
The silent breakage is the real finding here. A wide boundary does not just make change harder — it also hides the consequence of the change.
Where the Boundary Is Drawn
Measures do not prove a design is good, they give common ground for comparison. Three questions are asked when choosing where the boundary goes.
First: does data cross the boundary, or intent? In the wide design, the breakdown object is a snapshot of a computation step; in the narrow design, the parameters are a description of a request. This is the module-scale counterpart of the tell, don’t ask principle from the previous topic.
Second: is the same computation written on both sides? In the wide design, the rounding and minimum-fee steps stood both in the fee module and in the selection module. A rule standing in two places is the most concrete sign that the boundary passes through the wrong spot.
Third: along which axis is the boundary resistant to change? The narrow design is resistant to internal naming and to the order of intermediate steps, but it depends on the assumption that the carrier multiplier is a parameter. Every boundary protects one thing, not everything; what it protects has to be something that can be written down.
Summary
- The contract surface is measured with three numbers: the count of exposed names, the field count of the value crossing the boundary, and the count of names the other side must know.
- In two designs producing the same behavior, these three numbers changed from 9→1, 8→2, and 5→2 respectively.
- The number of exposed names and the number of actually used names are different; the contract is the usable surface, not the used surface.
- A three-line internal rename in the fee module silently broke the consumer under the wide boundary and had no effect under the narrow boundary.
- The boundary’s place is chosen with three questions: does data or intent cross it, does the same computation exist on both sides, and along which axis is the boundary resistant to change.
Next Step
What the narrow boundary protected was the library’s own internal naming. Outside the library stands a class of code the library did not write: tools that route requests, start processes, manage the lifecycle. These present a contract, and that contract is not under the library’s control. The next lesson defines a small outside tool, counts how many files it touches under two layouts, measures how many files in each layout must be fixed when the tool’s contract changes, and shows the cost of the isolation on the same scale.
To keep your progress and take notes, Log in
My notes
Log in to take notes.