---
title: 'Policy and Detail Separation'
source: 'https://academia.sh/en/courses/design-principles/policy-and-detail-separation'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:16+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
mkdir -p single-file
```

```js
// 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.

```js
// 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.

```js
// 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}`);
  }
}
```

```sh
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.

```sh
mkdir -p split/policy split/detail
```

```js
// 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.

```js
// 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,
};
```

```js
// 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.

```js
// 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}`);
```

```sh
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.
