Lesson 11 / 19
Encapsulate What Varies
Isolating axes of change: deriving the file sets touched by two changes coming from the price list and output format axes, counting their intersection, and showing that a layout which pulls each axis into its own module drives the intersection to zero.
Contents
In the previous lesson’s separated design, the weight limit constant and the dispatch rule lived in the same file. The two do not change for the same reason: the limit moves when the price list is renewed, the rule moves when organizational policy changes. This difference has a name — an axis of change: each separate source that sends a change request to a module is an axis.
Encapsulate what varies says to gather each axis in its own module. The single responsibility principle measured the same idea by the count of reasons to change; the measure here is different and more direct: the intersection of the file sets touched by two changes coming from two separate axes. If the intersection is not empty, two independent changes are sharing the same text.
Two Axes, Scattered Layout
The measurement uses the two axes that change most often in the pricing library: the price list and the output format. The pricing unit requests the first, accounting reporting requests the second; the two never arrive together. The library has three clients: an HTTP endpoint, batch processing, and a report. In the first layout, all three calculate the price themselves and format the line themselves.
mkdir -p scattered isolated
// records.mjs — sample shipments used by both layouts export const RECORDS = [ { code: "GN-1", weight: 0.8, address: "34100" }, { code: "GN-2", weight: 3.0, address: "06500" }, { code: "GN-3", weight: 12.0, address: "65200" }, ];
// scattered/web.mjs — both the price table and the output format live here const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export function line(shipment) { const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000; const cents = Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100); return `${shipment.code} ${cents} cents`; }
// scattered/batch.mjs — same price table, same format, second copy const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export function lines(shipments) { return shipments.map((s) => { const base = TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000; const cents = Math.round((base * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100); return `${s.code} ${cents} cents`; }); }
// scattered/report.mjs — third copy const TIER = [[1, 3900], [5, 6400], [20, 11800]]; const ZONE = { "34": 100, "06": 115, "65": 140 }; export function totalLine(shipments) { let total = 0; for (const s of shipments) { const base = TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000; total += Math.round((base * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100); } const cents = total; return `TOTAL ${cents} cents`; }
Isolated Layout
In the second layout, each axis stays in its own module: the price list in one module, the output format in one module. The clients take both from outside.
// isolated/price.mjs — first axis: the price list lives only here 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); }
// isolated/format.mjs — second axis: the output format lives only here export const amount = (cents) => `${cents} cents`;
// isolated/web.mjs — takes both axes from outside import { calculateCents } from "./price.mjs"; import { amount } from "./format.mjs"; export const line = (shipment) => `${shipment.code} ${amount(calculateCents(shipment))}`;
// isolated/batch.mjs — takes both axes from outside import { calculateCents } from "./price.mjs"; import { amount } from "./format.mjs"; export const lines = (shipments) => shipments.map((s) => `${s.code} ${amount(calculateCents(s))}`);
// isolated/report.mjs — takes both axes from outside import { calculateCents } from "./price.mjs"; import { amount } from "./format.mjs"; export const totalLine = (shipments) => `TOTAL ${amount(shipments.reduce((t, s) => t + calculateCents(s), 0))}`;
// setup.mjs — shows both layouts produce the same output import { RECORDS } from "./records.mjs"; const root = process.argv[2]; const { line } = await import(`./${root}/web.mjs`); const { lines } = await import(`./${root}/batch.mjs`); const { totalLine } = await import(`./${root}/report.mjs`); console.log(`${root}: ${line(RECORDS[0])} | ${lines(RECORDS).join(" ")} | ${totalLine(RECORDS)}`);
node setup.mjs scattered node setup.mjs isolated
scattered: GN-1 3900 cents | GN-1 3900 cents GN-2 7360 cents GN-3 16520 cents | TOTAL 27780 cents isolated: GN-1 3900 cents | GN-1 3900 cents GN-2 7360 cents GN-3 16520 cents | TOTAL 27780 cents
Two Changes, Two Sets
Axis A is the price list: the mid-tier fee rises from 6400 to 7100, the Ankara zone factor
rises from 115 to 120. Axis B is the output format: amounts will be written as two-decimal
lira instead of cents. The two changes are applied separately to separate copies, and the
file set each one touches is derived. A backup extension is given for the in-place edit; GNU
and BSD sed behave the same way in this form.
for k in scattered isolated; do cp -r $k $k-a cp -r $k $k-b sed -i.y 's/\[5, 6400\]/[5, 7100]/; s/"06": 115/"06": 120/' $k-a/*.mjs sed -i.y 's/\${cents} cents/${(cents \/ 100).toFixed(2)} TL/' $k-b/*.mjs rm -f $k-a/*.y $k-b/*.y node setup.mjs $k-a node setup.mjs $k-b done changed() { diff -rq "$1" "$2" | sed 's#^Files [^/]*/\([^ ]*\) and .*#\1#' | sort; } for k in scattered isolated; do changed $k $k-a > axis-a.txt changed $k $k-b > axis-b.txt echo "$k axis A -> $(tr '\n' ' ' < axis-a.txt)" echo "$k axis B -> $(tr '\n' ' ' < axis-b.txt)" echo "$k intersection = $(comm -12 axis-a.txt axis-b.txt | wc -l | tr -d ' ') $(comm -12 axis-a.txt axis-b.txt | tr '\n' ' ')" echo "$k files that know the zone factor = $(grep -l ZONE $k/*.mjs | wc -l | tr -d ' ')" done
scattered-a: GN-1 3900 cents | GN-1 3900 cents GN-2 8520 cents GN-3 16520 cents | TOTAL 28940 cents scattered-b: GN-1 39.00 TL | GN-1 39.00 TL GN-2 73.60 TL GN-3 165.20 TL | TOTAL 277.80 TL isolated-a: GN-1 3900 cents | GN-1 3900 cents GN-2 8520 cents GN-3 16520 cents | TOTAL 28940 cents isolated-b: GN-1 39.00 TL | GN-1 39.00 TL GN-2 73.60 TL GN-3 165.20 TL | TOTAL 277.80 TL scattered axis A -> batch.mjs report.mjs web.mjs scattered axis B -> batch.mjs report.mjs web.mjs scattered intersection = 3 batch.mjs report.mjs web.mjs scattered files that know the zone factor = 3 isolated axis A -> price.mjs isolated axis B -> format.mjs isolated intersection = 0 isolated files that know the zone factor = 1
Both layouts produce the same result after both changes; the difference is in cost. In the scattered layout, both axes touched the same three files, and the intersection is three. In the isolated layout, the sets are disjoint and the intersection is zero.
The last line shows the reason for the intersection: the zone factor was defined in three files in the scattered layout, in one file in the isolated layout. The source of the intersection is copying information.
What the Intersection Means
The intersection count is the shared measure of three separate risks. First, if two independent changes arrive at the same time, the same files must be edited; if both touch the same lines, merging turns into manual work. Second, the person making the price change also has to read the format code — the reading surface grows out of proportion to the change itself. Third, and most expensive, a change on one axis can break the other axis’s tests; when the intersection is zero, that possibility disappears structurally.
The measure also reads in reverse. If the intersection is zero and a change arriving on one axis still edits two files, that axis is not yet fully isolated. The goal of isolation is not reducing the file count, it is keeping the sets disjoint.
Choosing the Right Axis
Choosing the wrong axis does not reduce the cost, it relocates it. The split above was made into “calculation” and “format”; if the split had instead been made into “web side” and “batch side,” changes from the two axes would still land on the same files, because both the price list and the format are used on both sides.
The way to find the axis is not to look at a single codebase, it is to look at the change requests that arrive. If two requests never arrive together and come from different people, they are two separate axes. If they come from the same person, for the same reason, and together, they are one axis, and splitting them only increases the file count. By this measure, the price list and the output format are separate: one is requested by pricing, the other by accounting reporting.
Summary
- An axis of change is a separate source that sends change requests to a module; the principle says to gather each axis in its own module.
- The measure is the intersection of the file sets touched by two changes coming from two separate axes.
- In the scattered layout, both axes touched the same three files and the intersection came out to 3; in the isolated layout, the sets were disjoint and the intersection was 0.
- The source of the intersection is copying information: the zone factor was defined in 3 files in the scattered layout, in 1 file in the isolated layout.
- The axis is chosen by the source of the incoming change requests, not by the code structure; the wrong axis does not reduce the intersection.
Next Step
In the isolated layout, all three clients import the price.mjs module by name and call the
calculateCents function directly. When the price calculation needs a second implementation
— a list specific to a contracted customer, or a fixed list for testing — the clients’ import
lines have to change. The cost of binding to a concrete module can be measured by the number
of lines touched when the implementation changes. The next lesson counts that cost and
compares it against a version where the client binds to an abstraction instead of a concrete
type.
To keep your progress and take notes, Log in
My notes
Log in to take notes.