Lesson 14 / 19
Policy and Detail Separation
Separating the stable rule from the changeable detail: extracting the number of changes per decision from a shipping fee library's change log, counting file touches under a single-file and a split layout, and showing that the split does not change behavior.
Contents
The Coupling and Cohesion topic closed with inversion of control: the higher-level part handing the lower-level part a place to be called back, instead of calling it itself. Every measure up to that point sat on module pairs — the strength of the bond between two modules, how related the names inside one module are to each other, how many objects a call passes through.
Once a codebase grows, the question changes shape. It is no longer which module should connect to which, but where the boundary should be drawn. The answer to that question is hidden in how often pieces of code change: some decisions stay the same for years, others get refreshed once a month. The first is called policy, the second detail, and the boundary is drawn between the two.
The Criterion for the Split
The difference between policy and detail is not importance but frequency of change. In a shipping fee library, the sentence “the weight tier’s fee is multiplied by the zone factor, the result is rounded, and it cannot fall below the minimum fee” is the company’s rule, and it holds as long as the company does not change the rule. Where the tier boundaries sit, what the factors are, and to how many cents the rounding is done change every time the price list is refreshed.
The same split has a directional side too: the detail can know the policy, the policy cannot know the detail. The rule can be written without knowing which tier carries which fee; the table has to know what the rule expects of it.
The Mixed Layout
In the library’s first version, both sides sit in the same file.
mkdir -p single-file
// single-file/fee.mjs — rule, tariff, zone, and rounding all in one file 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 function calculateFee(shipment) { const tier = TIERS.find((t) => shipment.weight <= t.maxWeight); if (tier === undefined) throw new RangeError("no weight tier"); const zone = POSTAL_ZONE[shipment.postalCode.slice(0, 2)] ?? "far"; const raw = tier.fee * ZONE_FACTOR[zone]; return Math.max(Math.round(raw / ROUNDING_STEP) * ROUNDING_STEP, MINIMUM_FEE); }
The file is short and it works. The problem is not in reading it, it is in how often it gets touched. That claim needs counting, not asserting.
Counting the Changes
The following module carries the library’s eleven-month history as a change log: every line is the date of a change and the decisions that change touched.
// change-log.mjs — the library's 24 changes and the decisions each one touched export const DECISIONS = ["rule", "tier-table", "zone-table", "rounding-step", "minimum-amount"]; export const LOG = [ ["2024-01-08", ["tier-table"]], ["2024-01-22", ["zone-table"]], ["2024-02-05", ["tier-table", "minimum-amount"]], ["2024-02-19", ["tier-table"]], ["2024-03-04", ["zone-table"]], ["2024-03-18", ["tier-table"]], ["2024-04-01", ["rule"]], ["2024-04-15", ["tier-table"]], ["2024-04-29", ["zone-table"]], ["2024-05-13", ["tier-table", "minimum-amount"]], ["2024-05-27", ["rounding-step", "zone-table"]], ["2024-06-10", ["tier-table"]], ["2024-06-24", ["zone-table"]], ["2024-07-08", ["tier-table"]], ["2024-07-22", ["rule", "tier-table"]], ["2024-08-05", ["zone-table"]], ["2024-08-19", ["tier-table"]], ["2024-09-02", ["tier-table", "minimum-amount"]], ["2024-09-16", ["tier-table"]], ["2024-09-30", ["zone-table"]], ["2024-10-14", ["tier-table", "rounding-step"]], ["2024-10-28", ["zone-table"]], ["2024-11-11", ["tier-table", "minimum-amount"]], ["2024-11-25", ["rule"]], ];
The log is combined with two layouts that map decisions to files. The first is the single-file layout above, the second is the layout with the split applied.
// count-touches.mjs — changes per decision, and file touches under two layouts import { DECISIONS, LOG } from "./change-log.mjs"; const SINGLE_FILE = Object.fromEntries(DECISIONS.map((k) => [k, "fee.mjs"])); const SPLIT = { "rule": "policy/fee-rule.mjs", "tier-table": "detail/tariff-table.mjs", "rounding-step": "detail/tariff-table.mjs", "minimum-amount": "detail/tariff-table.mjs", "zone-table": "detail/zone-table.mjs", }; function fileTouches(mapping) { const counter = new Map(); for (const [, decisions] of LOG) { for (const file of new Set(decisions.map((k) => mapping[k]))) { counter.set(file, (counter.get(file) ?? 0) + 1); } } return [...counter].sort((a, b) => b[1] - a[1]); } for (const decision of DECISIONS) { const n = LOG.filter(([, k]) => k.includes(decision)).length; console.log(`${decision.padEnd(16)} ${String(n).padStart(2)} changes`); } console.log(`total changes = ${LOG.length}`); for (const [name, mapping] of [["single file", SINGLE_FILE], ["split", SPLIT]]) { console.log(`\n${name} layout`); for (const [file, n] of fileTouches(mapping)) { console.log(` ${file.padEnd(28)} ${String(n).padStart(2)} / ${LOG.length}`); } }
node count-touches.mjs
rule 3 changes tier-table 14 changes zone-table 8 changes rounding-step 2 changes minimum-amount 4 changes total changes = 24 single file layout fee.mjs 24 / 24 split layout detail/tariff-table.mjs 15 / 24 detail/zone-table.mjs 8 / 24 policy/fee-rule.mjs 3 / 24
The first five lines give the criterion for the split: rule was touched in three of
twenty-four changes, tier-table in fourteen. The fourfold gap between them removes any
grounds for keeping the two decisions in the same file.
The lower block shows the result. Under the single-file layout, the file holding the stable rule opens in 24 of 24 changes. Under the split layout, the same rule opens in 3 of 24 changes; the remaining 21 never touch it.
Separating Out the Stable Rule
The split is not just moving tables into another file. The rule must not import the tables — it must take the operations it needs as parameters; otherwise the file is split but the direction of the dependency stays the same.
mkdir -p split/policy split/detail
// split/policy/fee-rule.mjs — the stable rule: the steps themselves, and their order export function calculateFee(shipment, tariff, zones) { const tier = tariff.findTier(shipment.weight); if (tier === null) throw new RangeError("no weight tier"); const factor = zones.factorFor(shipment.postalCode); const rounded = tariff.round(tier.fee * factor); return Math.max(rounded, tariff.minimumFee()); }
There is not a single number in the file. The rule states only the steps themselves and their order: find the tier, multiply by the factor, round, and do not drop below the minimum.
// split/detail/tariff-table.mjs — the changeable detail: tiers, step, minimum amount 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; export const tariff = { findTier: (weight) => TIERS.find((t) => weight <= t.maxWeight) ?? null, round: (amount) => Math.round(amount / ROUNDING_STEP) * ROUNDING_STEP, minimumFee: () => MINIMUM_FEE, };
// split/detail/zone-table.mjs — changeable detail: postal code mapping and factors const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 }; const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" }; export const zones = { zoneFor: (postalCode) => POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far", factorFor(postalCode) { return ZONE_FACTOR[this.zoneFor(postalCode)]; }, };
Both detail files meet the names the policy expects. This is the boundaries-scale counterpart of the direction from the Dependency Inversion lesson: the unstable side looks toward the stable side.
Does the Split Have a Cost
A refactoring is not claimed to preserve behavior, it is tested. Both versions are called with the same shipments.
// equivalent-check.mjs — did the split change behavior: compares two versions on the same shipments import { calculateFee as singleFile } from "./single-file/fee.mjs"; import { calculateFee as split } from "./split/policy/fee-rule.mjs"; import { tariff } from "./split/detail/tariff-table.mjs"; import { zones } from "./split/detail/zone-table.mjs"; const SHIPMENTS = [ { weight: 0.4, postalCode: "34710" }, { weight: 3, postalCode: "06800" }, { weight: 12, postalCode: "65100" }, { weight: 28, postalCode: "35400" }, { weight: 0.9, postalCode: "81600" }, { weight: 5, postalCode: "34100" }, ]; let diverged = 0; for (const s of SHIPMENTS) { const a = singleFile(s), b = split(s, tariff, zones); if (a !== b) diverged += 1; console.log(`${String(s.weight).padStart(4)} kg ${s.postalCode} ${String(a).padStart(6)} ${String(b).padStart(6)}`); } console.log(`diverged results = ${diverged} / ${SHIPMENTS.length}`);
node equivalent-check.mjs
0.4 kg 34710 5000 5000 3 kg 06800 11450 11450 12 kg 65100 27000 27000 28 kg 35400 33750 33750 0.9 kg 81600 9000 9000 5 kg 34100 8500 8500 diverged results = 0 / 6
The result is the same for all six shipments. The only thing the split brought is the file count going from one to three; in exchange, the touch rate on the stable rule went from a hundred percent down to twelve percent.
Policy Level
The split is not binary. A library holds more than one policy level, and the level is measured by distance from the decision to its inputs and outputs. The fee rule sits at the highest level: it does not touch any outside source. The tariff table is one level down: the values come from outside, but the shape is the library’s own shape. The assumption that the first two digits of the postal code give the zone is at the lowest level; if the address format changes, only that rule changes.
The rule is written in one sentence: dependency arrows always point toward the higher policy level. Violating this principle breaks what the next lesson measures — it increases the number of details the stable side is forced to know about the other side.
Summary
- Policy and detail are split not by importance but by frequency of change; the measure is changes per decision, read from the change log.
- Across the library’s twenty-four changes, the rule changed three times, the tier table fourteen; the fourfold gap left no grounds for keeping the two in the same file.
- Under the single-file layout, the stable rule was touched in 24 of 24 changes; after the split, that number dropped to 3.
- The split is not finished by moving tables; the rule must not import the tables, it must take the operations it needs as parameters.
- Across six shipments, the two versions produced the same result: the refactoring did not change behavior, only the distribution of touches.
Next Step
Under the split layout, the rule expects three names from the tariff object: finding the tier, rounding, and the minimum fee. These three names are the contract between the two modules, and they have not been counted so far. How many names does the contract consist of, how many fields cross the boundary, how many fields does the other side have to know? The next lesson writes a tool that measures the contract surface, compares a wide and a narrow boundary definition with the same measure, and shows how the amount of detail leaking to the other side drops when the boundary is narrowed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.