Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close