---
title: 'Design Principles'
source: 'https://academia.sh/en/courses/design-principles'
language: en
updated: '2026-08-23T07:01:15+00:00'
license: 'CC BY-SA 4.0'
lessons: 19
outcomes:
    - 'Explains the SOLID principles with an example and a counterexample'
    - 'Judges module boundaries with coupling and cohesion criteria'
    - 'Turns the direction of dependency toward abstractions'
---

# Design Principles

Measuring whether a design is good by looking at the bond between units rather than inside a unit: establishing the SOLID principles with examples and counterexamples, judging module boundaries with coupling and cohesion criteria, turning the direction of dependency toward abstractions, and drawing boundaries along the policy-detail split.

## SOLID

The measurable counterparts of five design principles: a module's number of reasons to change, the lines changed and added when a new tariff type is added, a class that breaks the subtype contract failing a contract test, the number of methods a client calls against the number an interface imposes, and which side an abstraction is defined on.

### Single Responsibility Principle

Source: https://academia.sh/en/courses/design-principles/single-responsibility-principle

Counting a module's number of reasons to change with an actor table: comparing a single module that carries three different actors' decisions against a design with the decisions separated, and measuring whether an accounting-driven change leaks into the operations report by counting output lines.

The Programming Paradigms course closed by naming the fact that both paradigms described
how individual units should be built, but neither measured when the relationship between
units is healthy. Whether a design is good is understood not by looking inside a unit but
by looking at the bond between units. This course measures that bond.

The smallest question belongs to the first measure: when is a module considered to be
doing too much? The common answer is "if it does too much work," and it is useless,
because there is no threshold for the amount of work. The **single responsibility
principle** moves the question out of line count and onto the party requesting the
change: a module is expected to change for only one **actor's** decision. The measure
here is not the module's size but the number of separate parties that can send it a
request to change.

#### Three Actors, One Module

The example that runs through the course is a shipment fee and routing library. The fee
calculation is the product of three separate decisions: the base fee from the weight
tier, the zone factor derived from the address, and the volume discount applied to a
contracted customer. These decisions belong to different units: operations sets the zone
factors, contract management sets the discount threshold, and accounting sets the
rounding of the invoice amount.

```sh
mkdir -p combined separated
```

```js
// combined/fee.mjs — three actors' decisions in a single module
export const TIERS = [[1, 3900], [5, 6400], [20, 11800]];
export const ZONE = { "34": 100, "06": 115, "65": 140 };
export const VOLUME_THRESHOLD = 3;

export const round = (cents) => Math.round(cents / 100) * 100;
export const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;
export const discountRate = (count) => (count >= VOLUME_THRESHOLD ? 10 : 0);

export function tierFee(weight) {
  for (const [max, fee] of TIERS) if (weight <= max) return fee;
  return TIERS.at(-1)[1] + Math.ceil(weight - TIERS.at(-1)[0]) * 900;
}

export function fee(shipment, count) {
  const raw = (tierFee(shipment.weight) * zoneFactor(shipment.address)) / 100;
  return round((raw * (100 - discountRate(count))) / 100);
}

export function zoneReport(shipments) {
  const totals = new Map();
  for (const s of shipments) {
    const z = s.address.slice(0, 2);
    totals.set(z, (totals.get(z) ?? 0) + fee(s, shipments.length));
  }
  return [...totals].sort().map(([z, c]) => `zone ${z} total ${c} cents`);
}
```

```js
// combined/report.mjs — operations' zone report and accounting's invoice amount
import { zoneReport, fee } from "./fee.mjs";

const SHIPMENTS = [
  { weight: 0.8, address: "34100" }, { weight: 4.2, address: "06500" },
  { weight: 12.0, address: "65200" }, { weight: 2.5, address: "34700" },
];

for (const line of zoneReport(SHIPMENTS)) console.log(line);
console.log("invoice total =", SHIPMENTS.reduce((t, s) => t + fee(s, SHIPMENTS.length), 0), "cents");
```

```sh
node combined/report.mjs
```

```
zone 06 total 6600 cents
zone 34 total 9300 cents
zone 65 total 14900 cents
invoice total = 30800 cents
```

The module is thirty lines and works correctly. A measure that looks at line count sees
no problem here.

#### Counting the Reason to Change

The measure is obtained by writing which symbols each actor would change into a table
and counting which files those symbols are defined in. The script below scans the
modules in a directory and reports, for each module, how many separate actors carry a
decision in it and how many files each actor touches.

```js
// reason-count.mjs — counts how many actors' decisions each module changes for
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

// Symbols each actor will want to change. Requirements are read from this table.
const ACTORS = {
  operations: ["ZONE", "zoneFactor", "zoneReport"],
  accounting: ["round", "invoiceTotal"],
  contracts: ["VOLUME_THRESHOLD", "discountRate"],
};

const isDefined = (text, symbol) =>
  new RegExp(`\\b(?:const|function|let|class)\\s+${symbol}\\b`).test(text);

const root = process.argv[2];
const files = readdirSync(root).filter((f) => f.endsWith(".mjs")).sort();
const actorFiles = new Map(Object.keys(ACTORS).map((a) => [a, []]));
let highest = 0;

for (const f of files) {
  const text = readFileSync(join(root, f), "utf8");
  const found = Object.entries(ACTORS)
    .filter(([, symbols]) => symbols.some((s) => isDefined(text, s)))
    .map(([a]) => a);
  for (const a of found) actorFiles.get(a).push(f);
  highest = Math.max(highest, found.length);
  console.log(`${f.padEnd(18)} reasons to change = ${found.length}  ${found.join(" ")}`);
}
for (const [a, fs] of actorFiles) {
  console.log(`${a.padEnd(10)} -> ${fs.length} file: ${fs.join(" ")}`);
}
console.log(`highest reasons to change = ${highest}`);
```

```sh
node reason-count.mjs combined
```

```
fee.mjs            reasons to change = 3  operations accounting contracts
report.mjs         reasons to change = 0
operations -> 1 file: fee.mjs
accounting -> 1 file: fee.mjs
contracts  -> 1 file: fee.mjs
highest reasons to change = 3
```

All three actors touch a single file, and that file's number of reasons to change is
three. This is what violates the principle: not the file's size, but the fact that three
separate parties will edit the same text.

#### Separating the Decisions

In the separated design, each actor's decision stays in its own module. The tariff core
left in the middle carries no actor's decision directly; it produces the **exact** value
in cents and does not round.

```js
// separated/zone.mjs — operations' decision: zone factors
export const ZONE = { "34": 100, "06": 115, "65": 140 };
export const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;
```

```js
// separated/contracts.mjs — contract management's decision: volume discount
export const VOLUME_THRESHOLD = 3;
export const discountRate = (count) => (count >= VOLUME_THRESHOLD ? 10 : 0);
```

```js
// separated/tariff.mjs — tariff core: exact fee in cents, no rounding
import { zoneFactor } from "./zone.mjs";
import { discountRate } from "./contracts.mjs";

export const TIERS = [[1, 3900], [5, 6400], [20, 11800]];

export function tierFee(weight) {
  for (const [max, fee] of TIERS) if (weight <= max) return fee;
  return TIERS.at(-1)[1] + Math.ceil(weight - TIERS.at(-1)[0]) * 900;
}

export function fee(shipment, count) {
  const raw = (tierFee(shipment.weight) * zoneFactor(shipment.address)) / 100;
  return (raw * (100 - discountRate(count))) / 100;
}
```

```js
// separated/invoice.mjs — accounting's decision: invoice amount rounds to the whole lira
import { fee } from "./tariff.mjs";

export const round = (cents) => Math.round(cents / 100) * 100;
export const invoiceTotal = (shipments) =>
  shipments.reduce((t, s) => t + round(fee(s, shipments.length)), 0);
```

```js
// separated/zone-report.mjs — operations' decision: the report totals the exact fee
import { fee } from "./tariff.mjs";

export function zoneReport(shipments) {
  const totals = new Map();
  for (const s of shipments) {
    const z = s.address.slice(0, 2);
    totals.set(z, (totals.get(z) ?? 0) + fee(s, shipments.length));
  }
  return [...totals].sort().map(([z, c]) => `zone ${z} total ${c} cents`);
}
```

```js
// separated/report.mjs — same two outputs, with the separated design
import { zoneReport } from "./zone-report.mjs";
import { invoiceTotal } from "./invoice.mjs";

const SHIPMENTS = [
  { weight: 0.8, address: "34100" }, { weight: 4.2, address: "06500" },
  { weight: 12.0, address: "65200" }, { weight: 2.5, address: "34700" },
];

for (const line of zoneReport(SHIPMENTS)) console.log(line);
console.log("invoice total =", invoiceTotal(SHIPMENTS), "cents");
```

```sh
node separated/report.mjs
node reason-count.mjs separated
```

```
zone 06 total 6624 cents
zone 34 total 9270 cents
zone 65 total 14868 cents
invoice total = 30800 cents
contracts.mjs      reasons to change = 1  contracts
invoice.mjs        reasons to change = 1  accounting
report.mjs         reasons to change = 0
tariff.mjs         reasons to change = 0
zone-report.mjs    reasons to change = 1  operations
zone.mjs           reasons to change = 1  operations
operations -> 2 file: zone-report.mjs zone.mjs
accounting -> 1 file: invoice.mjs
contracts  -> 1 file: contracts.mjs
highest reasons to change = 1
```

The invoice total is the same in both designs. The zone report changed: the combined
version was totaling rounded amounts, the separated version totals exact cents. The
second measure reads the table from the other direction: files per actor for operations
rose to two. This rise in file count is the price of the principle; its payoff shows up
in the next measure.

#### The Leaking Change

Accounting introduces a new rule: the invoice amount rounds up, not to the nearest lira.
The change is writing `Math.ceil` instead of `Math.round`. The script below applies the
change to both designs and counts how many lines of output changed. The in-place edit is
given a backup extension for portability; GNU and BSD `sed` behave the same this way.

```sh
node combined/report.mjs > combined-before.txt
node separated/report.mjs > separated-before.txt
echo "touched file: $(grep -rl 'Math.round' combined separated | sort | tr '\n' ' ')"
sed -i.bak 's/Math.round/Math.ceil/' $(grep -rl 'Math.round' combined separated)
node combined/report.mjs > combined-after.txt
node separated/report.mjs > separated-after.txt
echo "combined: changed output lines = $(diff combined-before.txt combined-after.txt | grep -c '^<')"
echo "separated: changed output lines = $(diff separated-before.txt separated-after.txt | grep -c '^<')"
diff combined-before.txt combined-after.txt || true
```

```
touched file: combined/fee.mjs separated/invoice.mjs
combined: changed output lines = 3
separated: changed output lines = 1
1,2c1,2
< zone 06 total 6600 cents
< zone 34 total 9300 cents
---
> zone 06 total 6700 cents
> zone 34 total 9400 cents
4c4
< invoice total = 30800 cents
---
> invoice total = 31000 cents
```

A single file was touched in both designs; the difference is not in how many files were
touched but in the result. What accounting wanted changed was a single line, the invoice
total: in the separated design, only that line changed. In the combined design, both
lines of the zone report were also caught, because the report was totaling the product
of the rounding decision. Operations did not request this change and did not know about
it.

This is the measurable promise of the principle: once the number of reasons to change
drops to one, one actor's decision cannot break the output of an actor who does not
share that decision. The price was also measured — operations' file count rose to two.
The principle trades a surprise for a file count.

#### Where the Boundary of Responsibility Lies

Building the table around "function" instead of actor is a common mistake. The zone
factor and the zone report are different jobs, but both are operations' decision; they
are not required to stand in separate files. Conversely, rounding and the zone factor
could be computed on the same line and still belong to different actors; keeping them
together produces a measurable surprise.

The practical way to find the boundary is to look at where past change requests came
from. If the same file was touched at the request of two different units, the number of
reasons to change is greater than one. Setting up a separation ahead of any request is a
cost in the opposite direction: file count rises, and no measured surprise decreases in
exchange.

#### Summary

- The single responsibility principle bounds not a module's size but the number of
  separate actors that can send it a request to change.
- The number of reasons to change is measurable: scanning the actor-symbol table found 3
  in the combined module and at most 1 in the separated design.
- The measure's payoff was seen with a run: the same accounting change altered 3 output
  lines in the combined design and 1 output line in the separated design.
- The cost of the separation was also counted: the number of files operations touches
  rose from one to two. The principle trades a surprise for a file count.
- The boundary is drawn not by similarity of function but by who the change request came
  from.

#### Next Step

In the separated design, each actor changed its own file, but each actor still **edited
an existing file**. Adding a new zone to the zone factor table means editing the object
inside `zone.mjs`. When a request touches not the tier table but the tariff's **type** —
for instance, a second tariff that charges by volume instead of weight — the number of
files to edit rises again. The next lesson measures the number of lines changed against
the number of lines added in both designs when a new tariff type is added, and shows
what a structure means whose expansion requires no change.

### Open–Closed Principle

Source: https://academia.sh/en/courses/design-principles/open-closed-principle

Measuring a design that is open to extension and closed to modification: a new tariff is added both to the version where the same tariff type is chosen through two separate switches and to the version where tariffs write themselves into a registry, and the number of existing files edited, the number of lines added, and whether the fee module changed are compared.

The single responsibility principle separated decisions by actor, but each actor still
edited an existing file. Adding a new zone to the zone table means editing the object
inside `zone.mjs`. As long as this is duplicating a row in the table, it is cheap. When
a request touches the tariff's **type** — a second tariff that charges by volume instead
of weight — the cost turns out differently.

The **open–closed principle** separates these two operations: a module should be **open
to extension** with new behavior and **closed to modification** of its existing text.
The claim is measurable: the same requirement is applied to two designs, and the number
of existing files edited, the number of lines added, and whether the fee module changed
are counted.

#### The Version Where Type Is Chosen with a Switch

In the first version, the tariff type sits as a field on the shipment and is resolved
with `switch` in two separate places: the fee's base and the invoice line's description.

```js
// v1/fee.mjs — tariff type is chosen with two separate switches
export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export function base(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return shipment.weight <= 1 ? 3900 : shipment.weight <= 5 ? 6400 : 11800;
    case "fixed":
      return 4500;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export function description(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return `weight tier (${shipment.weight} kg)`;
    case "fixed":
      return "fixed fee";
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export const fee = (s) => Math.round((base(s) * zoneFactor(s.address)) / 100);
```

```js
// v1/main.mjs — fee lines for sample shipments
import { fee, description } from "./fee.mjs";

export const SAMPLE = [
  { tariff: "weight", weight: 0.8, address: "34100", width: 20, length: 20, height: 15 },
  { tariff: "fixed", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
```

```sh
node v1/main.mjs
```

```
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
```

In this design, the tariff type is **data**, and the behavior is the code that resolves
the type. The two `switch` statements each enumerate the same list separately; a third
usage site would mean a third list.

#### The Version Where Type Moves into a Registry

In the second version, a tariff is not a type tag but an object that carries behavior.
Objects write themselves into a shared registry; the fee module asks the registry and
never knows the type names.

```js
// v2/registry.mjs — tariff registry: new tariffs register themselves in this table
const TARIFFS = new Map();

export function register(tariff) {
  TARIFFS.set(tariff.name, tariff);
}

export function tariff(name) {
  const t = TARIFFS.get(name);
  if (t === undefined) throw new RangeError(`unknown tariff: ${name}`);
  return t;
}
```

```js
// v2/tariffs/weight.mjs — weight tier tariff
import { register } from "../registry.mjs";

register({
  name: "weight",
  base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800),
  description: (s) => `weight tier (${s.weight} kg)`,
});
```

```js
// v2/tariffs/fixed.mjs — fixed fee tariff
import { register } from "../registry.mjs";

register({ name: "fixed", base: () => 4500, description: () => "fixed fee" });
```

```js
// v2/fee.mjs — fee calculation is blind to tariff type; the choice comes from the registry
import { tariff } from "./registry.mjs";

export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export const fee = (s) => Math.round((tariff(s.tariff).base(s) * zoneFactor(s.address)) / 100);
export const description = (s) => tariff(s.tariff).description(s);
```

```js
// v2/main.mjs — composition root: only this file knows which tariffs are in use
import "./tariffs/weight.mjs";
import "./tariffs/fixed.mjs";
import { fee, description } from "./fee.mjs";

export const SAMPLE = [
  { tariff: "weight", weight: 0.8, address: "34100", width: 20, length: 20, height: 15 },
  { tariff: "fixed", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
```

```sh
node v2/main.mjs
```

```
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
```

The same output. The difference is not in behavior but in where a new type gets
written. The list of tariff names lives in a single place, and that place is the
composition root — the module introduced in the Layer Responsibilities lesson, the one
that alone wires the parts together.

#### Measuring the Extension

The new requirement is a volume tariff: the volumetric weight calculation is derived from width,
length, and height, and the base fee rises per volumetric kilogram. Both trees are copied and the
change is applied to both.

```sh
cp -r v1 v1-new
cp -r v2 v2-new
```

```js
// v1-new/fee.mjs — volume tariff added: both switches were edited
export const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;
const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000);

export function base(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return shipment.weight <= 1 ? 3900 : shipment.weight <= 5 ? 6400 : 11800;
    case "fixed":
      return 4500;
    case "volume":
      return 2600 + volumetricWeight(shipment) * 700;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export function description(shipment) {
  switch (shipment.tariff) {
    case "weight":
      return `weight tier (${shipment.weight} kg)`;
    case "fixed":
      return "fixed fee";
    case "volume":
      return `volume tariff (${volumetricWeight(shipment)} volumetric kg)`;
    default:
      throw new RangeError(`unknown tariff: ${shipment.tariff}`);
  }
}

export const fee = (s) => Math.round((base(s) * zoneFactor(s.address)) / 100);
```

```js
// v2-new/tariffs/volume.mjs — volume tariff: the fee module was not edited
import { register } from "../registry.mjs";

const volumetricWeight = (s) => Math.ceil((s.width * s.length * s.height) / 5000);

register({
  name: "volume",
  base: (s) => 2600 + volumetricWeight(s) * 700,
  description: (s) => `volume tariff (${volumetricWeight(s)} volumetric kg)`,
});
```

The remaining two edits in both trees belong to the sample data and the registration
list; the script below applies them, runs both versions, and counts the difference. The
in-place edit is given a backup extension; GNU and BSD `sed` behave the same this way.

```sh
SAMPLE='  { tariff: "volume", weight: 3.0, address: "06500", width: 30, length: 40, height: 50 },'
sed -i.y "s#^];#$SAMPLE\n];#" v1-new/main.mjs v2-new/main.mjs
sed -i.y 's#^import "./tariffs/fixed.mjs";#&\nimport "./tariffs/volume.mjs";#' v2-new/main.mjs
rm -f v1-new/*.y v2-new/*.y
node v1-new/main.mjs
node v2-new/main.mjs

measure() {
  echo "$1 -> $2"
  echo "  existing files edited = $(diff -rq $1 $2 | grep -c '^Files ')"
  echo "  new files             = $(diff -rq $1 $2 | grep -c '^Only in ')"
  echo "  lines added           = $(diff -rN $1 $2 | grep '^>' | grep -cvE '^> *(//|$)')"
  echo "  fee module changed    = $(diff -qI '^//' $1/fee.mjs $2/fee.mjs > /dev/null && echo no || echo yes)"
}
measure v1 v1-new
measure v2 v2-new
```

```
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
12650 cents  volume tariff (12 volumetric kg)
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
12650 cents  volume tariff (12 volumetric kg)
v1 -> v1-new
  existing files edited = 2
  new files             = 0
  lines added           = 6
  fee module changed    = yes
v2 -> v2-new
  existing files edited = 1
  new files             = 1
  lines added           = 9
  fee module changed    = no
```

The results are the same; the distribution of cost is not. The second version added
more lines (9 to 6), because the new tariff stands as a complete object in its own file.
Against that, the number of existing files edited dropped from two to one, and that one
file is the composition root. The decisive line is the last one: the module that
computes the fee changed in the first version and did not change in the second.

The comparison separates two kinds of cost. An added line is cheap: new text cannot
break an existing behavior. An edited line is expensive: the `base` and `description`
functions' two tariffs that previously worked correctly must be reviewed again when the
third tariff is added. The principle's measure is not "how many lines were written" but
"how many lines had to be re-verified."

#### The Limit of Closedness

Closedness cannot be universal. The second version is closed to a new **tariff type**,
but it is not closed to the tariff contract itself. Adding a third method to the tariff
object — a maximum volumetric weight limit, for instance — requires editing every tariff file in the
registry; the cost grows directly proportional to the number of tariffs.

The choice therefore rests on a prediction of which axis will change. If the number of
tariffs is growing, the second version wins; if the tariff contract is moving and the
number of types stays at two or three, `switch` is cheaper, because the registry
apparatus is itself a cost. The principle does not say to build an abstraction
everywhere; it says to measure which axis change arrives on and close against that axis.
The source of the measure is not prediction, either, but past extension requests.

#### Summary

- The open–closed principle distinguishes extension (adding new text) from modification
  (editing existing text), and it bounds the second.
- When the same requirement was applied to both designs, the `switch` version edited 2
  existing files and changed the fee module; the registry version edited 1 file, added 1
  new file, and did not touch the fee module.
- The registry version added more lines (9 to 6); the principle's measure is not the
  number of lines written but the number of lines forced to be re-verified.
- Closedness holds on a single axis: the registry version is closed to a new tariff
  type, not to a change in the tariff contract.
- Which axis to close against is determined not by prediction but by past extension
  requests.

#### Next Step

In the registry version, the fee module worked without asking which tariff it received,
because every tariff satisfies the same contract. This rests on the assumption that the
contract is genuinely satisfied. If a new tariff defines the `base` method but returns a
negative value, or works only for certain shipments and throws for the rest, the fee
module breaks without changing. The next lesson defines what a subtype contract means,
writes a tariff that breaks the contract, and runs a contract test that operates through
the supertype with two tariffs to show it breaks in the violating version.

### Liskov Substitution Principle

Source: https://academia.sh/en/courses/design-principles/liskov-substitution-principle

Defining the subtype contract through preconditions and postconditions: running a contract test that operates through the supertype against every tariff in the registry, two tariffs that strengthen the precondition and weaken the postcondition breaking the test, and satisfying the same requirement without breaking the contract.

The open–closed principle moved the tariff type into a registry; the fee module worked
without asking which tariff it received. This rests on the assumption that every object
inside the registry genuinely satisfies the same contract. The assumption was not
written down: the registry only saw that the names `base` and `description` existed,
not what these functions accepted or returned.

The **Liskov substitution principle** turns that assumption into a rule: wherever a
supertype is expected, one of its subtypes must be usable in its place without breaking
the program's correctness. The **subtype** relationship defined in the TypeScript course
is a structural compatibility the type checker sees; this principle wants the same
relationship at the level of behavior. The rule comes down to two clauses: a subtype
cannot **demand less than** what the supertype accepts (it cannot strengthen the
precondition), and it cannot **give less than** what the supertype promises (it cannot
weaken the postcondition).

#### Writing the Contract

The contract must stand beside the supertype, not as a comment but in a testable form.
The first step is defining the supertype and writing the expectation explicitly.

```js
// tariff.mjs — supertype and the contract that must be satisfied
export class Tariff {
  constructor(name) {
    this.name = name;
  }

  // Precondition: shipment carries weight, width, length, height, and address fields.
  // Postcondition: returns a positive integer (cents), the same value for the same shipment.
  base(shipment) {
    throw new Error(`${this.name}: base not implemented (${typeof shipment})`);
  }

  // Postcondition: returns a non-empty string.
  description(shipment) {
    throw new Error(`${this.name}: description not implemented (${typeof shipment})`);
  }
}
```

```js
// tariffs.mjs — three subtypes that satisfy the contract
import { Tariff } from "./tariff.mjs";

export class WeightTariff extends Tariff {
  constructor() { super("weight"); }
  base(s) { return s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800; }
  description(s) { return `weight tier (${s.weight} kg)`; }
}

export class FixedTariff extends Tariff {
  constructor() { super("fixed"); }
  base() { return 4500; }
  description() { return "fixed fee"; }
}

export class VolumeTariff extends Tariff {
  constructor() { super("volume"); }
  volumetricWeight(s) { return Math.ceil((s.width * s.length * s.height) / 5000); }
  base(s) { return 2600 + this.volumetricWeight(s) * 700; }
  description(s) { return `volume tariff (${this.volumetricWeight(s)} volumetric kg)`; }
}
```

The registry does the same job as in the previous lesson; the only difference is that it
holds instances instead of objects.

```js
// registry.mjs — tariff registry: access by name and the list of registered tariffs
const TABLE = new Map();

export function register(t) {
  TABLE.set(t.name, t);
}

export function tariff(name) {
  const t = TABLE.get(name);
  if (t === undefined) throw new RangeError(`unknown tariff: ${name}`);
  return t;
}

export const all = () => [...TABLE.values()];
```

```js
// setup.mjs — registers the three tariffs that satisfy the contract
import { register } from "./registry.mjs";
import { WeightTariff, FixedTariff, VolumeTariff } from "./tariffs.mjs";

register(new WeightTariff());
register(new FixedTariff());
register(new VolumeTariff());
```

```js
// fee.mjs — calculation: does not know the tariff's type, only trusts the contract
import { tariff } from "./registry.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export const fee = (s) => Math.round((tariff(s.tariff).base(s) * zoneFactor(s.address)) / 100);
export const description = (s) => tariff(s.tariff).description(s);
```

```js
// main.mjs — composition root: loads the setup, writes the fee lines
import "./setup.mjs";
import { fee, description } from "./fee.mjs";

const SAMPLE = [
  { tariff: "weight", weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { tariff: "fixed", weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
  { tariff: "volume", weight: 12.0, width: 60, length: 40, height: 40, address: "65200" },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
```

```sh
node main.mjs
```

```
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
23240 cents  volume tariff (20 volumetric kg)
```

#### A Test That Operates Through the Supertype

The contract test does not know the subtypes' names. It takes each tariff in the
registry in turn and checks the supertype's two clauses. Its measure is a single number:
the count of broken tests.

```js
// contract-test.mjs — operates through the supertype, does not know the subtype names
import test from "node:test";
import assert from "node:assert/strict";
import { Tariff } from "./tariff.mjs";
import { all } from "./registry.mjs";

// Shipments the supertype accepts. No subtype may reject these.
const SHIPMENTS = [
  { weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
  { weight: 12.0, width: 60, length: 40, height: 40, address: "65200" },
];

const attempt = (f) => {
  try { return { value: f(), error: null }; } catch (e) { return { value: null, error: e.message }; }
};

await import(process.env.SETUP ?? "./setup.mjs");

for (const t of all()) {
  test(`${t.name}: subtype of the supertype`, () => {
    assert.ok(t instanceof Tariff, `${t.name} does not derive from the supertype`);
  });
  test(`${t.name}: precondition not strengthened`, () => {
    for (const s of SHIPMENTS) {
      const r = attempt(() => t.base(s));
      assert.ok(r.error === null, `${t.name} rejected shipment ${s.address}: ${r.error}`);
    }
  });
  test(`${t.name}: postcondition not weakened`, () => {
    for (const s of SHIPMENTS) {
      const r = attempt(() => t.base(s));
      assert.ok(Number.isInteger(r.value) && r.value > 0,
        `${t.name} base for ${s.address} = ${r.error ?? r.value}`);
      assert.ok(attempt(() => t.description(s)).value?.length > 0, `${t.name} description for ${s.address} is empty`);
    }
  });
}
```

The setup module is read from an environment variable, so the same test can be run with
different registries. Because the report format includes timing and file-path lines, the
duration and location lines have been filtered out.

```sh
node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - weight: subtype of the supertype
ok 2 - weight: precondition not strengthened
ok 3 - weight: postcondition not weakened
ok 4 - fixed: subtype of the supertype
ok 5 - fixed: precondition not strengthened
ok 6 - fixed: postcondition not weakened
ok 7 - volume: subtype of the supertype
ok 8 - volume: precondition not strengthened
ok 9 - volume: postcondition not weakened
# tests 9
# pass 9
# fail 0
```

#### Two Subtypes That Break the Contract

Two new requirements arrive: a separate tariff for international shipments and a free
campaign for light shipments. Both are written by deriving from the supertype, in the
first form that comes to mind.

```js
// breaking-tariffs.mjs — two subtypes that break the contract in two different places
import { Tariff } from "./tariff.mjs";

// Strengthens the precondition: the supertype accepts every shipment, this subtype requires `country`.
export class InternationalTariff extends Tariff {
  constructor() { super("international"); }
  base(s) {
    if (s.country === undefined) throw new RangeError("international: country field required");
    return 9800 + Math.ceil(s.weight) * 1200;
  }
  description(s) { return `international (${s.country})`; }
}

// Weakens the postcondition: the supertype promises a positive integer, this subtype returns 0.
export class CampaignTariff extends Tariff {
  constructor() { super("campaign"); }
  base(s) { return s.weight <= 1 ? 0 : 3900; }
  description() { return "campaign"; }
}
```

```js
// setup-broken.mjs — same three tariffs, plus two that break the contract
import "./setup.mjs";
import { register } from "./registry.mjs";
import { InternationalTariff, CampaignTariff } from "./breaking-tariffs.mjs";

register(new InternationalTariff());
register(new CampaignTariff());
```

The same test, unchanged, is run with the second setup.

```sh
SETUP=./setup-broken.mjs node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
```

```
not ok 11 - international: precondition not strengthened
  error: 'international rejected shipment 34100: international: country field required'
not ok 12 - international: postcondition not weakened
  error: 'international base for 34100 = international: country field required'
not ok 15 - campaign: postcondition not weakened
  error: 'campaign base for 34100 = 0'
# tests 15
# pass 12
# fail 3
```

The number of tests rose from nine to fifteen, and three broke. Both tariffs write
`extends Tariff` and define both methods; structural compatibility is fine. What breaks
is the behavior contract.

#### What the Violation Looks Like in a Run

A broken test is not an abstract warning. The result becomes visible when the fee
module, unchanged, is run through the same registry.

```js
// main-broken.mjs — fee module unchanged, runs with the breaking tariffs
import "./setup-broken.mjs";
import { fee, description } from "./fee.mjs";

const SAMPLE = [
  { tariff: "campaign", weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { tariff: "international", weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
];

for (const s of SAMPLE) {
  try {
    console.log(`${s.tariff.padEnd(13)} -> ${fee(s)} cents  ${description(s)}`);
  } catch (e) {
    console.log(`${s.tariff.padEnd(13)} -> ${e.constructor.name}: ${e.message}`);
  }
}
```

```sh
node main-broken.mjs
```

```
campaign      -> 0 cents  campaign
international -> RangeError: international: country field required
```

The two results show two different forms of violation. The strengthened precondition
turned into an error: the fee module was rejected by the very data it sent. The weakened
postcondition is quieter — a zero-cent fee was produced and no error appeared anywhere.
The second form is dangerous, because only someone who looks at the invoice notices it.

#### The Same Requirement Without Breaking the Contract

Both requirements can be satisfied; what breaks the contract is not the requirements
themselves but how they are written. The precondition is extended by turning the missing
field into a defined default instead of an error. The postcondition is preserved by
bounding the discount with a minimum fee.

```js
// compliant-tariffs.mjs — the same two requirements, without breaking the contract
import { Tariff } from "./tariff.mjs";

// Precondition not extended: if there is no country field, the domestic value derived from the address is used.
export class InternationalTariff extends Tariff {
  constructor() { super("international"); }
  base(s) { return 9800 + Math.ceil(s.weight) * 1200; }
  description(s) { return `international (${s.country ?? "domestic"})`; }
}

// Postcondition preserved: the discount cannot drop below the minimum fee.
export class CampaignTariff extends Tariff {
  constructor() { super("campaign"); }
  base(s) { return Math.max(1500, s.weight <= 1 ? 0 : 3900); }
  description() { return "campaign"; }
}
```

```js
// setup-compliant.mjs — three tariffs, plus two that satisfy the contract
import "./setup.mjs";
import { register } from "./registry.mjs";
import { InternationalTariff, CampaignTariff } from "./compliant-tariffs.mjs";

register(new InternationalTariff());
register(new CampaignTariff());
```

```sh
SETUP=./setup-compliant.mjs node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
```

```
# tests 15
# pass 15
# fail 0
```

All fifteen tests pass. There is also a third option, and sometimes it is the correct
one: if a requirement genuinely wants more than what the supertype accepts, that
requirement is not a subtype. If international pricing is meaningless without country
information, it should not register into the `Tariff` registry; it should be a separate
type with its own contract. What the principle says is not "everything should derive
from the same supertype" but "everything that derives should keep the supertype's word."

#### The Scope of the Contract

Precondition and postcondition are not the whole contract. The invariants the supertype
preserves must also hold in the subtype: if `base` always returns the same value for the
same shipment, a subtype cannot add a random factor. This is why the test's three
clauses use the same shipment list; if a subtype gives a different result on a second
call, the third clause breaks.

What happens when the contract is not written down was also measured: the registry
accepted all five tariffs, the fee module ran both, and one silently produced the wrong
fee. Once the contract was written, the same registry gave three broken tests. The
difference is not in the code itself but in where the error becomes visible.

#### Summary

- The Liskov substitution principle wants behavioral compatibility, not structural
  compatibility: wherever a supertype is expected, a subtype must be usable without
  breaking the program's correctness.
- The rule has two clauses: a subtype cannot strengthen the precondition and cannot
  weaken the postcondition.
- The contract test is written through the supertype and does not know the subtypes'
  names; it runs every type in the registry through the same criterion.
- With three compliant tariffs, all 9 tests passed; when two tariffs that strengthen the
  precondition and weaken the postcondition were added, 3 of 15 tests broke.
- The strengthened precondition turned into an error in the run, and the weakened
  postcondition silently produced a zero-cent fee; the second form is invisible without a
  contract test.

#### Next Step

The contract test ran through two methods, and every tariff genuinely used both. This
convenience disappears once the tariff interface grows. When carrier selection, route
planning, and delivery status are gathered into a single carrier interface, a client
that cares only about the fee calculation also becomes bound to methods it does not use;
writing a fake carrier requires filling in all of those methods. The next lesson
measures the number of methods a client genuinely calls against the number of methods
the interface imposes, and counts the cost a bloated interface adds to the
fake-dependency apparatus.

### Interface Segregation Principle

Source: https://academia.sh/en/courses/design-principles/interface-segregation-principle

Counting the cost a bloated carrier interface imposes on clients: scanning and comparing the number of methods each client genuinely calls against the number of methods the interface imposes, measuring the total method count in fake dependencies, and finding with a run how many fakes break when a ninth method is added to the interface.

The contract test ran through two methods, and every tariff genuinely used both. This
balance breaks once the discussion moves to the carrier side: carrier selection, route
planning, and delivery tracking are gathered onto the same object. Once a single carrier
interface is built, a client that only calculates the fee also becomes bound to the
route and tracking methods.

The **interface segregation principle** forbids this: no client should be forced to
depend on methods it does not use. The principle's measure is a comparison of two
separate numbers — the number of methods a client **calls** and the number of methods
the interface **imposes**.

#### A Single, Wide Contract

In the first version, all eight methods are gathered into a single contract. The
contract is written as a list and a validator that checks it; clients call the validator
to make sure the object they received satisfies the contract.

```js
// v1/carrier.mjs — single, wide carrier contract
export const CARRIER_CONTRACT = [
  "zoneFactor", "baseFee", "transferPoints", "estimatedDuration",
  "state", "received", "printLabel", "cancel",
];

export function validateContract(c) {
  const missing = CARRIER_CONTRACT.filter((name) => typeof c?.[name] !== "function");
  if (missing.length > 0) throw new TypeError(`carrier contract missing: ${missing.join(", ")}`);
  return c;
}
```

Three clients bind to this contract. Each exports the contract it binds to under the
name `REQUIRED`; the measurement script will read this name.

```js
// v1/fee-calculation.mjs — fee client: calls two methods, requires eight
import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs";

export const REQUIRED = CARRIER_CONTRACT;

export function fee(carrier, shipment) {
  validateContract(carrier);
  return Math.round((carrier.baseFee(shipment) * carrier.zoneFactor(shipment.address)) / 100);
}
```

```js
// v1/route-planner.mjs — route client
import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs";

export const REQUIRED = CARRIER_CONTRACT;

export function route(carrier, source, destination) {
  validateContract(carrier);
  const points = carrier.transferPoints(source, destination);
  return { points, hours: carrier.estimatedDuration(points) };
}
```

```js
// v1/tracking.mjs — tracking client
import { validateContract, CARRIER_CONTRACT } from "./carrier.mjs";

export const REQUIRED = CARRIER_CONTRACT;

export function deliver(carrier, trackingNumber, signature) {
  validateContract(carrier);
  carrier.received(trackingNumber, signature);
  return carrier.state(trackingNumber);
}
```

#### Measuring the Two Numbers

The measurement script scans each client's text for calls that begin with `carrier.`
and counts the distinct method names it finds. The imposed count is the length of the
contract the client binds to.

```js
// interface-measure.mjs — number of methods a client calls vs. what the interface imposes
import { readFileSync } from "node:fs";

const CALL = /\bcarrier\.(\w+)\(/g;
const root = process.argv[2];
const clients = process.argv.slice(3);
let usedTotal = 0;
let imposedTotal = 0;

for (const name of clients) {
  const text = readFileSync(`${root}/${name}`, "utf8");
  const used = new Set([...text.matchAll(CALL)].map((m) => m[1]));
  const { REQUIRED } = await import(`./${root}/${name}`);
  usedTotal += used.size;
  imposedTotal += REQUIRED.length;
  console.log(`${name.padEnd(20)} used = ${used.size}  imposed = ${REQUIRED.length}` +
    `  surplus = ${REQUIRED.length - used.size}`);
}
console.log(`${root}: used total = ${usedTotal}, imposed total = ${imposedTotal}`);
```

```sh
node interface-measure.mjs v1 fee-calculation.mjs route-planner.mjs tracking.mjs
```

```
fee-calculation.mjs  used = 2  imposed = 8  surplus = 6
route-planner.mjs    used = 2  imposed = 8  surplus = 6
tracking.mjs         used = 2  imposed = 8  surplus = 6
v1: used total = 6, imposed total = 24
```

Six calls correspond to twenty-four dependencies. The surplus is not conceptual: each
client is bound to the name and existence of six methods it does not use.

#### The Cost of the Fake Dependency

The first concrete bill for the surplus arrives in fake dependencies. For each client,
fakes are written that carry only the methods that client calls.

```js
// fake-minimal.mjs — each fake carries only the methods its own client calls
export const FAKE = {
  fee: { zoneFactor: () => 115, baseFee: () => 6400 },
  route: { transferPoints: (s, d) => [s, "41", d], estimatedDuration: (p) => p.length * 6 },
  tracking: { state: () => "delivered", received: () => undefined },
};
```

```js
// try-fake.mjs — runs the three clients with the given fakes, counts what breaks and its cost
const root = process.argv[2];
const { FAKE } = await import(process.argv[3]);
const { fee } = await import(`./${root}/fee-calculation.mjs`);
const { route } = await import(`./${root}/route-planner.mjs`);
const { deliver } = await import(`./${root}/tracking.mjs`);

const jobs = [
  ["fee", () => fee(FAKE.fee, { weight: 3.0, address: "06500" })],
  ["route", () => route(FAKE.route, "34", "06")],
  ["tracking", () => deliver(FAKE.tracking, "TK-1", "A.Y.")],
];

let broken = 0;
for (const [name, f] of jobs) {
  try {
    console.log(`${name.padEnd(9)} -> ${JSON.stringify(f())}`);
  } catch (e) {
    broken += 1;
    console.log(`${name.padEnd(9)} -> ${e.message}`);
  }
}
const methods = Object.values(FAKE).reduce((t, s) => t + Object.keys(s).length, 0);
console.log(`${root} + ${process.argv[3]}: fake methods = ${methods}, broken clients = ${broken}`);
```

```sh
node try-fake.mjs v1 ./fake-minimal.mjs
```

```
fee       -> carrier contract missing: transferPoints, estimatedDuration, state, received, printLabel, cancel
route     -> carrier contract missing: zoneFactor, baseFee, state, received, printLabel, cancel
tracking  -> carrier contract missing: zoneFactor, baseFee, transferPoints, estimatedDuration, printLabel, cancel
v1 + ./fake-minimal.mjs: fake methods = 6, broken clients = 3
```

All three clients broke. For it to work, every fake must carry all eight methods; six
of them are padding that will never be called.

```js
// fake-full.mjs — padded fakes that satisfy the wide contract
const NOOP = () => undefined;

export const FAKE = {
  fee: {
    zoneFactor: () => 115, baseFee: () => 6400,
    transferPoints: NOOP, estimatedDuration: NOOP, state: NOOP, received: NOOP,
    printLabel: NOOP, cancel: NOOP,
  },
  route: {
    transferPoints: (s, d) => [s, "41", d], estimatedDuration: (p) => p.length * 6,
    zoneFactor: NOOP, baseFee: NOOP, state: NOOP, received: NOOP,
    printLabel: NOOP, cancel: NOOP,
  },
  tracking: {
    state: () => "delivered", received: NOOP,
    zoneFactor: NOOP, baseFee: NOOP, transferPoints: NOOP, estimatedDuration: NOOP,
    printLabel: NOOP, cancel: NOOP,
  },
};
```

```sh
node try-fake.mjs v1 ./fake-full.mjs
```

```
fee       -> 7360
route     -> {"points":["34","41","06"],"hours":18}
tracking  -> "delivered"
v1 + ./fake-full.mjs: fake methods = 24, broken clients = 0
```

The fake apparatus rose from six methods to twenty-four. The same load shows up in real
implementations too: a carrier that provides only tracking service must also fill in all
eight methods.

#### Splitting the Contract by Client

In the second version, the eight methods are split into as many contracts as there are
clients. The criterion for the split is not the methods' subject matter but the client
that calls them.

```js
// v2/contracts.mjs — three small, client-specific contracts
export const FEE_CONTRACT = ["zoneFactor", "baseFee"];
export const ROUTE_CONTRACT = ["transferPoints", "estimatedDuration"];
export const TRACKING_CONTRACT = ["state", "received"];

export const validate = (name, list) => (c) => {
  const missing = list.filter((m) => typeof c?.[m] !== "function");
  if (missing.length > 0) throw new TypeError(`${name} contract missing: ${missing.join(", ")}`);
  return c;
};
```

```js
// v2/fee-calculation.mjs — binds only to the fee contract
import { validate, FEE_CONTRACT } from "./contracts.mjs";

export const REQUIRED = FEE_CONTRACT;
const validateFee = validate("fee", FEE_CONTRACT);

export function fee(carrier, shipment) {
  validateFee(carrier);
  return Math.round((carrier.baseFee(shipment) * carrier.zoneFactor(shipment.address)) / 100);
}
```

```js
// v2/route-planner.mjs — binds only to the route contract
import { validate, ROUTE_CONTRACT } from "./contracts.mjs";

export const REQUIRED = ROUTE_CONTRACT;
const validateRoute = validate("route", ROUTE_CONTRACT);

export function route(carrier, source, destination) {
  validateRoute(carrier);
  const points = carrier.transferPoints(source, destination);
  return { points, hours: carrier.estimatedDuration(points) };
}
```

```js
// v2/tracking.mjs — binds only to the tracking contract
import { validate, TRACKING_CONTRACT } from "./contracts.mjs";

export const REQUIRED = TRACKING_CONTRACT;
const validateTracking = validate("tracking", TRACKING_CONTRACT);

export function deliver(carrier, trackingNumber, signature) {
  validateTracking(carrier);
  carrier.received(trackingNumber, signature);
  return carrier.state(trackingNumber);
}
```

A carrier that offers all eight methods satisfies all three contracts; a split interface
does not require a split implementation. What changes is only what the client knows.

```sh
node interface-measure.mjs v2 fee-calculation.mjs route-planner.mjs tracking.mjs
node try-fake.mjs v2 ./fake-minimal.mjs
```

```
fee-calculation.mjs  used = 2  imposed = 2  surplus = 0
route-planner.mjs    used = 2  imposed = 2  surplus = 0
tracking.mjs         used = 2  imposed = 2  surplus = 0
v2: used total = 6, imposed total = 6
fee       -> 7360
route     -> {"points":["34","41","06"],"hours":18}
tracking  -> "delivered"
v2 + ./fake-minimal.mjs: fake methods = 6, broken clients = 0
```

The imposed total dropped from twenty-four to six, and the fake apparatus dropped from
twenty-four methods to six.

#### The Ninth Method

The real difference appears once the interface grows. An insurance premium calculation
is needed, and the new method belongs to the fee side. Both trees are copied and the
change is applied to both. The in-place edit is given a backup extension; GNU and BSD
`sed` behave the same this way.

```sh
cp -r v1 v1-new
cp -r v2 v2-new
sed -i.y 's/"printLabel", "cancel",/"printLabel", "cancel", "insurancePremium",/' v1-new/carrier.mjs
sed -i.y 's/FEE_CONTRACT = \["zoneFactor", "baseFee"\]/FEE_CONTRACT = ["zoneFactor", "baseFee", "insurancePremium"]/' v2-new/contracts.mjs
rm -f v1-new/*.y v2-new/*.y
node try-fake.mjs v1-new ./fake-full.mjs
node try-fake.mjs v2-new ./fake-minimal.mjs
```

```
fee       -> carrier contract missing: insurancePremium
route     -> carrier contract missing: insurancePremium
tracking  -> carrier contract missing: insurancePremium
v1-new + ./fake-full.mjs: fake methods = 24, broken clients = 3
fee       -> fee contract missing: insurancePremium
route     -> {"points":["34","41","06"],"hours":18}
tracking  -> "delivered"
v2-new + ./fake-minimal.mjs: fake methods = 6, broken clients = 1
```

The single method added to the fee side broke all three clients at once in the wide
interface; in the split interface it broke only the relevant one. The route planner's
test, which had no knowledge of the insurance premium at all, broke in the first version
and did not break in the second.

#### The Criterion and Limit of Splitting

The correct axis for splitting an interface is the client. Grouping methods by subject
matter — "the ones related to calculation," "the ones related to registration" — does
not give the same result; two different clients can use different subsets of the same
subject. The criterion is the scan result: which client calls which method names.

Splitting also has a cost. Three contracts mean three validators and three names; and
if a client genuinely calls all four of four methods, splitting it in two does not
reduce the surplus, it only raises the file count. The principle's threshold is in the
measurement: if the surplus is zero, the gain from splitting is also zero.

#### Summary

- The interface segregation principle forbids a client from staying dependent on
  methods it does not use; its measure is the difference between the number of methods
  called and the number imposed.
- In the wide contract, the three clients' total calls came to 6 and the imposed
  dependencies to 24; in the split contracts, both came to 6.
- The first bill for the surplus is in fake dependencies: to satisfy the wide contract,
  the fake apparatus rose from 6 methods to 24, 18 of them padding that is never called.
- When a ninth method was added to the contract, 3 clients broke in the wide version
  and 1 client broke in the split version.
- The axis of the split is not the methods' subject matter but the client that calls
  them; where the surplus measures zero, splitting only raises the file count.

#### Next Step

The split contracts stood in the `v2/contracts.mjs` file, next to the clients. This
placement is not an accident: who defines the contract is a decision separate from how
many pieces the interface is split into. Had the contract been defined next to the
carrier implementation, clients would still bind to a split interface, but they would
still import the side where the implementation lives. The next lesson compares these two
placements by extracting the import direction, and turns which side the abstraction is
defined on into a number.

### Dependency Inversion

Source: https://academia.sh/en/courses/design-principles/dependency-inversion-principle

Turning which side an abstraction is defined on into a number: counting the direction of the import edges between the rule and carrier packages across three versions, measuring the contract's owner and its number of incoming edges, and testing with a run whether the rule module can load without the carrier package.

The split contracts stood next to the clients, but that placement was a separate
decision. How many pieces an interface is split into and which package those pieces are
defined in are independent of each other: a small contract can also live next to the
implementation. In that case, the client binds to a small interface but still imports
the package where the implementation lives.

**Dependency inversion** is SOLID's fifth item, and it takes on exactly this question:
the high-level rule should bind not to the low-level implementation but to an
abstraction both sides bind to; and the abstraction should be something the rule
defines, not the implementation. The Data Access Layer and Business Logic course
audited this rule through the import graph between layers, and measured the violation
count and the size of the import closure. The question here is narrower and its measure
is different: which direction do the edges between the two packages point, and which
package does the contract file stand in?

#### Three Placements

The codebase is split into two packages. `rule/` holds the fee rule, and `carrier/`
holds the carrier-specific base fee calculations. The same behavior is written with
three different placements.

```sh
mkdir -p direct/rule direct/carrier partial/rule partial/carrier
mkdir -p inverted/rule inverted/carrier
```

In the first placement, there is no abstraction at all; the rule directly imports the
two concrete modules and chooses between them itself.

```js
// direct/carrier/domestic.mjs — domestic carrier's base fee
export const domesticBase = (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800);
```

```js
// direct/carrier/express.mjs — express carrier's base fee
export const expressBase = (s) => 5200 + Math.ceil(s.weight) * 900;
```

```js
// direct/rule/fee.mjs — high-level rule imports the two concrete carriers itself
import { domesticBase } from "../carrier/domestic.mjs";
import { expressBase } from "../carrier/express.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export function fee(shipment) {
  const base = shipment.carrier === "express" ? expressBase(shipment) : domesticBase(shipment);
  return Math.round((base * zoneFactor(shipment.address)) / 100);
}
```

In the second placement, there is a contract, but it is defined in the carrier package.

```js
// partial/carrier/contract.mjs — contract exists, but defined in the carrier package
export function validateCarrier(c) {
  if (typeof c?.base !== "function") throw new TypeError("carrier contract missing: base");
  return c;
}
```

```js
// partial/carrier/domestic.mjs — domestic carrier satisfying the contract
import { validateCarrier } from "./contract.mjs";

export const domestic = validateCarrier({
  name: "domestic",
  base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800),
});
```

```js
// partial/carrier/express.mjs — express carrier satisfying the contract
import { validateCarrier } from "./contract.mjs";

export const express = validateCarrier({
  name: "express",
  base: (s) => 5200 + Math.ceil(s.weight) * 900,
});
```

```js
// partial/rule/fee.mjs — rule binds to the contract, but the contract lives in the carrier package
import { validateCarrier } from "../carrier/contract.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export function fee(carrier, shipment) {
  validateCarrier(carrier);
  return Math.round((carrier.base(shipment) * zoneFactor(shipment.address)) / 100);
}
```

In the third placement, the contract file is moved into the rule package exactly as it
is. The file's content does not change, only its location does.

```js
// inverted/rule/contract.mjs — contract defined in the rule package
export function validateCarrier(c) {
  if (typeof c?.base !== "function") throw new TypeError("carrier contract missing: base");
  return c;
}
```

```js
// inverted/rule/fee.mjs — rule imports only its own contract
import { validateCarrier } from "./contract.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export function fee(carrier, shipment) {
  validateCarrier(carrier);
  return Math.round((carrier.base(shipment) * zoneFactor(shipment.address)) / 100);
}
```

```js
// inverted/carrier/domestic.mjs — implementation, imports the rule's contract
import { validateCarrier } from "../rule/contract.mjs";

export const domestic = validateCarrier({
  name: "domestic",
  base: (s) => (s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800),
});
```

```js
// inverted/carrier/express.mjs — implementation, imports the rule's contract
import { validateCarrier } from "../rule/contract.mjs";

export const express = validateCarrier({
  name: "express",
  base: (s) => 5200 + Math.ceil(s.weight) * 900,
});
```

All three versions produce the same fee.

```js
// same-result.mjs — all three versions produce the same fee; the difference is not in behavior
const SHIPMENT = { carrier: "express", weight: 3.0, address: "06500" };

const { fee: directFee } = await import("./direct/rule/fee.mjs");
const { fee: partialFee } = await import("./partial/rule/fee.mjs");
const { express: partialExpress } = await import("./partial/carrier/express.mjs");
const { fee: invertedFee } = await import("./inverted/rule/fee.mjs");
const { express: invertedExpress } = await import("./inverted/carrier/express.mjs");

console.log("direct   =", directFee(SHIPMENT), "cents");
console.log("partial  =", partialFee(partialExpress, SHIPMENT), "cents");
console.log("inverted =", invertedFee(invertedExpress, SHIPMENT), "cents");
```

```sh
node same-result.mjs
```

```
direct   = 9085 cents
partial  = 9085 cents
inverted = 9085 cents
```

#### Counting the Edges by Direction

The difference is visible only in the import graph. The script below reads every module
in both packages, resolves the relative import paths, and counts the edges that cross
the package boundary by direction. Its second measure concerns the abstraction itself:
which package does the contract file stand in, and how many modules bind to it?

```js
// direction-measure.mjs — counts the import edges between the two packages and the abstraction's owner
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve, dirname } from "node:path";

const IMPORT = /^\s*(?:import|export)[^;'"]*from\s+["'](\.[^"']+)["']/gm;
const pkg = (path) => path.split("/")[0];
const root = process.argv[2];
const edges = [];

for (const p of ["rule", "carrier"]) {
  for (const name of readdirSync(join(root, p)).filter((a) => a.endsWith(".mjs")).sort()) {
    const source = `${p}/${name}`;
    for (const m of readFileSync(join(root, source), "utf8").matchAll(IMPORT)) {
      edges.push([source, relative(root, resolve(root, dirname(source), m[1]))]);
    }
  }
}

const outward = edges.filter(([a, b]) => pkg(a) !== pkg(b));
for (const [a, b] of outward) console.log(`  ${a} -> ${b}`);
const count = (p) => outward.filter(([a]) => pkg(a) === p).length;
console.log(`${root.padEnd(9)} rule -> carrier = ${count("rule")}` +
  `   carrier -> rule = ${count("carrier")}`);

const abstractions = [...new Set(edges.map(([, b]) => b))].filter((b) => b.endsWith("contract.mjs"));
if (abstractions.length === 0) {
  console.log(`${root.padEnd(9)} no abstraction defined`);
}
for (const a of abstractions) {
  const incoming = edges.filter(([, b]) => b === a).length;
  console.log(`${root.padEnd(9)} abstraction owner = ${pkg(a)}, incoming edges = ${incoming}`);
}
```

```sh
node direction-measure.mjs direct
node direction-measure.mjs partial
node direction-measure.mjs inverted
```

```
  rule/fee.mjs -> carrier/domestic.mjs
  rule/fee.mjs -> carrier/express.mjs
direct    rule -> carrier = 2   carrier -> rule = 0
direct    no abstraction defined
  rule/fee.mjs -> carrier/contract.mjs
partial   rule -> carrier = 1   carrier -> rule = 0
partial   abstraction owner = carrier, incoming edges = 3
  carrier/domestic.mjs -> rule/contract.mjs
  carrier/express.mjs -> rule/contract.mjs
inverted  rule -> carrier = 0   carrier -> rule = 2
inverted  abstraction owner = rule, incoming edges = 3
```

The three lines separate three distinct situations. The first version has two edges
from the rule to the implementation and no abstraction. The second version has an
abstraction, with three incoming edges — meaning the contract genuinely does its job —
but the number of boundary-crossing edges dropped from two to one, not to zero: the rule
still imports the carrier package. The third version has the same contract, with the
same three incoming edges, standing in the rule package, and every boundary-crossing
edge points in the reverse direction.

The second version is the principle's most common half-implementation. It is not
enough for the abstraction to exist; **who defines it** is the measure's real
determinant.

#### The Rule Without an Implementation

Edge direction looks like an abstract measure; its payoff is concrete. From each
version, a copy containing only the rule package is extracted, and an attempt is made to
load the rule module on its own.

```js
// try-rule.mjs — can the rule module load without the carrier package
const root = process.argv[2];
try {
  const m = await import(`./${root}/rule/fee.mjs`);
  console.log(`${root.padEnd(16)} -> loaded, exports: ${Object.keys(m).join(", ")}`);
} catch (e) {
  console.log(`${root.padEnd(16)} -> failed to load, ${e.code}`);
}
```

```sh
for k in direct partial inverted; do
  mkdir -p "$k-only"
  cp -r "$k/rule" "$k-only/rule"
  node try-rule.mjs "$k-only"
done
```

```
direct-only      -> failed to load, ERR_MODULE_NOT_FOUND
partial-only     -> failed to load, ERR_MODULE_NOT_FOUND
inverted-only    -> loaded, exports: fee
```

The second version, which has a contract, also failed to load. Whether the rule can be
resolved independently of the implementation depends not on the contract's existence
but on the contract being defined on the rule's side. This is the measure's numeric
payoff: the rule cannot stand on its own until the number of edges going from the rule
to the implementation is zero.

#### The Limit and Cost of the Principle

In the third version, the fee module does not know which carrier it will work with; the
carrier is given to it from outside. This is the **dependency injection** built in the
Application Architecture: Routing, State and Data and Unit Testing and Test-Driven
Development courses, and it appears here as a consequence of the principle: a module
that does not construct its own dependency has to get it from somewhere. That place is
the composition root.

The cost of inverting can also be counted: in the third version, one extra contract
file stands between the two packages, and the carrier choice is now outside the fee
module instead of inside it. Where the implementation is known to be singular and
unchanging, this surplus has no payoff. The principle does not ask for every dependency
to be inverted; it asks for the side that changes faster to bind to the side that
changes slower. If the carrier list changes more often than the fee rule, the direction
is inverted; if the reverse is true, the measurement says the reverse too.

#### Summary

- Dependency inversion asks for the high-level rule to bind to an abstraction rather
  than the implementation, and for that abstraction to be defined by the rule.
- The measure is the direction of the import edges that cross the package boundary: 2,
  1, and 0 edges were counted from the rule to the carrier; 0, 0, and 2 edges were
  counted from the carrier to the rule.
- The abstraction's existence is not enough: in the version whose contract stood in the
  carrier package, the rule still imported the carrier package even though the number of
  incoming edges was 3.
- The payoff of edge direction was seen with a run: without the carrier package, only
  the third version's rule module loaded; the first two gave `ERR_MODULE_NOT_FOUND`.
- The principle inverts not every dependency but the dependencies that point at the
  faster-changing side; the contract file and the outside-injection apparatus are its
  cost.

#### Next Step

The five principles said **whom** a unit binds to: how many actors, which axis of
extension, which contract, how large an interface, which side the abstraction is on.
None of them tied the bond itself to a measure. How is the strength of the bond between
two modules counted — the number of shared names, the number of parameters passed,
state changed in common? How is it understood whether the parts inside a module truly
belong to each other? The next topic builds these two measures and turns the judgments
the principles have so far left qualitative into numbers.

## Coupling and Cohesion

Counting the strength of the bond between two modules and whether the parts inside a module belong together: types of coupling, the cohesion measure, the law of Demeter, the tell, don't ask principle, command–query separation, isolating axes of change, the cost of binding to a concrete type, and inversion of control.

### Types of Coupling

Source: https://academia.sh/en/courses/design-principles/types-of-coupling

Counting the strength of the bond between two modules: writing a script that measures the number of shared names, the number of parameters passed, and the state modules change in common, then comparing the measurements across three designs that produce the same invoice line.

The SOLID principles said who a unit should depend on, but they did not measure the bond
itself. The difference between "loosely coupled" and "tightly coupled" should be countable,
not read off a design's tone. This topic establishes two measures; the first belongs to the
bond **between** modules.

**Coupling** is the strength of the bond between two modules. That strength has three
sources: the **number of names** one module takes from another, the **number of parameters**
passed in calls, and the **state the two modules change in common**. The types of coupling
fall out of these three measures combined, and the classic ranking runs from strongest to
weakest: content, common, control, stamp, data.

| Type | Symptom |
|---|---|
| Content coupling | One module reaches into another's internal name |
| Common coupling | Two modules share the same mutable state |
| Control coupling | A call passes a flag that tells the callee which branch to take |
| Stamp coupling | A whole record is passed, including fields that go unused |
| Data coupling | Only the number or string actually needed is passed |

Strong coupling is limited not because it is bad, but because it **enlarges the surface that
spreads a change**. The three designs below produce the same invoice line; the only
difference is the width of that surface.

#### Same Output, Three Designs

The first design works through a shared settings object. The calc module both returns the
result and writes it to shared state; the invoice module reads the result from there and
from the calc module's internal name.

```sh
mkdir -p common stamp data
```

```js
// common/settings.mjs — shared state that every module reads and writes
export const SETTING = { zone: 100, vat: 20, _last: 0 };
```

```js
// common/calc.mjs — returns the result, but writes to shared state first
import { SETTING } from "./settings.mjs";

export const _TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function calculate(shipment) {
  SETTING.zone = ZONE[shipment.address.slice(0, 2)] ?? 160;
  const base = _TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  SETTING._last = Math.round((base * SETTING.zone * (100 + SETTING.vat)) / 10000);
  return SETTING._last;
}
```

```js
// common/invoice.mjs — reads the result from shared state and the calc module's internal name
import { SETTING } from "./settings.mjs";
import { calculate, _TIER } from "./calc.mjs";

export function line(shipment) {
  calculate(shipment);
  return `${SETTING._last} cents (${_TIER.length} tiers)`;
}
```

The second design has no shared state. Instead, the whole shipment record is passed, along
with a flag that tells calc which branch to take.

```js
// stamp/calc.mjs — no shared state; the whole record and a flag are passed
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const tierCount = () => TIER.length;

export function calculate(shipment, addVat) {
  const zone = ZONE[shipment.address.slice(0, 2)] ?? 160;
  const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
  const raw = (base * zone) / 100;
  return Math.round(addVat ? (raw * 120) / 100 : raw);
}
```

```js
// stamp/invoice.mjs — gives calc the whole shipment and the flag that picks the behavior
import { calculate, tierCount } from "./calc.mjs";

export const line = (shipment) => `${calculate(shipment, true)} cents (${tierCount()} tiers)`;
```

In the third design, the calculation steps are split into separate functions, and each one
takes only the number it needs. The flag turns into two separate function names.

```js
// data/calc.mjs — each step takes only the numbers it needs
const TIER = [[1, 3900], [5, 6400], [20, 11800]];

export const tierCount = () => TIER.length;
export const base = (weight) => TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
export const zoned = (cents, factor) => (cents * factor) / 100;
export const withVat = (cents) => Math.round((cents * 120) / 100);
```

```js
// data/invoice.mjs — passes calc only numbers, no flag
import { base, zoned, withVat, tierCount } from "./calc.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };

export function line(shipment) {
  const factor = ZONE[shipment.address.slice(0, 2)] ?? 160;
  return `${withVat(zoned(base(shipment.weight), factor))} cents (${tierCount()} tiers)`;
}
```

#### The Measurer

The script below reads the modules in a directory, produces one edge per import statement,
and computes that edge's three measures: the number of names taken, the highest number of
parameters passed in calls, and the number of writes made to an imported name. It derives
the type from these measures; if an edge carries more than one type, the strongest one
becomes the label.

```js
// coupling-measure.mjs — the three measures and type of the bond between two modules
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const IMPORT = /import\s*\{([^}]*)\}\s*from\s*["']\.\/([^"']+)["']/g;
// Objects passed whole, as records. If one of these is an argument in a call, it is stamp coupling.
const RECORDS = ["shipment", "tariff", "customer"];

function callArguments(text, name) {
  const calls = [];
  for (const m of text.matchAll(new RegExp(`\\b${name}\\s*\\(`, "g"))) {
    let i = m.index + m[0].length;
    let depth = 1;
    let piece = "";
    const pieces = [];
    while (i < text.length && depth > 0) {
      const c = text[i];
      if ("([{".includes(c)) depth += 1;
      else if (")]}".includes(c)) depth -= 1;
      if (depth === 0) break;
      if (c === "," && depth === 1) { pieces.push(piece.trim()); piece = ""; } else piece += c;
      i += 1;
    }
    if (piece.trim() !== "") pieces.push(piece.trim());
    calls.push(pieces);
  }
  return calls;
}

const root = process.argv[2];
const files = readdirSync(root).filter((a) => a.endsWith(".mjs")).sort();
const texts = new Map(files.map((d) => [d, readFileSync(join(root, d), "utf8")]));

// First collect which imported names are written to across the whole tree: a written name is shared state.
const written = new Set();
for (const [, text] of texts) {
  for (const m of text.matchAll(/\b([A-Za-z_]\w*)\.\w+\s*=[^=]/g)) written.add(m[1]);
}

let totalNames = 0;
let totalParams = 0;
let totalWrites = 0;

for (const d of files) {
  const text = texts.get(d);
  for (const m of text.matchAll(IMPORT)) {
    const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
    const target = m[2];
    let params = 0;
    let writes = 0;
    const types = new Set();
    for (const name of names) {
      const calls = callArguments(text, name);
      const args = calls.flat();
      params += calls.length === 0 ? 0 : Math.max(...calls.map((c) => c.length));
      writes += [...text.matchAll(new RegExp(`\\b${name}\\.\\w+\\s*=[^=]`, "g"))].length;
      if (name.startsWith("_")) types.add("content");
      if (written.has(name)) types.add("common");
      if (args.some((a) => a === "true" || a === "false")) types.add("control");
      if (args.some((a) => RECORDS.includes(a))) types.add("stamp");
    }
    if (types.size === 0) types.add("data");
    const order = ["content", "common", "control", "stamp", "data"];
    const strongest = order.find((t) => types.has(t));
    totalNames += names.length;
    totalParams += params;
    totalWrites += writes;
    console.log(`  ${d} -> ${target}`.padEnd(30) +
      `names=${names.length} params=${params} writes=${writes}  type=${strongest}` +
      (types.size > 1 ? ` (${[...order.filter((t) => types.has(t))].join(", ")})` : ""));
  }
}
console.log(`${root.padEnd(6)} total: names=${totalNames} params=${totalParams} writes=${totalWrites}`);
```

```sh
node coupling-measure.mjs common
node coupling-measure.mjs stamp
node coupling-measure.mjs data
```

```
  calc.mjs -> settings.mjs    names=1 params=0 writes=2  type=common
  invoice.mjs -> settings.mjs names=1 params=0 writes=0  type=common
  invoice.mjs -> calc.mjs     names=2 params=1 writes=0  type=content (content, stamp)
common total: names=4 params=1 writes=2
  invoice.mjs -> calc.mjs     names=2 params=2 writes=0  type=control (control, stamp)
stamp  total: names=2 params=2 writes=0
  invoice.mjs -> calc.mjs     names=4 params=4 writes=0  type=data
data   total: names=4 params=4 writes=0
```

All three designs produce the same line, but their measures differ. The first design has
three edges, two of which look at shared state, and two writes were counted. The second
design is left with a single edge; the name and parameter counts are low, but its type is
control coupling. The third design has zero writes and its type is data coupling, yet its
name count rises to four and its parameter count also rises to four.

The last line reveals an important side of the measure: **type and magnitude are separate
things**. The third design has the weakest coupling type, yet it is also the design that
shares the most names. Reducing coupling does not mean "pass fewer names"; it means changing
what gets passed.

#### The Spread of a Change

The measure's real-world counterpart shows up in a touch that comes from an unrelated place.
The script below prints all three designs' lines, then touches only one field of the shared
settings object and prints the lines again.

```js
// remote-touch.mjs — how a single line in an unrelated module affects three designs
import { SETTING } from "./common/settings.mjs";
import { line as commonLine } from "./common/invoice.mjs";
import { line as stampLine } from "./stamp/invoice.mjs";
import { line as dataLine } from "./data/invoice.mjs";

const SHIPMENT = { weight: 3.0, address: "06500" };
const report = (label) =>
  console.log(`${label.padEnd(8)} common=${commonLine(SHIPMENT)}  stamp=${stampLine(SHIPMENT)}  data=${dataLine(SHIPMENT)}`);

report("before");
SETTING.vat = 0;
report("after");
```

```sh
node remote-touch.mjs
```

```
before   common=8832 cents (3 tiers)  stamp=8832 cents (3 tiers)  data=8832 cents (3 tiers)
after    common=7360 cents (3 tiers)  stamp=8832 cents (3 tiers)  data=8832 cents (3 tiers)
```

Nothing touched the calc module, the invoice module, or the tier table. The only thing that
changed was one field of the shared object, and the first design's output dropped from 8832
to 7360. This is the measurable definition of common coupling: a module's behavior can be
changed by a module that never imported it at all.

#### Concrete Symptoms of the Types

The symptom of control coupling is a flag parameter. Anyone reading the call
`calculate(shipment, true)` has to ask what `true` means; the answer sits in the callee's
body. Once the flag is removed, the information moves into the call itself: `withVat(...)`
and `zoned(...)` are separate names, and which one gets called is readable at the call site.

The symptom of stamp coupling is that the callee uses only part of the record's fields.
`calculate(shipment, true)` uses the shipment's weight and address fields; the rest of the
record — value, delivery address, volume — enters the call and goes unused. This means every
new field added to the shipment record enlarges the calc module's input surface.

The symptom of content coupling is the naming convention: the name `_TIER` starts with an
underscore, which says it should not be exported, yet the invoice module imports it anyway.
The measurer caught this; when the tier table's shape changes, the invoice module will have
to change with it.

#### Summary

- Coupling is the strength of the bond between two modules, and it is counted with three
  measures: the number of shared names, the number of parameters passed, and the state
  changed in common.
- The types run from strongest to weakest as content, common, control, stamp, and data
  coupling; an edge can carry more than one, and the label is the strongest.
- The three designs that produce the same invoice line measured, respectively, content,
  control, and data coupling; the write count came out to 2, 0, and 0.
- Type and magnitude are separate measures: the design with the weakest type was also the
  design that shared the most names (4) and the most parameters (4).
- A touch to a single field of the shared object changed only the commonly coupled design's
  output, from 8832 to 7360; the other two were unaffected.

#### Next Step

The coupling measure counted the edges between modules and never looked inside a module. The
third design's calc module exports four names; whether those four names belong together fell
outside the measure. Do functions sitting in the same file touch shared data, or are they
merely next to each other because they happen to share a file? The next lesson groups a
module's functions by the data they touch, splits a module with low relatedness, repeats the
measurement, and counts how the split affects the coupling measure.

### Cohesion

Source: https://academia.sh/en/courses/design-principles/cohesion

Counting whether the parts inside a module belong together: clustering functions by the module-level names they touch, computing the component count against the number of function pairs that share nothing, splitting a low-cohesion module and repeating the measurement, and showing that the split produces no new coupling edge.

The coupling measure counted the edges between modules and never looked inside a module. A
module might export four names; whether those four names belong together fell outside the
measure. Do functions sitting in the same file touch shared data, or are they merely next to
each other because they happen to share a file?

**Cohesion** is the degree to which the parts inside a module belong together, and it is
coupling's counterpart inside a module. Its measurable definition is this: when a module's
functions are clustered by the module-level names they touch, how many separate clusters do
they fall into? If they fall into a single cluster, the module is about one thing; if they
fall into several, the module is holding several separate things in the same file.

#### The Module to Measure

The module below holds three shipment-related jobs together: fee calculation, tracking
number generation, and address formatting. Because all three can be called by the word
"shipment," keeping them in the same file looks reasonable.

```sh
mkdir -p combined split
```

```js
// combined/shipment-service.mjs — everything shipment-related in a single module
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };
const PROVINCE_NAMES = { "34": "Istanbul", "06": "Ankara", "65": "Van" };
const PREFIX = "GN";
let COUNTER = 0;

export function base(weight) {
  return TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
}

export function zoneFactor(address) {
  return ZONE[address.slice(0, 2)] ?? 160;
}

export function fee(shipment) {
  return Math.round((base(shipment.weight) * zoneFactor(shipment.address)) / 100);
}

export function trackingNumber() {
  COUNTER += 1;
  return `${PREFIX}-${String(COUNTER).padStart(6, "0")}`;
}

export function isTrackingValid(no) {
  return new RegExp(`^${PREFIX}-\\d{6}$`).test(no);
}

export function formatAddress(address) {
  return `${PROVINCE_NAMES[address.slice(0, 2)] ?? "unknown"} ${address}`;
}
```

#### The Clustering Measurer

The script extracts the module's top-level names, finds which names appear in each
function's body, and puts two functions in the same cluster if they either touch a common
name or one calls the other. It reports the resulting component count along with the number
of function pairs that do and do not touch a shared name.

```js
// cohesion-measure.mjs — clusters a module's functions by the names they touch
import { readFileSync } from "node:fs";

function body(text, open) {
  let i = open;
  let depth = 0;
  do {
    if (text[i] === "{") depth += 1;
    else if (text[i] === "}") depth -= 1;
    i += 1;
  } while (i < text.length && depth > 0);
  return text.slice(open, i);
}

function resolve(path) {
  const text = readFileSync(path, "utf8");
  const data = [...text.matchAll(/^(?:export\s+)?(?:const|let)\s+(\w+)/gm)].map((m) => m[1]);
  const functionNames = [...text.matchAll(/^(?:export\s+)?function\s+(\w+)/gm)].map((m) => m[1]);
  const all = [...data, ...functionNames];
  const functions = [];
  for (const m of text.matchAll(/^(?:export\s+)?function\s+(\w+)\s*\(/gm)) {
    const g = body(text, text.indexOf("{", m.index + m[0].length));
    const refs = all.filter((a) => a !== m[1] && new RegExp(`\\b${a}\\b`).test(g));
    functions.push({ name: m[1], refs: new Set(refs) });
  }
  return { data, functions };
}

const overlaps = (a, b) =>
  [...a.refs].some((x) => b.refs.has(x)) || a.refs.has(b.name) || b.refs.has(a.name);

for (const path of process.argv.slice(2)) {
  const { data, functions } = resolve(path);
  const component = new Map(functions.map((f, i) => [f.name, i]));
  let changed = true;
  while (changed) {
    changed = false;
    for (const a of functions) {
      for (const b of functions) {
        if (a.name !== b.name && overlaps(a, b) && component.get(a.name) !== component.get(b.name)) {
          const smallest = Math.min(component.get(a.name), component.get(b.name));
          component.set(a.name, smallest);
          component.set(b.name, smallest);
          changed = true;
        }
      }
    }
  }
  const clusters = new Map();
  for (const [name, k] of component) clusters.set(k, [...(clusters.get(k) ?? []), name]);

  let shared = 0;
  let unshared = 0;
  for (let i = 0; i < functions.length; i += 1) {
    for (let j = i + 1; j < functions.length; j += 1) {
      if (overlaps(functions[i], functions[j])) shared += 1; else unshared += 1;
    }
  }
  console.log(`${path}  functions=${functions.length} data=${data.length} components=${clusters.size}`);
  let n = 0;
  for (const [, names] of [...clusters].sort((a, b) => a[0] - b[0])) {
    n += 1;
    console.log(`  component ${n}: ${names.join(", ")}`);
  }
  console.log(`  shared pair=${shared} unshared pair=${unshared}` +
    ` lack of cohesion=${Math.max(0, unshared - shared)}`);
}
```

```sh
node cohesion-measure.mjs combined/shipment-service.mjs
```

```
combined/shipment-service.mjs  functions=6 data=5 components=3
  component 1: base, zoneFactor, fee
  component 2: trackingNumber, isTrackingValid
  component 3: formatAddress
  shared pair=3 unshared pair=12 lack of cohesion=9
```

Six functions fell into three separate clusters. Of the fifteen function pairs, only three
touch a shared name; the remaining twelve have nothing in common. The lack-of-cohesion count
is the difference between these two values, and it came out to nine. Notice that the parts
look related only because the module is named "shipment service"; the measurement looks at
the code, not the name.

#### Splitting and Re-measuring

The components give the split boundary directly. The three components are extracted into
three modules; the function bodies do not change, only which file they live in.

```js
// split/fee.mjs — first component: fee calculation
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export function base(weight) {
  return TIER.find(([max]) => weight <= max)?.[1] ?? 15000;
}

export function zoneFactor(address) {
  return ZONE[address.slice(0, 2)] ?? 160;
}

export function fee(shipment) {
  return Math.round((base(shipment.weight) * zoneFactor(shipment.address)) / 100);
}
```

```js
// split/tracking.mjs — second component: tracking number
const PREFIX = "GN";
let COUNTER = 0;

export function trackingNumber() {
  COUNTER += 1;
  return `${PREFIX}-${String(COUNTER).padStart(6, "0")}`;
}

export function isTrackingValid(no) {
  return new RegExp(`^${PREFIX}-\\d{6}$`).test(no);
}
```

```js
// split/address.mjs — third component: address formatting
const PROVINCE_NAMES = { "34": "Istanbul", "06": "Ankara", "65": "Van" };

export function formatAddress(address) {
  return `${PROVINCE_NAMES[address.slice(0, 2)] ?? "unknown"} ${address}`;
}
```

```sh
node cohesion-measure.mjs split/fee.mjs split/tracking.mjs split/address.mjs
grep -c "^import" split/fee.mjs split/tracking.mjs split/address.mjs || true
```

```
split/fee.mjs  functions=3 data=2 components=1
  component 1: base, zoneFactor, fee
  shared pair=2 unshared pair=1 lack of cohesion=0
split/tracking.mjs  functions=2 data=2 components=1
  component 1: trackingNumber, isTrackingValid
  shared pair=1 unshared pair=0 lack of cohesion=0
split/address.mjs  functions=1 data=1 components=1
  component 1: formatAddress
  shared pair=0 unshared pair=0 lack of cohesion=0
split/fee.mjs:0
split/tracking.mjs:0
split/address.mjs:0
```

In all three modules, the component count is one and lack of cohesion is zero. The last
three lines count the cost of the split: the new modules do not import each other at all.
Splitting a module with low cohesion did not produce coupling, because the parts being split
never touched each other in the first place. Splitting only costs coupling when functions
that genuinely touch shared data get separated.

Behavior did not change either.

```js
// same-behavior.mjs — two layouts produce the same results
const SHIPMENT = { weight: 3.0, address: "06500" };
const c = await import("./combined/shipment-service.mjs");
const f = await import("./split/fee.mjs");
const t = await import("./split/tracking.mjs");
const a = await import("./split/address.mjs");

console.log("combined ->", c.fee(SHIPMENT), c.trackingNumber(), c.formatAddress(SHIPMENT.address), c.isTrackingValid("GN-000001"));
console.log("split ->", f.fee(SHIPMENT), t.trackingNumber(), a.formatAddress(SHIPMENT.address), t.isTrackingValid("GN-000001"));
```

```sh
node same-behavior.mjs
```

```
combined -> 7360 GN-000001 Ankara 06500 true
split -> 7360 GN-000001 Ankara 06500 true
```

#### The Practical Counterpart of Cohesion

So the component count does not stay abstract, a second measure is taken: the size of the
file that must be read when a piece of data changes. The tracking number prefix and the
zone factors are decisions made by different people; where each one lives is measurable.

```js
// impact-area.mjs — the size of the file that must be read when a data name changes
import { readFileSync } from "node:fs";

const [name, ...files] = process.argv.slice(2);

for (const path of files) {
  const text = readFileSync(path, "utf8");
  if (!new RegExp(`^(?:const|let)\\s+${name}\\b`, "m").test(text)) continue;
  const functions = [...text.matchAll(/^(?:export\s+)?function\s+\w+/gm)].length;
  const lines = text.trim().split("\n").length;
  console.log(`${name.padEnd(10)} ${path.padEnd(30)} functions=${functions} lines=${lines}`);
}
```

```sh
for name in PREFIX ZONE; do
  node impact-area.mjs $name combined/shipment-service.mjs \
    split/fee.mjs split/tracking.mjs split/address.mjs
done
```

```
PREFIX     combined/shipment-service.mjs  functions=6 lines=31
PREFIX     split/tracking.mjs             functions=2 lines=12
ZONE       combined/shipment-service.mjs  functions=6 lines=31
ZONE       split/fee.mjs                  functions=3 lines=15
```

Changing the tracking prefix requires opening a 6-function, 31-line file in the combined
layout, and a 2-function, 12-line file in the split layout. This is the real-world
counterpart of cohesion: the reading surface of a change stays proportional to the change
itself.

#### Misleading Cohesion Criteria

Similarity of name is not cohesion. All six functions under the name `shipment-service` were
shipment-related, and the measurement still found three components. In the same way, reasons
like "they are all helper functions" or "they all do validation" cannot be measured either;
in these groupings the functions share no common data, they only do similar work.

There is also a mistake in the opposite direction: driving the component count down to one
is not always right. Making every function in a module touch a single shared object drives
the component count to one, but it produces the common coupling measured in the previous
lesson. The two measures have to be read together: a component count of one inside the
module, and a write count of zero between modules.

#### Summary

- Cohesion is how many clusters a module's functions fall into based on the names they
  touch; it is coupling's counterpart inside a module.
- In the combined module, 6 functions split into 3 components; 12 of the 15 function pairs
  touched no shared name at all, and lack of cohesion measured 9.
- The components give the split boundary directly: once the three components were extracted
  into three modules, each module had a component count of 1 and lack of cohesion of 0.
- The coupling cost of the split came out to zero: the new modules do not import each other
  at all, because the separated parts were never touching shared data in the first place.
- The file that has to be read when a piece of data changes shrank from 6 functions and 31
  lines to 2 functions and 12 lines.

#### Next Step

The cohesion measure looked at whether a module touches its own names. Once modules start
passing objects to each other, a new question appears: when a function reaches through an
object it received into a second object, and from there into a third field, how many
separate types does that function have to know the shape of? A chain like
`shipment.recipient.address.province` creates a dependency on the shape of every step in
between, and that dependency shows up neither in the import graph nor in the cohesion
measure. The next lesson writes a scan that counts chain depth and measures how the number of
files touched by a field change drops once the chain gets shorter.

### Law of Demeter

Source: https://academia.sh/en/courses/design-principles/law-of-demeter

Limiting chained access: writing a scan that counts access chain depth, comparing the finding count in two designs that produce the same five outputs, and measuring how many files get edited when the address record is restructured.

The cohesion measure looked at whether a module touches its own names. Once modules start
passing objects to each other, a new question appears: when a function reaches through an
object it received, into a second object, and from there into a third field, how many
separate structures does it have to know about? This dependency shows up neither in the
import graph nor in the cohesion measure, because there is no new import and no new
module-level name involved.

The **law of Demeter** limits this reach. A function should call methods only on: its own
object, the parameters it received, the objects it constructs itself, and its direct parts.
It is summarized as "talk to your neighbor, not your neighbor's neighbor." Its measurable
counterpart is the depth of the access chain: `shipment.code` is one step,
`shipment.recipient.address.province` is three steps, and it creates a dependency on the
shape of the two structures in between.

#### The Chained Design

The record is three levels deep, and five separate clients reach into it.

```sh
mkdir -p chained short
```

```js
// chained/record.mjs — nested shipment record
export const RECORD = {
  code: "GN-000001",
  recipient: { name: "A. Yilmaz", address: { province: "Ankara", district: "Cankaya", postalCode: "06500" } },
  tariff: { name: "weight", tier: { max: 5, fee: 6400 } },
};
```

```js
// chained/label.mjs — reaches two steps into the recipient record
export const label = (shipment) =>
  `${shipment.recipient.name} / ${shipment.recipient.address.province} ${shipment.recipient.address.postalCode}`;
```

```js
// chained/fee.mjs — reaches into the tariff's tier and into the address
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const fee = (shipment) =>
  Math.round((shipment.tariff.tier.fee *
    (ZONE[shipment.recipient.address.postalCode.slice(0, 2)] ?? 160)) / 100);
```

```js
// chained/report.mjs — reads province and district separately
export const report = (shipment) =>
  `${shipment.code} ${shipment.recipient.address.province}/${shipment.recipient.address.district} ${shipment.tariff.name}`;
```

```js
// chained/notification.mjs — the notification text walks the same chain
export const notification = (shipment) =>
  `${shipment.code} departed for ${shipment.recipient.address.province}`;
```

```js
// chained/branch.mjs — delivery branch selection
export const branch = (shipment) =>
  `${shipment.recipient.address.province}-${shipment.recipient.address.district} branch`;
```

```js
// chained/setup.mjs — the output of five clients
import { RECORD } from "./record.mjs";
import { label } from "./label.mjs";
import { fee } from "./fee.mjs";
import { report } from "./report.mjs";
import { notification } from "./notification.mjs";
import { branch } from "./branch.mjs";

for (const f of [label, fee, report, notification, branch]) console.log(f(RECORD));
```

```sh
node chained/setup.mjs
```

```
A. Yilmaz / Ankara 06500
7360
GN-000001 Ankara/Cankaya weight
GN-000001 departed for Ankara
Ankara-Cankaya branch
```

#### The Chain Scan

The script extracts the access chains in each file, counts their depth, and reports the ones
longer than two steps as findings. Language and runtime objects (such as `Math`, `JSON`,
`console`) do not count as a field chain; the composition root, `setup.mjs`, is also outside
the scan.

```js
// chain-scan.mjs — counts access chain depth; every chain longer than two steps is a finding
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const CHAIN = /\b[a-zA-Z_]\w*(?:\.\w+)+/g;
// Language and runtime objects do not count as a field chain.
const EXCLUDED = ["Math", "Object", "String", "Number", "JSON", "console", "process", "Array"];

const root = process.argv[2];
let totalFindings = 0;
let deepest = 0;

for (const d of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) {
  if (d === "setup.mjs") continue;
  const text = readFileSync(join(root, d), "utf8").replace(/^\/\/.*$/gm, "");
  const chains = [...text.matchAll(CHAIN)]
    .map((m) => m[0])
    .filter((z) => !EXCLUDED.includes(z.split(".")[0]));
  const depths = chains.map((z) => z.split(".").length - 1);
  const findings = chains.filter((z) => z.split(".").length - 1 >= 2);
  const localDeepest = depths.length === 0 ? 0 : Math.max(...depths);
  totalFindings += findings.length;
  deepest = Math.max(deepest, localDeepest);
  console.log(`  ${d.padEnd(14)} chains=${chains.length} deepest=${localDeepest}` +
    ` findings=${findings.length}${findings.length > 0 ? "  " + findings.join(" ") : ""}`);
}
console.log(`${root.padEnd(9)} total findings=${totalFindings} deepest chain=${deepest}`);
```

```sh
node chain-scan.mjs chained
```

```
  branch.mjs     chains=2 deepest=3 findings=2  shipment.recipient.address.province shipment.recipient.address.district
  fee.mjs        chains=2 deepest=4 findings=2  shipment.tariff.tier.fee shipment.recipient.address.postalCode.slice
  label.mjs      chains=3 deepest=3 findings=3  shipment.recipient.name shipment.recipient.address.province shipment.recipient.address.postalCode
  notification.mjs chains=2 deepest=3 findings=1  shipment.recipient.address.province
  record.mjs     chains=0 deepest=0 findings=0
  report.mjs     chains=4 deepest=3 findings=3  shipment.recipient.address.province shipment.recipient.address.district shipment.tariff.name
chained   total findings=11 deepest chain=4
```

Eleven findings, with the deepest chain at four steps. All five clients know the address
record's field names.

#### Shortening the Chain

The way to shorten the chain is to give the intermediate objects behavior. Only one module
knows the shape of the address record; the rest ask it for a result.

```js
// short/record.mjs — the same nested record
export const RECORD = {
  code: "GN-000001",
  recipient: { name: "A. Yilmaz", address: { province: "Ankara", district: "Cankaya", postalCode: "06500" } },
  tariff: { name: "weight", tier: { max: 5, fee: 6400 } },
};
```

```js
// short/address.mjs — only this module knows the shape of the address record
export const address = (record) => ({
  format: () => `${record.province} ${record.postalCode}`,
  zoneCode: () => record.postalCode.slice(0, 2),
  provinceDistrict: () => `${record.province}/${record.district}`,
  province: () => record.province,
});
```

```js
// short/shipment.mjs — shipment asks its neighbor and hands back the result under its own name
import { address } from "./address.mjs";

export const shipment = (record) => {
  const a = address(record.recipient.address);
  return {
    code: record.code,
    labelLine: () => `${record.recipient.name} / ${a.format()}`,
    zoneCode: () => a.zoneCode(),
    provinceDistrict: () => a.provinceDistrict(),
    province: () => a.province(),
    tariffName: () => record.tariff.name,
    tierFee: () => record.tariff.tier.fee,
  };
};
```

```js
// short/label.mjs — one step
export const label = (shipment) => shipment.labelLine();
```

```js
// short/fee.mjs — one step
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const fee = (shipment) =>
  Math.round((shipment.tierFee() * (ZONE[shipment.zoneCode()] ?? 160)) / 100);
```

```js
// short/report.mjs — one step
export const report = (shipment) => `${shipment.code} ${shipment.provinceDistrict()} ${shipment.tariffName()}`;
```

```js
// short/notification.mjs — one step
export const notification = (shipment) => `${shipment.code} departed for ${shipment.province()}`;
```

```js
// short/branch.mjs — one step
export const branch = (shipment) => `${shipment.provinceDistrict().replace("/", "-")} branch`;
```

```js
// short/setup.mjs — the same five outputs
import { RECORD } from "./record.mjs";
import { shipment } from "./shipment.mjs";
import { label } from "./label.mjs";
import { fee } from "./fee.mjs";
import { report } from "./report.mjs";
import { notification } from "./notification.mjs";
import { branch } from "./branch.mjs";

const s = shipment(RECORD);
for (const f of [label, fee, report, notification, branch]) console.log(f(s));
```

```sh
node short/setup.mjs
node chain-scan.mjs short
```

```
A. Yilmaz / Ankara 06500
7360
GN-000001 Ankara/Cankaya weight
GN-000001 departed for Ankara
Ankara-Cankaya branch
  address.mjs    chains=6 deepest=2 findings=1  record.postalCode.slice
  branch.mjs     chains=1 deepest=1 findings=0
  fee.mjs        chains=2 deepest=1 findings=0
  label.mjs      chains=1 deepest=1 findings=0
  notification.mjs chains=2 deepest=1 findings=0
  record.mjs     chains=0 deepest=0 findings=0
  report.mjs     chains=3 deepest=1 findings=0
  shipment.mjs   chains=10 deepest=3 findings=4  record.recipient.address record.recipient.name record.tariff.name record.tariff.tier.fee
short     total findings=5 deepest chain=3
```

The same five outputs, but five findings instead of eleven. More importantly, look at how
the findings are distributed: all five client modules dropped to zero findings, and the
remaining findings gathered in the two modules that resolve the structure. The law of
Demeter does not eliminate knowledge of the structure; it reduces **the number of modules
that know it**.

#### The Files a Change Touches

The measure's counterpart shows up in a structural change. The address record is being
reorganized: the `province` and `district` fields move into a nested record named
`location`. `postalCode` stays where it is. The in-place edit is given a backup extension;
GNU and BSD `sed` behave the same way with this form.

```sh
cp -r chained chained-new
cp -r short short-new
NEW='{ location: { province: "Ankara", district: "Cankaya" }, postalCode: "06500" }'
OLD='{ province: "Ankara", district: "Cankaya", postalCode: "06500" }'
sed -i.y "s#$OLD#$NEW#" chained-new/record.mjs short-new/record.mjs
sed -i.y -e 's/\.address\.province/.address.location.province/g' -e 's/\.address\.district/.address.location.district/g' chained-new/*.mjs
sed -i.y -e 's/record\.province/record.location.province/g' -e 's/record\.district/record.location.district/g' short-new/address.mjs
rm -f chained-new/*.y short-new/*.y
node chained-new/setup.mjs
node short-new/setup.mjs
for k in chained short; do
  echo "$k: edited files = $(diff -rq $k $k-new | grep -c '^Files')  ->" \
    "$(diff -rq $k $k-new | sed 's#.*/\([a-z]*\.mjs\) .*#\1#' | tr '\n' ' ')"
done
```

```
A. Yilmaz / Ankara 06500
7360
GN-000001 Ankara/Cankaya weight
GN-000001 departed for Ankara
Ankara-Cankaya branch
A. Yilmaz / Ankara 06500
7360
GN-000001 Ankara/Cankaya weight
GN-000001 departed for Ankara
Ankara-Cankaya branch
chained: edited files = 5  -> branch.mjs label.mjs notification.mjs record.mjs report.mjs
short: edited files = 2  -> address.mjs record.mjs
```

Both versions keep producing the same five lines. The difference is in cost: the same
structural change required editing five files in the chained design and two files in the
short design. In the chained version, the fee module was the only one left untouched, because
it used only the `postalCode` field — which module gets affected, in other words, depended on
which field name that module reached into.

#### Misapplying the Law

The wrong way to shorten the chain is to write a delegating method for every intermediate
field. An interface that grows as `shipment.recipientName()`, `shipment.recipientProvince()`,
`shipment.recipientDistrict()`, `shipment.recipientPostalCode()` shortens the chain, but it
copies the same information into method names instead; the interface grows as fields are
added, and the interface bloat measured in the previous lesson comes back. In the short
version above, the `address` object does not return raw fields — it answers the question
being asked: `format`, `zoneCode`, `provinceDistrict`. These are the names of the clients'
questions, not the names of the fields.

The law also has an exception: in records that exist purely to carry data — a serialized
configuration object, or a parsed body at a boundary point — the chain is unavoidable and its
cost is low, because that record is already an external contract. The measure is not whether
a chain exists, but how many modules separately know the same structure.

#### Summary

- The law of Demeter limits the objects a function can reach to its own parts, its
  parameters, and the objects it constructs; its measure is the step count of the access
  chain.
- In the design where five clients reach directly into the record, 11 findings and a chain
  as deep as 4 steps were counted.
- Once the intermediate objects were given behavior, the finding count dropped to 5, and all
  five client modules dropped to zero findings; the remaining findings gathered in the two
  modules that resolve the structure.
- When the address record was restructured, 5 files were edited in the chained design and 2
  in the short design; both versions kept producing the same five lines.
- Shortening the chain with one delegating method per field does not fix the measure; the
  intermediate object should answer the clients' questions, not their field names.

#### Next Step

In the short version, the `address` object still returns values: `provinceDistrict()` hands
back a string, and the caller makes the decision. Branch selection takes that string and
formats it; notification uses another part of it. When the same rule repeats across several
call sites, the question changes: hand the data out and leave the decision to the client, or
tell the object the decision itself? The next lesson counts how many separate places the same
rule repeats in, and compares it with a version that moves the decision into the data.

### Tell, Don't Ask

Source: https://academia.sh/en/courses/design-principles/tell-dont-ask

Moving behavior into the data: comparing a design where four clients each build the same dispatch rule with a design that tells the object the decision, counting the number of files that know the rule's fields, measuring the clients' divergent result set, and finding the number of files edited when the rule changes.

In the Demeter lesson, chains got shorter, but the intermediate objects still returned
values: `provinceDistrict()` hands back a string, and the caller makes the decision. This
hides another form of the same problem. Every client that takes fields from an object and
decides by looking at those fields carries a copy of the rule inside itself. As the number
of copies grows, the rule no longer has a single definition.

**Tell, don't ask** targets this repetition: the object that owns the data is told what to
do, instead of having its data taken so the decision can be made outside it. Its measure is
two numbers — how many files know the fields the rule depends on, and how many files repeat
the same rule.

#### The Design That Writes the Rule Four Times

The dispatch rule consists of three conditions: the shipment must be in the warehouse, it
must be paid, and its weight must not exceed the limit. Four clients each build this rule
separately.

```sh
mkdir -p ask tell
```

```js
// ask/record.mjs — shipments awaiting dispatch
export const RECORDS = [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// ask/web.mjs — the HTTP endpoint builds the rule itself
export function dispatch(record) {
  if (record.state === "warehouse" && record.paid && record.weight <= 30) {
    return { httpStatus: 200, code: record.code };
  }
  return { httpStatus: 409, code: record.code };
}
```

```js
// ask/batch.mjs — the batch job rewrites the same rule
export const selected = (records) =>
  records
    .filter((r) => r.state === "warehouse" && r.paid && r.weight <= 30)
    .map((r) => r.code);
```

```js
// ask/courier.mjs — the courier app never wrote the weight condition
export const list = (records) =>
  records.filter((r) => r.state === "warehouse" && r.paid).map((r) => r.code);
```

```js
// ask/scheduled.mjs — the scheduled job writes the rule a third time
export function scan(records) {
  const out = [];
  for (const r of records) {
    if (r.state === "warehouse" && r.paid && r.weight <= 30) out.push(r.code);
  }
  return out;
}
```

#### The Design That Tells the Object the Decision

In the second design, the rule lives inside the shipment object. Clients do not read fields;
they tell the object to depart and take back the result.

```js
// tell/record.mjs — shipments awaiting dispatch
export const RECORDS = [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// tell/shipment.mjs — the rule lives inside the object, in one place
const WEIGHT_LIMIT = 30;

export const shipment = (record) => ({
  code: record.code,
  depart() {
    if (record.state !== "warehouse") return { ok: false, reason: "not_in_warehouse" };
    if (record.paid === false) return { ok: false, reason: "unpaid" };
    if (record.weight > WEIGHT_LIMIT) return { ok: false, reason: "weight_limit" };
    return { ok: true, reason: "departed" };
  },
});
```

```js
// tell/web.mjs — tells the object the decision
export function dispatch(s) {
  return { httpStatus: s.depart().ok ? 200 : 409, code: s.code };
}
```

```js
// tell/batch.mjs — gives the same command
export const selected = (shipments) =>
  shipments.filter((s) => s.depart().ok).map((s) => s.code);
```

```js
// tell/courier.mjs — gives the same command; no chance to write the condition
export const list = (shipments) =>
  shipments.filter((s) => s.depart().ok).map((s) => s.code);
```

```js
// tell/scheduled.mjs — gives the same command
export function scan(shipments) {
  const out = [];
  for (const s of shipments) if (s.depart().ok) out.push(s.code);
  return out;
}
```

#### Counting the Repetition

The first measure is how many files know the field names the rule depends on. A file is
counted as carrying a copy of the rule if it knows two or more of these fields; the data
file is excluded from this count.

```js
// rule-count.mjs — counts how many files know the fields the rule depends on
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const FIELDS = ["state", "paid", "weight"];
const root = process.argv[2];
let filesKnowing = 0;
let repeats = 0;

for (const d of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) {
  const text = readFileSync(join(root, d), "utf8").replace(/^\/\/.*$/gm, "");
  const known = FIELDS.filter((a) => new RegExp(`\\b${a}\\b`).test(text));
  if (known.length > 0) filesKnowing += 1;
  if (known.length >= 2 && d !== "record.mjs") repeats += 1;
  console.log(`  ${d.padEnd(12)} known fields=${known.length}  ${known.join(" ")}`);
}
console.log(`${root.padEnd(6)} files knowing the fields=${filesKnowing}, files repeating the rule=${repeats}`);
```

```sh
node rule-count.mjs ask
node rule-count.mjs tell
```

```
  batch.mjs    known fields=3  state paid weight
  courier.mjs  known fields=2  state paid
  record.mjs   known fields=3  state paid weight
  scheduled.mjs known fields=3  state paid weight
  web.mjs      known fields=3  state paid weight
ask    files knowing the fields=5, files repeating the rule=4
  batch.mjs    known fields=0
  courier.mjs  known fields=0
  record.mjs   known fields=3  state paid weight
  scheduled.mjs known fields=0
  shipment.mjs known fields=3  state paid weight
  web.mjs      known fields=0
tell   files knowing the fields=2, files repeating the rule=1
```

Four files repeat the rule in the first design; one in the second. The scan also showed
where the rule had already cracked: the courier module knows only two of the three fields.

#### The Divergent Result Set

The runtime counterpart of drift between copies is that clients working on the same data
produce different lists. The driver script runs all four clients on the same five shipments
and counts how many distinct results come out.

```js
// setup.mjs — four clients work with the same data, results are compared
const root = process.argv[2];
const { RECORDS } = await import(`./${root}/record.mjs`);
const { dispatch } = await import(`./${root}/web.mjs`);
const { selected } = await import(`./${root}/batch.mjs`);
const { list } = await import(`./${root}/courier.mjs`);
const { scan } = await import(`./${root}/scheduled.mjs`);

const input = root.startsWith("tell")
  ? RECORDS.map((await import(`./${root}/shipment.mjs`)).shipment)
  : RECORDS;

const results = {
  web: input.filter((r) => dispatch(r).httpStatus === 200).map((r) => r.code),
  batch: selected(input),
  courier: list(input),
  scheduled: scan(input),
};
for (const [name, arr] of Object.entries(results)) console.log(`${name.padEnd(9)} ${arr.join(" ")}`);
const distinct = new Set(Object.values(results).map((l) => l.join(" ")));
console.log(`${root}: distinct result set among clients = ${distinct.size}`);
```

```sh
node setup.mjs ask
node setup.mjs tell
```

```
web       GN-1 GN-5
batch     GN-1 GN-5
courier   GN-1 GN-2 GN-5
scheduled GN-1 GN-5
ask: distinct result set among clients = 2
web       GN-1 GN-5
batch     GN-1 GN-5
courier   GN-1 GN-5
scheduled GN-1 GN-5
tell: distinct result set among clients = 1
```

GN-2, at forty-five kilograms, shows up on the courier's list and nowhere else. In the
second design there is no room for this kind of drift, because there are no four separate
bodies of code to write the condition in.

#### When the Rule Changes

The weight limit drops from 30 to 20. Both trees are copied and the change is applied to
each. The in-place edit is given a backup extension; GNU and BSD `sed` behave the same way
with this form.

```sh
cp -r ask ask-new
cp -r tell tell-new
sed -i.y 's/weight <= 30/weight <= 20/' ask-new/*.mjs
sed -i.y 's/WEIGHT_LIMIT = 30/WEIGHT_LIMIT = 20/' tell-new/shipment.mjs
rm -f ask-new/*.y tell-new/*.y
node setup.mjs ask-new
node setup.mjs tell-new
for k in ask tell; do echo "$k: edited files = $(diff -rq $k $k-new | grep -c '^Files')"; done
```

```
web       GN-1
batch     GN-1
courier   GN-1 GN-2 GN-5
scheduled GN-1
ask-new: distinct result set among clients = 2
web       GN-1
batch     GN-1
courier   GN-1
scheduled GN-1
tell-new: distinct result set among clients = 1
ask: edited files = 3
tell: edited files = 1
```

Three files against one. Notice also that the drift grew: the courier's list used to carry
only the extra GN-2, and once the limit dropped, GN-5 was added too. Once the rule's copies
diverge, the divergence is permanent; it grows with every change to the rule.

#### The Limit of the Principle

Tell, don't ask does not forbid every field read. Handing a field out becomes a problem only
when a **decision** about that field is made outside the object. Reading the shipment code
to print it in a report is not a decision; looking at the shape of the code and deciding
whether the shipment is domestic is.

Overapplying the principle also produces a measurable cost. Adding a separate command method
to the object for every question grows the interface, and the bloat measured in the
Interface Segregation Principle lesson comes back. The criterion is not the number of
methods, but the number of files repeating the rule: if that number is greater than one, the
behavior sits in the wrong place.

#### Summary

- The tell, don't ask principle asks that the work behind a decision be told to the object
  that owns the data, instead of taking its fields and deciding outside it.
- The number of files that know the rule's fields dropped from 5 to 2, and the number of
  files repeating the rule dropped from 4 to 1.
- Repetition produced drift: the four clients gave 2 distinct result sets on the same data,
  while the single-definition version produced 1.
- When the weight limit changed, 3 files were edited in the repeating design and 1 in the
  single-definition design; the drift grew along with the rule change.
- The principle limits deciding on a field from outside, not reading the field itself; the
  criterion is the number of files repeating the rule.

#### Next Step

`depart` was named like a command, but it actually only returns a result: the shipment's
state does not change. A real dispatch operation should change the state too. What happens
when the same method both changes state and returns a value? Does calling the method twice
give the same result, does an extra call made just to write a log line change the system's
state? The next lesson demonstrates, with a runtime example, the surprise produced by an
operation that both changes state and returns a value, and establishes the measure for
separating the two responsibilities.

### Command-Query Separation

Source: https://academia.sh/en/courses/design-principles/command-query-separation

Separating operations that change state from ones that only read it: writing a scan that finds methods which both write and return, counting how many records a report-only path changes, and showing with a run that a log line writes the opposite of what actually happened.

The previous lesson's `depart` method was named like a command, but it only returned a
result — the shipment's state did not change. A real dispatch operation must change state
too. When a method takes on both jobs at once, a new question follows: what does calling the
same method a second time change?

**Command–query separation** was introduced in the Programming Fundamentals course: an
operation either changes state and returns no value (a command), or returns a value and
changes no state (a query). This lesson carries the principle into object design and measures
the cost of violating it with two numbers: the count of methods that both write and return,
and the number of records a read-only path changes.

#### Mixed Method

In the first design, `depart` both writes the `state` field and returns a result.

```sh
mkdir -p mixed separated
```

```js
// mixed/records.mjs — a generator so every trial starts with fresh records
export const newRecords = () => [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// mixed/shipment.mjs — a single method both changes state and returns a value
const WEIGHT_LIMIT = 30;

export const shipment = (record) => ({
  code: record.code,
  depart() {
    if (record.state !== "warehouse") return { ok: false, reason: "not_in_warehouse" };
    if (record.paid === false) return { ok: false, reason: "unpaid" };
    if (record.weight > WEIGHT_LIMIT) return { ok: false, reason: "weight_limit" };
    record.state = "in_transit";
    return { ok: true, reason: "departed" };
  },
});
```

```js
// mixed/dispatch.mjs — dispatches first, then re-queries the reason to write to the log
export function dispatch(shipments) {
  const departed = shipments.filter((s) => s.depart().ok).map((s) => s.code);
  const log = shipments.map((s) => `${s.code}=${s.depart().reason}`);
  return { departed, log };
}
```

```js
// mixed/report.mjs — a report that only wants to write the reasons
export const report = (shipments) => shipments.map((s) => `${s.code}=${s.depart().reason}`);
```

The report module does not want to dispatch anything; it only wants to write down each
shipment's state. With only one method available to ask, it calls that one.

#### Separated Methods

In the second design, the same information is split into two methods. `departureBlocker` is
a query: it reads state, it does not change it. `depart` is a command: it changes state and
returns no value; it throws for a shipment that cannot depart.

```js
// separated/records.mjs — a generator so every trial starts with fresh records
export const newRecords = () => [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// separated/shipment.mjs — the query does not change state, the command returns no value
const WEIGHT_LIMIT = 30;

export const shipment = (record) => ({
  code: record.code,
  departureBlocker() {
    if (record.state !== "warehouse") return "not_in_warehouse";
    if (record.paid === false) return "unpaid";
    if (record.weight > WEIGHT_LIMIT) return "weight_limit";
    return null;
  },
  depart() {
    const blocker = this.departureBlocker();
    if (blocker !== null) throw new RangeError(`${record.code} cannot depart: ${blocker}`);
    record.state = "in_transit";
  },
});
```

```js
// separated/dispatch.mjs — first asks, writes to the log, then issues the command
export function dispatch(shipments) {
  const departed = [];
  const log = [];
  for (const s of shipments) {
    const blocker = s.departureBlocker();
    log.push(`${s.code}=${blocker ?? "departed"}`);
    if (blocker === null) {
      s.depart();
      departed.push(s.code);
    }
  }
  return { departed, log };
}
```

```js
// separated/report.mjs — a report that only wants to write the reasons
export const report = (shipments) =>
  shipments.map((s) => `${s.code}=${s.departureBlocker() ?? "departed"}`);
```

#### The Surprise Shown by the Run

The driver script runs two trials. In the first, only the report is called, and the number of
records whose state the report changes is counted. In the second, the dispatch operation
runs, the log lines are written, and the same operation is called a second time.

```js
// setup.mjs — three trials: report, dispatch log, and calling the same operation a second time
const root = process.argv[2];
const { newRecords } = await import(`./${root}/records.mjs`);
const { shipment } = await import(`./${root}/shipment.mjs`);
const { report } = await import(`./${root}/report.mjs`);
const { dispatch } = await import(`./${root}/dispatch.mjs`);

const records = newRecords();
const before = records.map((r) => r.state);
console.log("report      ", report(records.map(shipment)).join(" "));
const after = records.map((r) => r.state);
console.log(`records changed by report = ${before.filter((d, i) => d !== after[i]).length}`);

const secondRecords = newRecords();
const shipments = secondRecords.map(shipment);
const first = dispatch(shipments);
console.log("dispatch 1 departed", first.departed.join(" ") || "(empty)");
console.log("dispatch 1 log", first.log.join(" "));
const second = dispatch(shipments);
console.log("dispatch 2 departed", second.departed.join(" ") || "(empty)");
```

```sh
node setup.mjs mixed
```

```
report       GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
records changed by report = 2
dispatch 1 departed GN-1 GN-5
dispatch 1 log GN-1=not_in_warehouse GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=not_in_warehouse
dispatch 2 departed (empty)
```

Two surprises at once. The report set two records' state to `in_transit`: a code path that
never meant to dispatch anything sent two shipments off. The log line, in turn, wrote the
opposite of what actually happened — `not_in_warehouse` shows up for the dispatched GN-1 and
GN-5, because by the time the reason was asked, the state had already changed.

```sh
node setup.mjs separated
```

```
report       GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
records changed by report = 0
dispatch 1 departed GN-1 GN-5
dispatch 1 log GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
dispatch 2 departed (empty)
```

The report line is the same, but the number of records it changes is zero. The log line now
writes the truth too. The dispatch result is identical in both versions; the separation did
not change behavior, it only made observation safe.

#### Found by Scanning

A violation can also be found without waiting for a run. The script below looks at the body
of object methods: a method that writes to the record is a command, one with a return value
is a query, and one with both is a finding.

```js
// command-query-scan.mjs — finds methods that both change and return state
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const METHOD = /^ {2}(\w+)\(/gm;

function body(text, opening) {
  let i = opening;
  let depth = 0;
  do {
    if (text[i] === "{") depth += 1;
    else if (text[i] === "}") depth -= 1;
    i += 1;
  } while (i < text.length && depth > 0);
  return text.slice(opening, i);
}

const root = process.argv[2];
let hits = 0;

for (const f of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) {
  const text = readFileSync(join(root, f), "utf8");
  for (const m of text.matchAll(METHOD)) {
    const b = body(text, text.indexOf("{", m.index + m[0].length));
    const writes = /\brecord\.\w+\s*=[^=]/.test(b);
    const returns = /\breturn\s+[^;\s]/.test(b);
    const kind = writes && returns ? "command+query" : writes ? "command" : returns ? "query" : "empty";
    if (writes && returns) hits += 1;
    console.log(`  ${f.padEnd(14)} ${m[1].padEnd(18)} writes=${writes} returns=${returns}  ${kind}`);
  }
}
console.log(`${root.padEnd(10)} methods that both write and return = ${hits}`);
```

```sh
node command-query-scan.mjs mixed
node command-query-scan.mjs separated
```

```
  shipment.mjs   depart             writes=true returns=true  command+query
mixed      methods that both write and return = 1
  shipment.mjs   departureBlocker   writes=false returns=true  query
  shipment.mjs   depart             writes=true returns=false  command
separated  methods that both write and return = 0
```

#### Accepted Exceptions to the Separation

The separation cannot be applied absolutely. An operation that pops an item off a stack both
changes state and returns the item it removed; splitting it apart can open a race between the
two calls. In the same way, an operation that inserts a record and returns the generated
identifier is also mixed, and splitting it would require looking the identifier up again with
a second query.

Two conditions make the exception acceptable. The first is that the returned value is the
operation's **result** — the popped item, the generated identifier — not a recomputed state.
The second is that the call is not repeated for the purpose of reading. Both conditions were
violated in the mixed version above: the returned value was not the dispatch's result but a
re-evaluation of the conditions, and it was called again for the log line.

Naming is therefore part of the measure. `departureBlocker` asks a question, `depart` gives a
command; the two getting mixed usually starts with the name getting mixed too.

#### Summary

- Command–query separation requires an operation to either change state and return no value,
  or return a value and change no state.
- The scan counts methods whose body both writes to the record and returns a value: the mixed
  design produced 1 finding, the separated design produced 0.
- The report-only code path changed 2 records' state in the mixed design and 0 records in the
  separated design.
- The log line wrote the opposite of the truth in the mixed design: `not_in_warehouse` showed
  up for the two dispatched shipments, because the state had already changed by the time the
  reason was asked.
- The exception is accepted when the returned value is the operation's result and the call is
  not repeated for reading; naming is the visible part of the separation.

#### Next Step

In the separated design, the `WEIGHT_LIMIT` constant sits inside the shipment module, and so
does the dispatch rule. The two decisions live in the same file, but they do not change for
the same reason: the limit moves when the price list is renewed, the rule moves when
organizational policy changes. When a module holds more than one axis of change, two changes
coming from two separate axes touch the same files and break each other's tests. The next
lesson derives the file sets touched by changes on two axes, measures their intersection, and
shows how isolating the axes empties that intersection.

### Encapsulate What Varies

Source: https://academia.sh/en/courses/design-principles/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.

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.

```sh
mkdir -p scattered isolated
```

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

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

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

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

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

```js
// isolated/format.mjs — second axis: the output format lives only here
export const amount = (cents) => `${cents} cents`;
```

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

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

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

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

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

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

### Programming to Abstractions

Source: https://academia.sh/en/courses/design-principles/programming-to-abstractions

Counting the cost of binding to a concrete type: comparing the number of files edited and lines touched in two layouts when a second price list is added, finding the number of places that make the choice, and showing that a fake price list only takes effect for the client bound to an abstraction.

In the previous lesson's isolated layout, all three clients imported the price module by name
and called its function directly. As long as there was only one price list, this stayed
invisible. When a second list arrives for contracted customers, the question sharpens: how
will the client know which list to use?

**Programming to abstractions** means the client binds to a contract that multiple
implementations can satisfy, not to a concrete module. The dependency inversion lesson
measured which package the contract is **defined** in; the measure here is on the client
side: how many lines are touched when the implementation changes, or when a second one is
added?

#### Layout Bound to a Concrete Module

```sh
mkdir -p concrete abstract
```

```js
// records.mjs — sample shipments used by both layouts
export const RECORDS = [
  { code: "GN-1", weight: 0.8, address: "34100", contracted: false },
  { code: "GN-2", weight: 3.0, address: "06500", contracted: true },
  { code: "GN-3", weight: 12.0, address: "65200", contracted: true },
];
```

```js
// concrete/price.mjs — the standard price list
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);
}
```

```js
// concrete/web.mjs — imports the concrete price module by name
import { calculateCents } from "./price.mjs";

export const line = (shipment) => `${shipment.code} ${calculateCents(shipment)} cents`;
```

```js
// concrete/batch.mjs — imports the concrete price module by name
import { calculateCents } from "./price.mjs";

export const lines = (shipments) =>
  shipments.map((s) => `${s.code} ${calculateCents(s)} cents`);
```

```js
// concrete/report.mjs — imports the concrete price module by name
import { calculateCents } from "./price.mjs";

export const totalLine = (shipments) =>
  `TOTAL ${shipments.reduce((t, s) => t + calculateCents(s), 0)} cents`;
```

```js
// concrete/setup.mjs — composition root
import { RECORDS } from "../records.mjs";
import { line } from "./web.mjs";
import { lines } from "./batch.mjs";
import { totalLine } from "./report.mjs";

console.log(line(RECORDS[1]));
console.log(lines(RECORDS).join(" | "));
console.log(totalLine(RECORDS));
```

#### Layout Bound to an Abstraction

In the second layout, the price list is an object given to the clients from outside. The
clients know that a method named `calculateCents` exists; they do not know which list is
arriving.

```js
// abstract/price-standard.mjs — a standard list that satisfies the contract
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const standard = {
  name: "standard",
  calculateCents(shipment) {
    const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 15000;
    return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 160)) / 100);
  },
};
```

```js
// abstract/web.mjs — takes the price from outside, does not know the implementation's name
export const line = (price, shipment) => `${shipment.code} ${price.calculateCents(shipment)} cents`;
```

```js
// abstract/batch.mjs — takes the price from outside
export const lines = (selectPrice, shipments) =>
  shipments.map((s) => `${s.code} ${selectPrice(s).calculateCents(s)} cents`);
```

```js
// abstract/report.mjs — takes the price from outside
export const totalLine = (selectPrice, shipments) =>
  `TOTAL ${shipments.reduce((t, s) => t + selectPrice(s).calculateCents(s), 0)} cents`;
```

```js
// abstract/setup.mjs — composition root: only this file knows which price list is used
import { RECORDS } from "../records.mjs";
import { standard } from "./price-standard.mjs";
import { line } from "./web.mjs";
import { lines } from "./batch.mjs";
import { totalLine } from "./report.mjs";

const selectPrice = () => standard;

console.log(line(selectPrice(RECORDS[1]), RECORDS[1]));
console.log(lines(selectPrice, RECORDS).join(" | "));
console.log(totalLine(selectPrice, RECORDS));
```

```sh
node concrete/setup.mjs
node abstract/setup.mjs
```

```
GN-2 7360 cents
GN-1 3900 cents | GN-2 7360 cents | GN-3 16520 cents
TOTAL 27780 cents
GN-2 7360 cents
GN-1 3900 cents | GN-2 7360 cents | GN-3 16520 cents
TOTAL 27780 cents
```

#### Second Implementation

A contracted customer list arrives: the tier fees and zone factors differ. Both trees are
copied and the requirement is applied to both.

```sh
cp -r concrete concrete-new
cp -r abstract abstract-new
```

```js
// concrete-new/price-contracted.mjs — contracted customer price list
const TIER = [[1, 3200], [5, 5300], [20, 9700]];
const ZONE = { "34": 100, "06": 110, "65": 130 };

export function calculateCents(shipment) {
  const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 13000;
  return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 150)) / 100);
}
```

```js
// concrete-new/web.mjs — imports both concrete modules and chooses between them
import { calculateCents } from "./price.mjs";
import { calculateCents as contractedCents } from "./price-contracted.mjs";

export const line = (shipment) =>
  `${shipment.code} ${shipment.contracted ? contractedCents(shipment) : calculateCents(shipment)} cents`;
```

```js
// concrete-new/batch.mjs — the same choice a second time
import { calculateCents } from "./price.mjs";
import { calculateCents as contractedCents } from "./price-contracted.mjs";

export const lines = (shipments) =>
  shipments.map((s) =>
    `${s.code} ${s.contracted ? contractedCents(s) : calculateCents(s)} cents`);
```

```js
// concrete-new/report.mjs — the same choice a third time
import { calculateCents } from "./price.mjs";
import { calculateCents as contractedCents } from "./price-contracted.mjs";

export const totalLine = (shipments) =>
  `TOTAL ${shipments.reduce((t, s) =>
    t + (s.contracted ? contractedCents(s) : calculateCents(s)), 0)} cents`;
```

The clients are not opened in the abstract layout. The new list is a second object that
satisfies the same contract, and the choice is made in the composition root.

```js
// abstract-new/price-contracted.mjs — a second list satisfying the same contract
const TIER = [[1, 3200], [5, 5300], [20, 9700]];
const ZONE = { "34": 100, "06": 110, "65": 130 };

export const contracted = {
  name: "contracted",
  calculateCents(shipment) {
    const base = TIER.find(([max]) => shipment.weight <= max)?.[1] ?? 13000;
    return Math.round((base * (ZONE[shipment.address.slice(0, 2)] ?? 150)) / 100);
  },
};
```

```js
// abstract-new/setup.mjs — composition root: only this file knows which price list is used
import { RECORDS } from "../records.mjs";
import { standard } from "./price-standard.mjs";
import { contracted } from "./price-contracted.mjs";
import { line } from "./web.mjs";
import { lines } from "./batch.mjs";
import { totalLine } from "./report.mjs";

const selectPrice = (s) => (s.contracted ? contracted : standard);

console.log(line(selectPrice(RECORDS[1]), RECORDS[1]));
console.log(lines(selectPrice, RECORDS).join(" | "));
console.log(totalLine(selectPrice, RECORDS));
```

#### Counting the Cost

```sh
node concrete-new/setup.mjs
node abstract-new/setup.mjs
for k in concrete abstract; do
  echo "$k: files edited = $(diff -rq $k $k-new | grep -c '^Files')" \
    " new files = $(diff -rq $k $k-new | grep -c '^Only in')" \
    " lines touched = $(diff -rU0 $k $k-new | grep -cE '^[+-][^+-]')" \
    " files making the choice = $(grep -l 'contracted ?' $k-new/*.mjs | wc -l | tr -d ' ')"
done
```

```
GN-2 5830 cents
GN-1 3900 cents | GN-2 5830 cents | GN-3 12610 cents
TOTAL 22340 cents
GN-2 5830 cents
GN-1 3900 cents | GN-2 5830 cents | GN-3 12610 cents
TOTAL 22340 cents
concrete: files edited = 3  new files = 1  lines touched = 18  files making the choice = 3
abstract: files edited = 1  new files = 1  lines touched = 5  files making the choice = 1
```

Same requirement, same result, different cost: eighteen lines against five, three files
edited against one. The last column gives the reason for the difference — in the concrete
layout, the choice is made in three separate places. When a third list is added, all three of
those places will be edited again; in the abstract layout, the place to edit will remain the
composition root.

#### Substitutability

The second measure is whether the implementation can be swapped out during testing. The same
client is given a fake price list that produces a fixed value.

```js
// try-fake.mjs — can clients work with a fake price list
const FAKE = { name: "fake", calculateCents: () => 1000 };
const G = { code: "GN-9", weight: 3.0, address: "06500", contracted: false };

const concreteWeb = await import("./concrete/web.mjs");
const abstractWeb = await import("./abstract/web.mjs");

console.log("concrete ->", concreteWeb.line(G));
console.log("abstract ->", abstractWeb.line(FAKE, G));
```

```sh
node try-fake.mjs
```

```
concrete -> GN-9 7360 cents
abstract -> GN-9 1000 cents
```

In the concrete layout, there is no place to hand the fake list to; the client ignored it and
calculated the real price. In the abstract layout, the same client used the fake. The
difference is not a convenience, it is structural: a concrete import leaves no point of
choice.

#### The Cost of the Principle

Abstraction is not free. In the abstract layout, all three clients' signatures grew by one
parameter, and the `selectPrice` function built an extra layer of indirection in the
composition root. If the implementation stays single, this extra cost is never recovered: the
measurement paid off once a second implementation arrived; had none arrived, it would only
have increased the parameter count.

The criterion is therefore the history of implementation count. If a module has never needed
a second implementation, and testing does not require swapping it either, a concrete import
is the right choice. If testing requires swapping it, the implementation count is already
two; a fake is an implementation too.

#### Summary

- Programming to abstractions means the client binds to a contract that multiple
  implementations can satisfy, not to a concrete module.
- When the second price list was added, the concrete layout touched 3 files and 18 lines, the
  abstract layout touched 1 file and 5 lines.
- The source of the difference is the point of choice: the concrete layout makes the choice
  in 3 files, the abstract layout in 1.
- A fake price list took effect only in the abstract layout; a concrete import leaves no
  point at which the implementation can be swapped.
- The cost of the abstraction is the parameter count and a layer of indirection; if a second
  implementation never arrives, this cost is never recovered.

#### Next Step

In the abstract layout, the composition root decides which price list is used, but it still
calls the clients itself: first `line`, then `lines`, then `totalLine`. The order and count of
the steps live in the caller's body. Adding a new step to this flow — a contracted customer
discount, an insurance premium, an audit line — requires editing the caller every time. The
next lesson reverses the direction between caller and callee: it moves the order of the steps
into a skeleton, moves the steps themselves into parts handed to the skeleton, and compares
the cost of adding a new step in the two arrangements.

### Hollywood Principle

Source: https://academia.sh/en/courses/design-principles/hollywood-principle

Reversing the direction between caller and callee: comparing an arrangement where three separate callers write the step order against one where the steps register themselves with a skeleton, counting how many files know the order, and measuring the files edited and lines touched when a new step is added.

In the previous lesson's abstract layout, the composition root decided which price list to
use, but it still called the clients itself. The order and count of the steps lived in the
caller's body. Adding a new step to this flow required editing the caller's body every time.

**The Hollywood principle** reverses the direction: "do not call us, we will call you." Parts do
not call the high-level flow; the high-level flow gives the parts a point to be called from.
**Inversion of control**, introduced in the Asynchronous JavaScript and the Runtime course, is
the general name for this principle; what is measured here is a specific form of it — which
module the step order is written in.

#### Arrangement Where the Caller Writes the Order

The fee calculation consists of three steps: the base fee, the zone factor, the contracted
customer discount. Three separate callers write this order in their own body.

```sh
mkdir -p caller skeleton
```

```js
// records.mjs — sample shipments used by both arrangements
export const RECORDS = [
  { code: "GN-1", weight: 0.8, address: "34100", contracted: false },
  { code: "GN-2", weight: 3.0, address: "06500", contracted: true },
  { code: "GN-3", weight: 12.0, address: "65200", contracted: true },
];
```

```js
// caller/steps.mjs — steps; they do not know the order, they do not know the caller either
const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

export const base = (cents, s) => TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000;
export const zone = (cents, s) => Math.round((cents * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100);
export const discount = (cents, s) => Math.round((cents * (s.contracted ? 90 : 100)) / 100);
```

```js
// caller/invoice.mjs — the caller writes the order
import { base, zone, discount } from "./steps.mjs";

export function invoice(shipment) {
  let cents = base(0, shipment);
  cents = zone(cents, shipment);
  cents = discount(cents, shipment);
  return `${shipment.code} ${cents} cents`;
}
```

```js
// caller/preview.mjs — writes the same order a second time
import { base, zone, discount } from "./steps.mjs";

export function preview(shipment) {
  let cents = base(0, shipment);
  cents = zone(cents, shipment);
  cents = discount(cents, shipment);
  return `~${cents}`;
}
```

```js
// caller/batch.mjs — writes the same order a third time
import { base, zone, discount } from "./steps.mjs";

export function total(shipments) {
  let grandTotal = 0;
  for (const s of shipments) {
    let cents = base(0, s);
    cents = zone(cents, s);
    cents = discount(cents, s);
    grandTotal += cents;
  }
  return `TOTAL ${grandTotal} cents`;
}
```

```js
// caller/setup.mjs — composition root
import { RECORDS } from "../records.mjs";
import { invoice } from "./invoice.mjs";
import { preview } from "./preview.mjs";
import { total } from "./batch.mjs";

console.log(invoice(RECORDS[1]), preview(RECORDS[1]), total(RECORDS));
```

#### Arrangement Where the Skeleton Knows the Order

In the second arrangement, the order lives in a skeleton. The steps do not call the skeleton;
they register themselves with it and are called when their turn comes.

```js
// skeleton/pipeline.mjs — skeleton: calls the steps in order, does not know their names in advance
const STEPS = [];

export const addStep = (name, fn) => STEPS.push({ name, fn });
export const stepNames = () => STEPS.map((s) => s.name);

export function run(shipment) {
  let cents = 0;
  for (const s of STEPS) cents = s.fn(cents, shipment);
  return cents;
}
```

```js
// skeleton/steps.mjs — steps register themselves with the skeleton; they do not call it
import { addStep } from "./pipeline.mjs";

const TIER = [[1, 3900], [5, 6400], [20, 11800]];
const ZONE = { "34": 100, "06": 115, "65": 140 };

addStep("base", (cents, s) => TIER.find(([max]) => s.weight <= max)?.[1] ?? 15000);
addStep("zone", (cents, s) => Math.round((cents * (ZONE[s.address.slice(0, 2)] ?? 160)) / 100));
addStep("discount", (cents, s) => Math.round((cents * (s.contracted ? 90 : 100)) / 100));
```

```js
// skeleton/invoice.mjs — does not know the order, calls the skeleton
import { run } from "./pipeline.mjs";

export const invoice = (shipment) => `${shipment.code} ${run(shipment)} cents`;
```

```js
// skeleton/preview.mjs — does not know the order
import { run } from "./pipeline.mjs";

export const preview = (shipment) => `~${run(shipment)}`;
```

```js
// skeleton/batch.mjs — does not know the order
import { run } from "./pipeline.mjs";

export const total = (shipments) =>
  `TOTAL ${shipments.reduce((t, s) => t + run(s), 0)} cents`;
```

```js
// skeleton/setup.mjs — composition root: loads the steps, then calls the clients
import "./steps.mjs";
import { RECORDS } from "../records.mjs";
import { stepNames } from "./pipeline.mjs";
import { invoice } from "./invoice.mjs";
import { preview } from "./preview.mjs";
import { total } from "./batch.mjs";

console.log(invoice(RECORDS[1]), preview(RECORDS[1]), total(RECORDS));
console.log("steps:", stepNames().join(" -> "));
```

```sh
node caller/setup.mjs
node skeleton/setup.mjs
```

```
GN-2 6624 cents ~6624 TOTAL 25392 cents
GN-2 6624 cents ~6624 TOTAL 25392 cents
steps: base -> zone -> discount
```

The same result. The difference is what shows up in the `stepNames()` line: in the second
arrangement, the step list is data that can be queried at run time; in the first, it is a
control flow scattered across three separate bodies.

#### The Cost of a New Step

An insurance premium step is being added: the amount rises by two percent after the discount.
In the first arrangement, a new function is written and added to three callers' bodies; in
the second, a single registration line is enough. A backup extension is given for the
in-place edit; GNU and BSD `sed` behave the same way in this form.

```sh
cp -r caller caller-new
cp -r skeleton skeleton-new

cat >> caller-new/steps.mjs <<'EOF'
export const insurance = (cents) => Math.round((cents * 102) / 100);
EOF
sed -i.y 's/base, zone, discount/base, zone, discount, insurance/' caller-new/*.mjs
sed -i.y 's/^  cents = discount(cents, shipment);/&\n  cents = insurance(cents);/' caller-new/*.mjs
sed -i.y 's/^    cents = discount(cents, s);/&\n    cents = insurance(cents);/' caller-new/*.mjs
rm -f caller-new/*.y

cat >> skeleton-new/steps.mjs <<'EOF'
addStep("insurance", (cents) => Math.round((cents * 102) / 100));
EOF

node caller-new/setup.mjs
node skeleton-new/setup.mjs
for k in caller skeleton; do
  echo "$k: files edited = $(diff -rq $k $k-new | grep -c '^Files')" \
    " lines touched = $(diff -rU0 $k $k-new | grep -cE '^[+-][^+-]')" \
    " files that know the order = $(grep -l 'cents = ' $k/*.mjs | wc -l | tr -d ' ')"
done
```

```
GN-2 6756 cents ~6756 TOTAL 25899 cents
GN-2 6756 cents ~6756 TOTAL 25899 cents
steps: base -> zone -> discount -> insurance
caller: files edited = 4  lines touched = 10  files that know the order = 3
skeleton: files edited = 1  lines touched = 1  files that know the order = 1
```

Both arrangements produce the same new result. The cost is four files and ten lines against
one file and one line. The last column gives the reason: the first arrangement had three
files that knew the order, the second had one.

The measure says something different from what the open–closed principle measures. There,
the cost of adding a new **type** was counted; here, what is counted is the cost of adding a
new **step** to an existing flow. Both point in the same direction: the change lands on data,
not on text.

#### Reversing the Direction

The difference between the two arrangements also shows up in the import graph. In the first
arrangement, `invoice.mjs` imports the steps and calls them; control flows top-down. In the
second, `steps.mjs` imports the skeleton and registers with it; the call direction is built
bottom-up, while the execution direction stays top-down. The principle carries this name
because the relationship between caller and callee is reversed.

This reversal is not the same thing as dependency inversion. There, the question was which
side the abstraction is **defined** on; here, the question is which side the control flow is
**written** on. The two principles are used together: the skeleton defines the steps'
contract (dependency inversion) and also holds the order in which the steps are called
(Hollywood principle).

#### The Cost of the Skeleton

The reversal's cost is paid in readability. In the first arrangement, reading a single body
is enough to see the flow; in the second, the order depends on the run-time arrangement of the
registration calls, and that arrangement comes from the composition root's import order. A
query like `stepNames()` is therefore necessary: once the order becomes data, that data has to
be readable.

The second cost is in debugging. When a step produces a wrong result, the call chain shows up
directly in the stack trace in the first arrangement; in the second, the skeleton's loop gets
in the way. The criterion is again a number: if the number of files that know the order is
greater than one, and requests to add a step to the flow keep recurring, the skeleton wins; if
the flow is fixed and there is a single caller, the skeleton is only a layer of indirection.

#### Summary

- The Hollywood principle reverses the direction between caller and callee: parts do not call
  the flow, the flow gives parts a point to be called from.
- The measure is how many files the step order is written in: 3 files knew the order in the
  caller arrangement, 1 in the skeleton arrangement.
- When a new step was added to the flow, the caller arrangement had 4 files and 10 lines
  edited, the skeleton arrangement had 1 file and 1 line.
- In the skeleton arrangement, the step list is data that can be queried at run time; in the
  caller arrangement, it is a control flow scattered across three separate bodies.
- The cost is in readability and debugging; with a single caller and a fixed flow, the
  skeleton only adds a layer of indirection.

#### Next Step

Every measure up to this point has been about **pairs of modules**: the strength of the bond
between two modules, how related the names inside one module are, how many objects a call
passes through, the file set a change touches. Once a codebase grows, the real question
changes shape — it is no longer which module binds to which, but where the boundary **gets
drawn**. The answer to that question is hidden in how often decisions change — which decision
is a stable policy that stands for years, and which is a detail that moves every time the
price list is renewed. The next topic builds this distinction and measures both sides of the
boundary by how often they change.

## Boundaries and Component Principles

Separating stable policy from changeable detail, narrowing the contract surface between modules, putting an outside tool dependency behind an adapter, extracting the component boundary from files that change together, measuring component stability, and finding cycles and layer violations in the dependency graph.

### Policy and Detail Separation

Source: https://academia.sh/en/courses/design-principles/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.

### Drawing Boundaries

Source: https://academia.sh/en/courses/design-principles/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.

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.

```sh
mkdir -p wide narrow
```

In the first design, the fee module also exposes its tables and its intermediate steps.

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

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

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

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

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

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

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

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

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

### Keeping Framework Code at Arm's Length

Source: https://academia.sh/en/courses/design-principles/keeping-framework-code-at-arms-length

Isolating a framework dependency: counting, under two layouts, the files that touch a small external tool's contract; comparing how many files must be fixed in each layout when the tool's contract changes; and measuring the adapter's cost in files and lines.

The previous lesson measured the contract surface with three numbers and showed that the
narrow boundary protected the library's own internal naming. Both sides of that boundary were
the library's own code: the library wrote the contract, and the library also decided to narrow
it.

Outside the library stands a class of code whose contract someone else wrote — tools that
handle requests, match paths, call the handler. These impose a contract the library is not
consulted on when it changes. This lesson counts, under two layouts, how many files touch such
a tool's contract, then changes the contract and compares which files need fixing.

#### The Difference Between a Framework and a Library

The split is not in function, it is in the direction of control. A library **is called**: the
side writing the order of calls is the library. A **framework calls**: what gets written is a
handler in the shape it expects, and the framework decides when it runs. The Hollywood
principle from the Coupling and Cohesion topic is the name for this direction; the same
inversion was established on the interface side in the Component-Based Interface Development
course.

The cost of the reversed direction is that the dependency does not stay put in a signature. If
a library changes, the call sites get fixed, and they can be searched for. If a framework
changes, every place reading fields off the **context** object it hands over gets fixed. How
many such places there are is not a product choice, it is a measurable design decision.

A small outside tool is written for the measurement: a router that matches path patterns and
calls the matching handler with its own context object, in a six-name vocabulary.

```sh
mkdir -p tool direct adapter
```

```js
// tool/request-router.mjs — external tool v1: matches path patterns, calls the handler with its own context
export function createRouter() {
  const registrations = [];
  const matchPattern = (pattern, path) => {
    const d = pattern.split("/"), p = path.split("/");
    if (d.length !== p.length) return null;
    const captured = {};
    for (let i = 0; i < d.length; i += 1) {
      if (d[i][0] === ":") captured[d[i].slice(1)] = p[i];
      else if (d[i] !== p[i]) return null;
    }
    return captured;
  };
  return {
    addPattern: (pattern, handler) => registrations.push([pattern, handler]),
    handleRequest(path, fields = {}) {
      for (const [pattern, handler] of registrations) {
        const match = matchPattern(pattern, path);
        if (match === null) continue;
        let response = null;
        handler({ params: match, query: fields,
          reply: (code, body) => { response = { code, body }; } });
        return response ?? { code: 500, body: { error: "no response produced" } };
      }
      return { code: 404, body: { error: "path not found" } };
    },
  };
}
```

The fee tables are shared between both layouts and never mention the tool.

```js
// tariff.mjs — the tables both layouts use; never mentions the tool
const TIERS = [
  { maxWeight: 1, fee: 4990 }, { maxWeight: 5, fee: 8490 },
  { maxWeight: 15, fee: 14990 }, { maxWeight: 30, fee: 24990 },
];
const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 };
const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" };
const MINIMUM_FEE = 3990;

export function baseFee(weight, postalCode) {
  const tier = TIERS.find((t) => weight <= t.maxWeight);
  if (tier === undefined) return null;
  const factor = ZONE_FACTOR[POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"];
  return Math.max(Math.round((tier.fee * factor) / 50) * 50, MINIMUM_FEE);
}
```

#### The Directly Bound Layout

In the first layout, the business rule reads the tool's context directly and hands its
response back the same way. Even validation is bound to the tool's vocabulary.

```js
// direct/validation.mjs — validation also returns its response through the tool's context
export function validateWeight(context) {
  const weight = Number(context.params.weight);
  if (Number.isFinite(weight) && weight > 0) return weight;
  context.reply(400, { error: "invalid weight" });
  return null;
}
```

```js
// direct/fee-endpoint.mjs — the calculation reads from and writes to the tool's context directly
import { baseFee } from "../tariff.mjs";
import { validateWeight } from "./validation.mjs";

export function feeHandler(context) {
  const weight = validateWeight(context);
  if (weight === null) return;
  const amount = baseFee(weight, context.params.postalCode);
  if (amount === null) { context.reply(422, { error: "no weight tier" }); return; }
  const discount = Number(context.query.discount ?? 0);
  context.reply(200, { amount: Math.round(amount * (1 - discount)), currency: "cents" });
}
```

```js
// direct/carrier-endpoint.mjs — a second endpoint, bound to the same context contract
import { baseFee } from "../tariff.mjs";

const CARRIERS = [
  { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 },
];

export function carrierHandler(context) {
  const base = baseFee(Number(context.params.weight), context.params.postalCode);
  if (base === null) { context.reply(422, { error: "no weight tier" }); return; }
  const cheapest = CARRIERS
    .map((c) => ({ carrier: c.name, amount: Math.round(base * c.multiplier) }))
    .sort((a, b) => a.amount - b.amount)[0];
  context.reply(200, cheapest);
}
```

```js
// direct/app.mjs — sets up the tool and registers the endpoints
import { createRouter } from "../tool/request-router.mjs";
import { feeHandler } from "./fee-endpoint.mjs";
import { carrierHandler } from "./carrier-endpoint.mjs";

export function app() {
  const r = createRouter();
  r.addPattern("/fee/:weight/:postalCode", feeHandler);
  r.addPattern("/carrier/:weight/:postalCode", carrierHandler);
  return (path, fields) => r.handleRequest(path, fields);
}
```

#### The Layout Behind the Adapter

In the second layout, the business rule is a plain operation: `(request) => response`. Both the
request's and the response's field names are the library's own choice, not the tool's. A
single file does the translation between the tool and this shape; this file is called an
**adapter**.

```js
// adapter/validation.mjs — validation returns a plain result, no tool name appears
export function validateWeight(values) {
  const weight = Number(values.weight);
  if (Number.isFinite(weight) && weight > 0) return { valid: true, weight };
  return { valid: false, response: { status: 400, content: { error: "invalid weight" } } };
}
```

```js
// adapter/fee-operation.mjs — the operation takes a plain request, returns a plain response
import { baseFee } from "../tariff.mjs";
import { validateWeight } from "./validation.mjs";

export function feeOperation(request) {
  const v = validateWeight(request.values);
  if (!v.valid) return v.response;
  const amount = baseFee(v.weight, request.values.postalCode);
  if (amount === null) return { status: 422, content: { error: "no weight tier" } };
  const discount = Number(request.options.discount ?? 0);
  return { status: 200, content: { amount: Math.round(amount * (1 - discount)), currency: "cents" } };
}
```

```js
// adapter/carrier-operation.mjs — second operation, same plain contract
import { baseFee } from "../tariff.mjs";

const CARRIERS = [
  { name: "fast", multiplier: 1.25 }, { name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 },
];

export function carrierOperation(request) {
  const base = baseFee(Number(request.values.weight), request.values.postalCode);
  if (base === null) return { status: 422, content: { error: "no weight tier" } };
  const cheapest = CARRIERS
    .map((c) => ({ carrier: c.name, amount: Math.round(base * c.multiplier) }))
    .sort((a, b) => a.amount - b.amount)[0];
  return { status: 200, content: cheapest };
}
```

```js
// adapter/tool-bridge.mjs — the one file touching the tool: translates its context to a plain request, plain response to its context
import { createRouter } from "../tool/request-router.mjs";

export function setupApp(registrations) {
  const r = createRouter();
  for (const [pattern, operation] of registrations) {
    r.addPattern(pattern, (context) => {
      const response = operation({ values: context.params, options: context.query });
      context.reply(response.status, response.content);
    });
  }
  return (path, fields) => r.handleRequest(path, fields);
}
```

```js
// adapter/app.mjs — registration file; no name from the tool's vocabulary appears
import { setupApp } from "./tool-bridge.mjs";
import { feeOperation } from "./fee-operation.mjs";
import { carrierOperation } from "./carrier-operation.mjs";

export const app = () => setupApp([
  ["/fee/:weight/:postalCode", feeOperation],
  ["/carrier/:weight/:postalCode", carrierOperation],
]);
```

For the comparison to mean anything, the two layouts have to answer the same requests the same
way.

```js
// run.mjs — do the two layouts answer the same requests the same way
import { app as directApp } from "./direct/app.mjs";
import { app as bridged } from "./adapter/app.mjs";

const REQUESTS = [
  ["/fee/3/06800", {}], ["/fee/12/65100", { discount: 0.1 }],
  ["/fee/0/34710", {}], ["/carrier/28/35400", {}], ["/route/1/34000", {}],
];

const a = directApp(), b = bridged();
let diverged = 0;
for (const [path, query] of REQUESTS) {
  const x = JSON.stringify(a(path, query)), y = JSON.stringify(b(path, query));
  if (x !== y) diverged += 1;
  console.log(`${path.padEnd(22)} ${x}`);
}
console.log(`diverged responses = ${diverged} / ${REQUESTS.length}`);
```

```
/fee/3/06800           {"code":200,"body":{"amount":11450,"currency":"cents"}}
/fee/12/65100          {"code":200,"body":{"amount":24300,"currency":"cents"}}
/fee/0/34710           {"code":400,"body":{"error":"invalid weight"}}
/carrier/28/35400      {"code":200,"body":{"carrier":"economy","amount":28688}}
/route/1/34000         {"code":404,"body":{"error":"path not found"}}
diverged responses = 0 / 5
```

The same status code and the same body for all five requests. Including the invalid weight,
the off-tier weight, and the undefined path, the difference is not in the behavior.

#### The Number of Files Touching the Tool

The measure of isolation is the number of files carrying at least one name from the tool's
vocabulary. The vocabulary consists of six names: the constructor, the registration and
execution names, and three fields of the context.

```js
// count-touched-files.mjs — counts files carrying at least one name from the tool's vocabulary
import { readdirSync, readFileSync } from "node:fs";

const VOCABULARY = ["createRouter", "addPattern", "handleRequest", "params", "query", "reply"];

for (const folder of ["direct", "adapter"]) {
  const files = readdirSync(folder).filter((d) => d.endsWith(".mjs")).sort();
  let touched = 0;
  console.log(`${folder}/`);
  for (const f of files) {
    const text = readFileSync(`${folder}/${f}`, "utf8");
    const matches = VOCABULARY.filter((name) => new RegExp(`\\b${name}\\b`).test(text));
    if (matches.length > 0) touched += 1;
    console.log(`  ${f.padEnd(24)} ${matches.length > 0 ? matches.join(" ") : "-"}`);
  }
  console.log(`  files touching the tool = ${touched} / ${files.length}`);
}
```

```
direct/
  app.mjs                  createRouter addPattern handleRequest
  carrier-endpoint.mjs     params reply
  fee-endpoint.mjs         params query reply
  validation.mjs           params reply
  files touching the tool = 4 / 4
adapter/
  app.mjs                  -
  carrier-operation.mjs    -
  fee-operation.mjs        -
  tool-bridge.mjs          createRouter addPattern handleRequest params query reply
  validation.mjs           -
  files touching the tool = 1 / 5
```

In the first layout, all four files are bound to the tool; in the second, one of five. A
notable line is `direct/validation.mjs`: even without an endpoint in it, weight validation
ended up in the tool's vocabulary. Binding to the framework does not stay at the endpoints; it
walks inward, following the error path.

#### When the Tool's Contract Changes

Numbers only pay off once a change arrives. The tool's second version changes three names on
the context: `pathValues` instead of `params`, `queryValues` instead of `query`, and a
single-object `send` instead of the two-argument `reply`. The new version is derived from the
first and placed in a separate tree.

```sh
mkdir -p v2 && cp -r tool direct adapter tariff.mjs run.mjs v2/
sed -e 's/params: match, query: fields,/pathValues: match, queryValues: fields,/' \
    -e 's/reply: (code, body) => { response = { code, body }; }/send: ({ status, body }) => { response = { code: status, body }; }/' \
    tool/request-router.mjs > v2/tool/request-router.mjs
grep -n "pathValues\|send:" v2/tool/request-router.mjs
```

```
21:        handler({ pathValues: match, queryValues: fields,
22:          send: ({ status, body }) => { response = { code: status, body }; } });
```

The only place changing inside the tool itself is the two lines where the context object is
built. Now the same change is applied to both layouts and the fixed files are counted. The
in-place edit flag is spelled differently between BSD and GNU `sed`, so the `-i.bak` form is
used; it leaves a backup file alongside.

```sh
sed -i.bak -e 's/context\.params/context.pathValues/g' \
           -e 's/context\.query/context.queryValues/g' \
           -e 's/context\.reply(\([^,]*\), \(.*\))/context.send({ status: \1, body: \2 })/g' \
           v2/direct/*.mjs v2/adapter/*.mjs
for d in direct adapter; do
  n=0; t=0
  for f in "$d"/*.mjs; do
    t=$((t + 1)); cmp -s "$f" "v2/$f" || n=$((n + 1))
  done
  echo "$d: fixed files = $n / $t"
done
cd v2 && node run.mjs
```

```
direct: fixed files = 3 / 4
adapter: fixed files = 1 / 5
/fee/3/06800           {"code":200,"body":{"amount":11450,"currency":"cents"}}
/fee/12/65100          {"code":200,"body":{"amount":24300,"currency":"cents"}}
/fee/0/34710           {"code":400,"body":{"error":"invalid weight"}}
/carrier/28/35400      {"code":200,"body":{"carrier":"economy","amount":28688}}
/route/1/34000         {"code":404,"body":{"error":"path not found"}}
diverged responses = 0 / 5
```

Three files were fixed under the directly bound layout, one under the adapter-backed one. The
registration file `direct/app.mjs` did not change this time, because the changed names were
context fields, not the constructor or registration names. Had a version also changed the
tool's constructor name, four files would have been fixed under the direct layout and still
just one under the adapter-backed one: the bridge's count is independent of how large the
change to the tool's contract is.

The two versions' responses to the five requests stayed the same too; the migration did not
change behavior.

#### The Cost of Isolation

The adapter is not free, and its cost can be counted on the same scale.

```sh
for d in direct adapter; do
  echo "$d: $(ls "$d"/*.mjs | wc -l | tr -d ' ') files, $(cat "$d"/*.mjs | wc -l | tr -d ' ') lines"
done
```

```
direct: 4 files, 45 lines
adapter: 5 files, 55 lines
```

One extra file and ten extra lines. In exchange, the number of files fixed on a change to the
tool's contract dropped from three to one. The fixed cost of ten lines is weighed against the
two-file difference repeated at every contract change; if the tool's contract never changes,
isolation does not pay for itself.

The second cost is measured not in a number but in scope: the bridge passes through only what
it translates. A capability the tool offers but the bridge does not map — a streaming response,
request cancellation — cannot be used by the business rule until it is added to the bridge's
contract. This blocks reading the principle as "every framework must always be wrapped." The
reason for isolation is that the tool's contract is outside the library's control and has a
history of changing; without that reason, the bridge is only a layer of indirection.

#### Summary

- A framework and a library are separated not by function but by the direction of control: a
  library is called, a framework calls; this is why the framework dependency does not stay at
  the call sites, it stands wherever the context gets read.
- The measure of isolation is the number of files carrying at least one name from the tool's
  vocabulary: 4/4 under the directly bound layout, 1/5 under the adapter-backed one.
- Binding to the framework does not stay at the endpoints; it walked inward, following the
  error path, all the way to the validation module.
- When three names in the tool's context contract changed, 3 files were fixed under the direct
  layout, 1 under the adapter-backed layout; the response to the five requests did not change
  in either layout.
- The cost of isolation is 1 file and 10 lines, and on top of that the bridge passes through
  only the capability it maps; if the tool's contract does not change, this cost goes
  unrewarded.

#### Next Step

The three lessons so far discussed where to draw the boundary one file at a time: which file
holds the policy, how many names the contract consists of, how many files touch the tool. Once
a codebase grows, the unit stops being the file; a set of files packaged, versioned, and
released together becomes a **component**. The question then is: which files should go into
the same component? The next lesson answers this question from the change log — it writes a
tool that clusters files that change together, measures the number of components and the
number of links between components once the component boundary is drawn along these clusters,
and compares how many components a single change touches and how many files a single
capability binds to across three splits.

### Component Cohesion Principles

Source: https://academia.sh/en/courses/design-principles/component-cohesion-principles

Extracting the component boundary from files that change together: computing the co-change count from a change log, clustering files by a threshold, and comparing the directory split with the co-change split by component count, republish count, and files bound to a capability.

The three lessons so far discussed the boundary one file at a time: which file the policy sits
in, how many names the contract between two modules consists of, how many files the outside
tool's vocabulary touches. At these scales the unit is the file and the boundary is an import
line.

Once a codebase grows, the unit changes. Files stop getting packaged, versioned, and released
one at a time; they get packaged, versioned, and released in sets, and this set is called a
**component**. The new question is: which files should go into the same component? This
lesson pulls the answer not from a guess, but from the library's change log.

#### A Component Is a Unit of Reuse

A component's first criterion is not technical, it is about release: the side using a library
does not take it file by file, it takes it version by version. This is why **the unit of reuse
equals the unit of release** — this is the reuse/release equivalence principle. A set that
cannot be versioned separately cannot be reused separately, because there is no name to tell
the consumer what it is bound to.

This criterion alone does not fix the boundary, but it produces two consequences. First: if a
single file in a component changes, the whole component gets republished, and everyone bound
to the component has to accommodate the new version. Second: whoever binds to a component ends
up bound to every file inside it.

These two consequences turn into two separate principles. The **common closure principle**
asks that files changing at the same time for the same reason be gathered into the same
component; its measure is how many components a single change spreads across. The **common
reuse principle** asks that files not used together stay in separate components; its measure
is how many files a party needing one capability ends up bound to.

The three together determine a component's **cohesion**. In the Coupling and Cohesion topic,
cohesion was measured as how related the names inside a module are to each other; the question
here is the same, only the containing unit is a component instead of a module, and the
evidence for relatedness is change history instead of names.

#### The Library's Files and Change Log

The library has grown to twelve files. The following module carries three things: the file
list, the import edges between the files, and the files touched together in each of
twenty-two changes. The change log was kept at the decision level in the first lesson; here it
is at the file level.

```js
// library.mjs — the library's files, import edges, and twenty-two changes
export const FILES = [
  "fee/tariff-table.mjs", "fee/fee-rule.mjs", "fee/discount.mjs",
  "zone/postal-mapping.mjs", "zone/factor.mjs",
  "carrier/selection.mjs", "carrier/carrier-list.mjs",
  "route/transfer-points.mjs", "route/route-building.mjs",
  "delivery/status-flow.mjs", "delivery/timestamp.mjs", "shared/money.mjs",
];

export const IMPORTS = [
  ["fee/fee-rule.mjs", "fee/tariff-table.mjs"],
  ["fee/fee-rule.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "shared/money.mjs"],
  ["fee/discount.mjs", "shared/money.mjs"],
  ["fee/tariff-table.mjs", "shared/money.mjs"],
  ["zone/factor.mjs", "zone/postal-mapping.mjs"],
  ["carrier/selection.mjs", "fee/fee-rule.mjs"],
  ["carrier/selection.mjs", "fee/discount.mjs"],
  ["carrier/selection.mjs", "carrier/carrier-list.mjs"],
  ["route/route-building.mjs", "route/transfer-points.mjs"],
  ["route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["delivery/status-flow.mjs", "route/route-building.mjs"],
];

export const LOG = [
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["route/transfer-points.mjs", "route/route-building.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs", "carrier/selection.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs", "zone/postal-mapping.mjs"],
  ["route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs", "fee/fee-rule.mjs"],
  ["route/transfer-points.mjs", "route/route-building.mjs", "carrier/carrier-list.mjs"],
  ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"],
  ["delivery/status-flow.mjs", "delivery/timestamp.mjs"],
  ["fee/discount.mjs", "carrier/selection.mjs"],
  ["route/transfer-points.mjs", "carrier/carrier-list.mjs", "route/route-building.mjs"],
  ["fee/tariff-table.mjs", "zone/factor.mjs"],
  ["fee/fee-rule.mjs", "fee/discount.mjs"],
  ["shared/money.mjs", "fee/fee-rule.mjs"],
];
```

The directory names suggest a split: `fee`, `zone`, `carrier`, `route`, `delivery`, `shared`.
This split was made by looking at what the files are about. The log may say something else.

#### Finding What Changes Together

**Co-change** is the number of times two files were touched together in the same change. The
tool below computes this number for every pair, then binds pairs staying above a threshold into
the same cluster. The binding runs over disjoint sets: every file starts at its own root, and
every pair over the threshold unions the two roots.

```js
// co-change.mjs — clusters files that change together, above a threshold
import { FILES, LOG } from "./library.mjs";

const THRESHOLD = 3;
const pairKey = (a, b) => [a, b].sort().join(" + ");

const pair = new Map();
for (const files of LOG) {
  for (let i = 0; i < files.length; i += 1) {
    for (let j = i + 1; j < files.length; j += 1) {
      const k = pairKey(files[i], files[j]);
      pair.set(k, (pair.get(k) ?? 0) + 1);
    }
  }
}

const root = new Map(FILES.map((d) => [d, d]));
const find = (d) => (root.get(d) === d ? d : find(root.get(d)));
for (const [k, n] of pair) {
  if (n < THRESHOLD) continue;
  const [a, b] = k.split(" + ");
  root.set(find(a), find(b));
}

const cluster = new Map();
for (const d of FILES) {
  const k = find(d);
  cluster.set(k, [...(cluster.get(k) ?? []), d]);
}

console.log("co-change count (threshold >= " + THRESHOLD + ")");
for (const [k, n] of [...pair].sort((a, b) => b[1] - a[1])) {
  console.log(`  ${n >= THRESHOLD ? "*" : " "} ${String(n).padStart(2)}  ${k}`);
}
console.log(`\ncluster count = ${cluster.size}`);
for (const [, members] of cluster) console.log(`  ${members.join(", ")}`);
```

```
co-change count (threshold >= 3)
  *  7  fee/tariff-table.mjs + zone/factor.mjs
  *  4  fee/tariff-table.mjs + zone/postal-mapping.mjs
  *  4  fee/discount.mjs + fee/fee-rule.mjs
  *  4  carrier/selection.mjs + fee/discount.mjs
  *  3  zone/factor.mjs + zone/postal-mapping.mjs
  *  3  route/route-building.mjs + route/transfer-points.mjs
  *  3  delivery/status-flow.mjs + delivery/timestamp.mjs
  *  3  carrier/carrier-list.mjs + route/route-building.mjs
     2  carrier/selection.mjs + fee/fee-rule.mjs
     2  carrier/carrier-list.mjs + route/transfer-points.mjs
     1  fee/fee-rule.mjs + shared/money.mjs

cluster count = 5
  fee/tariff-table.mjs, zone/postal-mapping.mjs, zone/factor.mjs
  fee/fee-rule.mjs, fee/discount.mjs, carrier/selection.mjs
  carrier/carrier-list.mjs, route/transfer-points.mjs, route/route-building.mjs
  delivery/status-flow.mjs, delivery/timestamp.mjs
  shared/money.mjs
```

Three of the five clusters cross directory boundaries. The highest count, seven, is between the
fee table and the zone factor: when the price list is refreshed, the two get refreshed
together, even though they sit in separate directories. Discount and carrier selection also
changed together four times; the discount rate affects the carrier ranking, so the two are a
single commercial decision.

The threshold being three is a decision, not data. Two files changing together once could be a
coincidence; changing together three times points to a reason. Raise the threshold and the
cluster count rises; lower it and the clusters stick together.

#### Measuring the Three Splits

Now three splits are compared with the same measures: the split by directory, the split by
co-change, and the split gathering everything into a single component. There are four
measures — component count, inter-component edge count, the number of components a single
change spreads across, and the number of files a consumer needing the route-building
capability has to bind to.

The last measure is found by following the edges leaving the route component in the component
graph one after another: whoever binds to a component also ends up bound to the components that
component binds to.

```js
// component-measure.mjs — compares three splits with the same four measures
import { FILES, IMPORTS, LOG } from "./library.mjs";

const directorySplit = Object.fromEntries(FILES.map((d) => [d, d.split("/")[0]]));
const CLUSTERS = [
  ["tariff", ["fee/tariff-table.mjs", "zone/postal-mapping.mjs", "zone/factor.mjs"]],
  ["commercial-rule", ["fee/fee-rule.mjs", "fee/discount.mjs", "carrier/selection.mjs"]],
  ["network", ["carrier/carrier-list.mjs", "route/transfer-points.mjs", "route/route-building.mjs"]],
  ["tracking", ["delivery/status-flow.mjs", "delivery/timestamp.mjs"]],
  ["money", ["shared/money.mjs"]],
];
const coChangeSplit = Object.fromEntries(
  CLUSTERS.flatMap(([name, members]) => members.map((d) => [d, name])));
const singleSplit = Object.fromEntries(FILES.map((d) => [d, "library"]));

function closure(split, start) {
  const edges = new Set(IMPORTS
    .filter(([a, b]) => split[a] !== split[b])
    .map(([a, b]) => `${split[a]} -> ${split[b]}`));
  const seen = new Set([split[start]]);
  for (const s of seen) {
    for (const e of edges) {
      const [left, right] = e.split(" -> ");
      if (left === s) seen.add(right);
    }
  }
  return { edgeCount: edges.size, fileCount: FILES.filter((d) => seen.has(split[d])).length };
}

for (const [name, split] of [["directory", directorySplit], ["co-change", coChangeSplit], ["single", singleSplit]]) {
  const components = new Set(Object.values(split));
  const touched = LOG.map((d) => new Set(d.map((f) => split[f])).size);
  const republishes = touched.reduce((a, b) => a + b, 0);
  const spread = touched.filter((n) => n > 1).length;
  const { edgeCount, fileCount } = closure(split, "route/route-building.mjs");
  console.log(`${name.padEnd(11)} components ${components.size}  inter-component edges ${edgeCount}`);
  console.log(`            changes touching more than one component ${spread} / ${LOG.length}`);
  console.log(`            total republishes ${republishes}  average ${(republishes / LOG.length).toFixed(2)}`);
  console.log(`            files bound to build a route ${fileCount} / ${FILES.length}`);
}
```

```
directory   components 6  inter-component edges 5
            changes touching more than one component 16 / 22
            total republishes 38  average 1.73
            files bound to build a route 10 / 12
co-change   components 5  inter-component edges 5
            changes touching more than one component 1 / 22
            total republishes 23  average 1.05
            files bound to build a route 3 / 12
single      components 1  inter-component edges 0
            changes touching more than one component 0 / 22
            total republishes 22  average 1.00
            files bound to build a route 12 / 12
```

Under the directory split, sixteen of twenty-two changes spread across more than one component;
under the co-change split, one does. Total republishes drop from 38 to 23. The two splits'
component counts and inter-component edge counts are nearly the same — the gain does not come
from the component count, it comes from where the boundary passes.

The route-building measure is sharper. Under the directory split, the route component is bound
to the carrier component, the carrier component's selection file is bound to fee, fee to zone
and to money; as a result, a consumer wanting only to build a route ends up bound to ten of the
twelve files. Under the co-change split, the carrier list sits in the same component as the
route files, so this chain does not even get built: three files are enough.

#### The Tension Between the Three Principles

The third row shows the principles pushing against each other. The single-component split is
flawless on the common closure measure — no change spreads across more than one component,
because there is only one component. The same split gives the worst result on the common reuse
measure: a party needing just one capability ends up bound to all twelve files.

The tension runs in three directions. Common closure wants to **grow** components, because
everything that changes together should be inside. Common reuse wants to **shrink** them,
because nothing unused should be inside. Reuse/release equivalence puts a floor under both:
whatever the split is, a set too entangled to be released separately does not count as a
component.

The three cannot be maximized at once. Which one dominates depends on what stage the library is
at: if the component is not yet published externally, what makes development hard is changes
spreading, and common closure takes the lead; if the component is published to many consumers,
unneeded dependencies get expensive, and common reuse takes the lead.

#### The Limit of the Measure

Co-change gives a signal, it does not make the decision. It misleads in three situations.

First, changes touching every file — a mass rename or a formatting fix raises every pair's
count at once and glues the clusters together. Such changes have to be filtered out of the log
before the measurement means anything.

Second, files with little history: if none of a file's pairs cross the threshold, it stays
alone in its own cluster. In the output above, `shared/money.mjs` is in this state — it changed
together with the fee rule only once and stayed in its own cluster. The result is correct here,
because currency handling really does change for a separate reason; but the same pattern comes
out for every newly added file too, and there it carries no information.

Third, the log narrates the past, not the future. The library opening into a new domain redraws
the clusters. The component boundary is not something drawn once and left alone; it is
something recomputed as the log grows.

#### Summary

- A component is a set of files packaged and released together; the unit of reuse equals the
  unit of release.
- The common closure principle gathers what changes together into the same component, the
  common reuse principle separates what is not used together; the two pull in opposite
  directions.
- Once the co-change counts of twenty-two changes were computed, five clusters came out and
  three crossed directory boundaries; the highest count was between the fee table and the zone
  factor.
- Under the directory split, 16 of 22 changes spread across more than one component; under the
  co-change split, 1 did; total republishes dropped from 38 to 23.
- The number of files bound to build a route dropped from 10 to 3; under the split gathering
  everything into one component, the same number was 12, and the tension between the two
  principles became visible.

#### Next Step

This lesson's measures looked **inside** components: which files should stand together. The
component graph's edges were counted, but their direction was not questioned. Is there a
criterion for which direction an edge should point? A stable component bound to a frequently
changing one loses its own stability. The next lesson computes a stability measure for each
component from its incoming and outgoing link counts, counts whether links flow in the stable
direction, and shows why a cycle between components in the same graph makes a release order
impossible.

### Component Coupling Principles

Source: https://academia.sh/en/courses/design-principles/component-coupling-principles

Measuring the direction of the bond between components: computing the instability metric from fan-in and fan-out, counting edges that do not flow in the stable direction, deriving a release order with topological sort, and showing how a single import into the stable core makes ordering impossible.

The previous lesson pulled the component boundary from files that change together and compared
two splits by republish count. Those measures looked **inside** components: which files should
stand together. Edges between components were counted, but their direction was not questioned.

An inter-component edge is the component-scale counterpart of the coupling measured for module
pairs in the Coupling and Cohesion topic. The difference is direction: there, the strength of
the bond was counted; here, which way it points will be counted. There is a criterion for which
direction an edge should point, and it is again about change: a component bound to a frequently
changing one gets dragged along behind those changes even if it never changes itself. This
lesson computes a stability measure for every component, counts whether links flow in the right
direction, and shows what stability buys in the release order.

#### Stability Is Something Measurable

A component's stability is not that it never changes, it is that **changing it is hard**. Two
numbers determine this: how many components are bound to it (**fan-in**) and how many
components it is bound to (**fan-out**). A component with a high fan-in is hard to change,
because changing it breaks others. A component with a high fan-out is easy to change, because
changing it breaks no one — but that same component is exposed to being affected by others'
changes.

The two reduce to a single ratio. The **instability metric** is the ratio of fan-out to total
links:

$$I = \frac{\text{fan-out}}{\text{fan-in} + \text{fan-out}}$$

A component with $I = 0$ is the most stable: things bind to it, it binds to nothing. A
component with $I = 1$ is the most unstable: nothing is bound to it, it is bound to others. The
measure reports position, not quality; a library needs components at both ends.

The **stable dependencies principle** is built on top of this measure: an edge must flow in the
direction where $I$ **drops**. A stable component binding to an unstable one turns its own
stability into a fiction — the component looks closed to change, but it gets republished every
time the side it is bound to changes.

#### Three Graphs to Measure

The two splits from the previous lesson give the same library's two component graphs. A third
graph is produced by adding a single import to the second: the money component starts
formatting amounts by zone and imports the tariff component.

```js
// component-graph.mjs — the same library's three component graphs
export const DIRECTORY = {
  components: ["fee", "zone", "carrier", "route", "delivery", "shared"],
  edges: [["fee", "zone"], ["fee", "shared"], ["carrier", "fee"],
    ["route", "carrier"], ["delivery", "route"]],
};

export const COCHANGE = {
  components: ["tariff", "commercial-rule", "network", "tracking", "money"],
  edges: [["commercial-rule", "tariff"], ["commercial-rule", "money"], ["commercial-rule", "network"],
    ["tariff", "money"], ["tracking", "network"]],
};

export const BROKEN = {
  components: COCHANGE.components,
  edges: [...COCHANGE.edges, ["money", "tariff"]],
};
```

The measuring tool computes two things. First, the instability metric and the edges not
flowing in the stable direction. Second, the **release order**: a component can be released
only after everything it is bound to has been released. This is the component-level
counterpart of the topological sort established in the Data Structures course; if a component
is left unsorted, there is a cycle in the graph.

```js
// stability.mjs — instability metric, stable-direction violations, and release order
export function measure(components, edges) {
  const fanIn = Object.fromEntries(components.map((c) => [c, 0]));
  const fanOut = Object.fromEntries(components.map((c) => [c, 0]));
  for (const [a, b] of edges) { fanOut[a] += 1; fanIn[b] += 1; }
  const I = Object.fromEntries(components.map((c) => {
    const total = fanIn[c] + fanOut[c];
    return [c, total === 0 ? 0 : fanOut[c] / total];
  }));
  return { fanIn, fanOut, I, violations: edges.filter(([a, b]) => I[a] < I[b]) };
}

export function releaseOrder(components, edges) {
  const remaining = new Set(components);
  const order = [];
  for (;;) {
    const ready = [...remaining]
      .filter((c) => edges.every(([a, r]) => a !== c || !remaining.has(r))).sort();
    if (ready.length === 0) return { order, remaining: [...remaining].sort() };
    for (const c of ready) { order.push(c); remaining.delete(c); }
    if (remaining.size === 0) return { order, remaining: [] };
  }
}
```

```js
// stability-report.mjs — reports all three graphs with the same measures
import { DIRECTORY, COCHANGE, BROKEN } from "./component-graph.mjs";
import { measure, releaseOrder } from "./stability.mjs";

for (const [name, graph] of [["directory", DIRECTORY], ["co-change", COCHANGE], ["broken", BROKEN]]) {
  const { fanIn, fanOut, I, violations } = measure(graph.components, graph.edges);
  console.log(`${name} graph — ${graph.components.length} components, ${graph.edges.length} edges`);
  for (const c of [...graph.components].sort((x, y) => I[x] - I[y] || x.localeCompare(y))) {
    console.log(`  ${c.padEnd(16)} fan-in ${fanIn[c]}  fan-out ${fanOut[c]}  I = ${I[c].toFixed(2)}`);
  }
  console.log(`  stable-direction violations = ${violations.length} / ${graph.edges.length}` +
    (violations.length ? `  ${violations.map(([a, b]) => `${a} -> ${b}`).join(", ")}` : ""));
  const { order, remaining } = releaseOrder(graph.components, graph.edges);
  console.log(`  release order: ${order.join(" -> ")}` +
    (remaining.length ? `  || unorderable: ${remaining.join(", ")}` : ""));
}
```

```
directory graph — 6 components, 5 edges
  shared           fan-in 1  fan-out 0  I = 0.00
  zone             fan-in 1  fan-out 0  I = 0.00
  carrier          fan-in 1  fan-out 1  I = 0.50
  route            fan-in 1  fan-out 1  I = 0.50
  fee              fan-in 1  fan-out 2  I = 0.67
  delivery         fan-in 0  fan-out 1  I = 1.00
  stable-direction violations = 1 / 5  carrier -> fee
  release order: shared -> zone -> fee -> carrier -> route -> delivery
co-change graph — 5 components, 5 edges
  money            fan-in 2  fan-out 0  I = 0.00
  network          fan-in 2  fan-out 0  I = 0.00
  tariff           fan-in 1  fan-out 1  I = 0.50
  commercial-rule  fan-in 0  fan-out 3  I = 1.00
  tracking         fan-in 0  fan-out 1  I = 1.00
  stable-direction violations = 0 / 5
  release order: money -> network -> tariff -> tracking -> commercial-rule
broken graph — 5 components, 6 edges
  network          fan-in 2  fan-out 0  I = 0.00
  money            fan-in 2  fan-out 1  I = 0.33
  tariff           fan-in 2  fan-out 1  I = 0.33
  commercial-rule  fan-in 0  fan-out 3  I = 1.00
  tracking         fan-in 0  fan-out 1  I = 1.00
  stable-direction violations = 0 / 6
  release order: network -> tracking  || unorderable: commercial-rule, money, tariff
```

#### Where the Direction Is Wrong

In the directory graph, one of five edges does not flow in the stable direction: `carrier ->
fee`. The carrier component's instability is 0.50, the fee component's is 0.67. The link points
toward the more unstable one.

What this means concretely: because the route component is bound to carrier, and carrier to
fee, every commercial-rule change reaching the fee component walks all the way to the route
component. The fee component was the most frequently touched spot in the previous lesson's
log; the most-changing component has settled in the middle of the chain.

In the co-change graph, that count is zero. All five edges flow from unstable to stable: the two
most unstable components (`commercial-rule` and `tracking`) feed into no component, and the two
most stable ones (`network` and `money`) bind to nothing. The measure confirms the previous
lesson's split with an independent criterion.

#### What Stability Buys

If the stable direction were only an aesthetic preference, it would not be worth measuring. What
it buys shows up in the release order: in the co-change graph, the five components release in
the order `money -> network -> tariff -> tracking -> commercial-rule`, guaranteeing that
everything each component is bound to was released before it. Which components need retesting
once a new version ships is also read from the same order.

The condition for the order to exist is the **acyclic dependencies principle**: the component
graph must not contain a cycle. If there is a cycle, there is no release order, because every
component in the cycle is waiting for another one to be released first.

#### A Single Import Into the Stable Core

The broken graph shows exactly this. The one edge added is `money -> tariff`. The results:

The money component's instability went from 0.00 to 0.33. The library's most stable part is now
bound to the tariff table; every time the price list is refreshed, the money component gets
republished too. The tariff component symmetrically dropped from 0.50 to 0.33 — the number went
down, but the situation did not improve, because the two components are now entangled in each
other.

The stable-direction violation count came out **zero**. Because the two components' instability
is equal, no edge trips the "does not flow from unstable to stable" criterion. This is proof
that the instability metric alone is not enough: a cycle does not violate the measure, it makes
the measure **meaningless**.

The release order does not hide the situation, though. Only two of the five components could be
ordered; `money`, `tariff`, and `commercial-rule` came out unorderable. `commercial-rule` is on
the list even though it is not inside the cycle: every component bound to a cycle also becomes
unorderable. A single import line in one component made a third component, bound to it from a
distance, impossible to release.

#### Breaking the Cycle

There are two known ways to remove a cycle, and both are moves already established earlier.

The first is reversing the direction: instead of importing the tariff, the money component takes
the information it needs for formatting as a parameter. This is the component-scale counterpart
of the first lesson's move — the rule does not import the table, the table is given to the rule.
The dependency inversion item of SOLID and the layer rule from M16/K04 describe this same move.

The second is inserting a component in between: a shared need of two components is split off
into a third component, and both bind to it. The cycle is broken because both edges now point
the same way.

Once the first way is applied, the graph becomes the co-change graph again: `money` returns to
$I = 0$, the edge count drops from six to five, and all five components get ordered. All three
measures recover at once.

#### Summary

- A component's instability is computed as
  $I = \text{fan-out} / (\text{fan-in} + \text{fan-out})$; 0 marks the most stable
  position, 1 the most unstable.
- The stable dependencies principle asks edges to flow in the direction where $I$ drops; 1 of 5
  edges did not flow this way under the directory split, 0 of 5 under the co-change split.
- Stability's payoff is the release order: in the co-change graph, all five components can be
  released in a single order, and once a version ships, which components need retesting is read
  from that same order.
- A single import added to the stable core (`money -> tariff`) raised the money component's
  instability from 0.00 to 0.33 and made three of the five components unorderable.
- The same broken graph produced zero stable-direction violations; the instability metric does
  not catch a cycle — a cycle makes the measure itself meaningless.

#### Next Step

The release order said a cycle **exists**, but not where it was: which of the three unorderable
components is inside the cycle and which is only on the list because it is bound to it could not
be read from the output. Also, every measure in this section sat on a component graph written by
hand; in a real codebase, the graph is extracted from import lines. The next lesson writes a tool
that reads the import graph from source files, lists cycles by name with a depth-first search,
and counts imports violating the layer rule; it first runs the tool on a broken version, then
shows both numbers dropping to zero on the fixed version.

### Dependency Graph Health

Source: https://academia.sh/en/courses/design-principles/dependency-graph-health

Extracting the import graph from source files and auditing it at the component level: listing cycles by name with a depth-first search, counting edges that violate layer order, showing what a cycle looks like at runtime, and confirming both numbers drop to zero after the repair.

The previous lesson read from the release order's breakdown that a cycle **existed**, but could
not say where: which of the three unorderable components was inside the cycle, and which was
only on the list because it was bound to it, was not visible. Every measure there also sat on a
component graph written by hand.

In a real codebase the graph is not written by hand, it is extracted from import lines. This
lesson writes a tool that does that extraction, lists cycles by name, and counts edges violating
layer order; it first runs the tool on a broken version, then shows both numbers dropping to
zero on the repaired version.

#### The Source Tree to Audit

Extracting the import graph at the file level and auditing the layer rule was established in
the Layer Responsibilities lesson of the Data Access Layer and Business Logic course. The work
here is different: the same graph is collapsed to the **component level**, and a cycle is
searched for on it. The layer audit is also done by component rank, not by file path.

The following block sets up the library's broken version. Nine files, five components, and
four layer ranks: `money` at the bottom (0), `tariff` and `network` one above it (1),
`commercial-rule` above those (2), and the entry file at the top (3).

```sh
mkdir -p broken
cat > broken/money.mjs <<'EOF'
import { TIERS } from "./tariff-table.mjs";
export const cents = (amount) => Math.round(amount * 100);
export const MAX_WEIGHT = TIERS.at(-1).maxWeight;
EOF
cat > broken/tariff-table.mjs <<'EOF'
import { cents } from "./money.mjs";
export const TIERS = [{ maxWeight: 5, fee: cents(84.9) }, { maxWeight: 30, fee: cents(249.9) }];
EOF
cat > broken/factor.mjs <<'EOF'
import { cents } from "./money.mjs";
export const ZONE_FACTOR = { near: 1, mid: 1.35, far: 1.8 };
export const MINIMUM = cents(39.9);
EOF
cat > broken/fee-rule.mjs <<'EOF'
import { TIERS } from "./tariff-table.mjs";
import { ZONE_FACTOR, MINIMUM } from "./factor.mjs";
export const fee = (weight, zone) =>
  Math.max((TIERS.find((t) => weight <= t.maxWeight)?.fee ?? 0) * ZONE_FACTOR[zone], MINIMUM);
EOF
cat > broken/discount.mjs <<'EOF'
import { cents } from "./money.mjs";
export const discounted = (amount, rate) => Math.max(Math.round(amount * (1 - rate)), cents(39.9));
EOF
cat > broken/selection.mjs <<'EOF'
import { fee } from "./fee-rule.mjs";
import { discounted } from "./discount.mjs";
import { CARRIERS } from "./carrier-list.mjs";
export const cheapest = (weight, zone, rate) => CARRIERS
  .map((c) => ({ name: c.name, amount: discounted(fee(weight, zone) * c.multiplier, rate) }))
  .sort((a, b) => a.amount - b.amount)[0];
EOF
cat > broken/carrier-list.mjs <<'EOF'
import { cheapest } from "./selection.mjs";
export const CARRIERS = [{ name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }];
export const defaultCarrier = (weight) => cheapest(weight, "mid", 0).name;
EOF
cat > broken/route-building.mjs <<'EOF'
import { CARRIERS } from "./carrier-list.mjs";
export const route = (zone) => [zone, CARRIERS[0].name];
EOF
cat > broken/try.mjs <<'EOF'
import { cheapest } from "./selection.mjs";
console.log("cheapest(3, 'mid', 0) =", JSON.stringify(cheapest(3, "mid", 0)));
EOF
ls broken | tr '\n' ' '
```

```
carrier-list.mjs discount.mjs factor.mjs fee-rule.mjs money.mjs route-building.mjs selection.mjs tariff-table.mjs try.mjs 
```

Two import lines are placed in excess: `money.mjs` imports the tariff table, and
`carrier-list.mjs` imports the selection module. Looked at alone, both seem harmless — the
money module wants to know the top weight tier, the carrier list wants to compute the default
carrier.

#### What a Cycle Looks Like at Runtime

The cost of these two lines does not stay on the graph. When the entry file runs, the modules
end up waiting on each other.

```sh
node broken/try.mjs 2>&1 | grep -m1 "^ReferenceError"
```

```
ReferenceError: Cannot access 'TIERS' before initialization
```

The error comes from one module in the cycle running its body while the constant the other one
exports has not been defined yet. Which module gets evaluated first depends on the entry point,
so the same cycle can pass under a different name at another entry point, or without throwing
an error at all. This is the hardest part of a cycle: its symptom is not stable.

#### Cycle Detection and Layer Auditing

The tool does three steps. First, it reads every file's import lines and extracts component
edges; imports within the same component are dropped. Then it runs a **depth-first search**
over these edges — the three-color scheme established in the Depth-First Search lesson of the
Data Structures course: a gray node is still being processed, a black node is finished, and
returning to a gray node is a cycle. Last, it compares every edge's layer ranks.

```js
// graph-health.mjs — extracts component cycles and layer violations from the import graph
import { readdirSync, readFileSync } from "node:fs";

const COMPONENT = {
  "money.mjs": "money", "tariff-table.mjs": "tariff", "factor.mjs": "tariff",
  "fee-rule.mjs": "commercial-rule", "discount.mjs": "commercial-rule", "selection.mjs": "commercial-rule",
  "carrier-list.mjs": "network", "route-building.mjs": "network", "try.mjs": "entry",
};
const LAYER = { money: 0, tariff: 1, network: 1, "commercial-rule": 2, entry: 3 };

function componentEdges(dir) {
  const edge = new Set();
  for (const file of readdirSync(dir).filter((d) => d.endsWith(".mjs")).sort()) {
    const text = readFileSync(`${dir}/${file}`, "utf8");
    for (const m of text.matchAll(/^import\s.*?from\s+"\.\/([\w.-]+)"/gm)) {
      const [a, b] = [COMPONENT[file], COMPONENT[m[1]]];
      if (a !== b) edge.add(`${a} ${b}`);
    }
  }
  return [...edge].map((k) => k.split(" "));
}

function cycles(edges) {
  const adjacency = new Map();
  for (const [a, b] of edges) adjacency.set(a, [...(adjacency.get(a) ?? []), b]);
  const state = new Map(), path = [], found = [];
  const depthFirstSearch = (d) => {
    state.set(d, "gray"); path.push(d);
    for (const k of adjacency.get(d) ?? []) {
      if (state.get(k) === "gray") found.push([...path.slice(path.indexOf(k)), k]);
      else if (state.get(k) !== "black") depthFirstSearch(k);
    }
    path.pop(); state.set(d, "black");
  };
  for (const d of [...new Set(edges.flat())].sort()) if (!state.has(d)) depthFirstSearch(d);
  return found;
}

for (const dir of process.argv.slice(2)) {
  const edges = componentEdges(dir);
  const cycle = cycles(edges);
  const violation = edges.filter(([a, b]) => LAYER[a] < LAYER[b]);
  console.log(`${dir}/  component edges = ${edges.length}`);
  console.log(`  cycles = ${cycle.length}`);
  for (const c of cycle) console.log(`    ${c.join(" -> ")}`);
  console.log(`  layer violations = ${violation.length}`);
  for (const [a, b] of violation) console.log(`    ${a}(${LAYER[a]}) -> ${b}(${LAYER[b]})`);
}
```

```sh
node graph-health.mjs broken
```

```
broken/  component edges = 7
  cycles = 2
    money -> tariff -> money
    commercial-rule -> network -> commercial-rule
  layer violations = 2
    network(1) -> commercial-rule(2)
    money(0) -> tariff(1)
```

The previous lesson's release order had given three components as "unorderable"; the output
here names the components inside the cycle separately, and shows that `commercial-rule` is not
in the cycle, it is bound to it.

The two numbers point at the same two edges but do not measure the same thing. A layer
violation is a **rule** violation: the ranks were declared beforehand, and the edge did not
follow them. A cycle is independent of the rule; even where no layer has been declared, a cycle
is still a defect, because the release order and the evaluation order break regardless of it.
In a library with no layer rule, the second number cannot be computed, the first can.

#### The Fix

The two imports are removed by two different moves. `money.mjs` takes the tiers as a parameter
instead of importing the top weight tier — the same move as the first lesson's: the rule does
not import the table. The default-carrier calculation inside `carrier-list.mjs` is moved into
the selection module instead; the calculation was already bound to selection, not to the list.

```sh
cp -r broken fixed
cat > fixed/money.mjs <<'EOF'
export const cents = (tl) => Math.round(tl * 100);
export const maxWeight = (tiers) => tiers.at(-1).maxWeight;
EOF
cat > fixed/carrier-list.mjs <<'EOF'
export const CARRIERS = [{ name: "standard", multiplier: 1 }, { name: "economy", multiplier: 0.85 }];
EOF
cat >> fixed/selection.mjs <<'EOF'
export const defaultCarrier = (weight) => cheapest(weight, "mid", 0).name;
EOF
node fixed/try.mjs
node graph-health.mjs fixed
```

```
cheapest(3, 'mid', 0) = {"name":"economy","amount":9742}
fixed/  component edges = 5
  cycles = 0
  layer violations = 0
```

The entry file now runs without an error and produces a result. Component edges dropped from
seven to five, cycles from two to zero, layer violations from two to zero. No capability is
lost: the default-carrier calculation still exists, only in a different file.

#### The Numbers as a Threshold

The value of these two numbers is that they are not meant to be a one-time measurement. Cycles
do not build up from one big decision, they build up import line by import line, each looking
harmless on its own; both lines in the broken version had a reasonable justification at the
moment they were written. When the audit runs on every change and the threshold for cycle count
is set to zero, the justification gets argued before the line is written, not after.

The graph the tool extracts is also the input to the previous lesson's instability metric.
Three numbers are read from the same import graph: cycle count, layer violation count, and
per-component instability. Once all three settle at zero or at the expected direction, the
component boundaries are working.

#### Summary

- The component graph is not written by hand; it is extracted from import lines, and imports
  within the same component are dropped down to component edges.
- A cycle is found with the depth-first search's three-color scheme: returning to a gray node
  is a cycle, and the path on the stack names the cycle's members.
- In the broken version, two import lines produced two cycles and two layer violations; the
  same cycle turned into an initialization error at runtime, tied to the entry point.
- A layer violation is a violation of a declared rule; a cycle is a defect independent of the
  rule — a cycle is countable even in a library with no layer rule.
- After the repair, component edges dropped from 7 to 5, cycles and layer violations from 2 to
  0; the moved calculation was not lost, it only moved to the side it already depended on.

#### Course Wrap-Up

The course measured whether a design was good not by looking inside the unit but at the bond
between units, and at every part it moved the measure one level up.

The **SOLID** part established five criteria at the class and module level: counting
responsibility not as amount of work but as number of reasons to change, extending behavior
without editing existing code, a subtype standing in for its supertype without breaking its
contract, limiting interfaces to what the client actually uses, and binding dependency to an
abstraction instead of a concrete detail.

The **Coupling and Cohesion** part moved to module pairs: measuring the strength of the bond
between two modules, how related the names inside a module are to each other, limiting how
many objects a call passes through, telling the decision to the data instead of pulling the
data to make the decision, separating state-changing operations from ones that only read,
encapsulating axes of change separately, the cost of binding to a concrete type, and handing
control over to the callee.

The **Boundaries and Component Principles** part moved the scale from file to component:
separating stable policy from changeable detail by frequency of change, measuring and narrowing
the contract surface by name and field count, putting a tool whose contract is under someone
else's control behind an adapter, extracting the component boundary from files that change
together, counting whether links flow in the stable direction, and finding cycles and layer
violations in the dependency graph.

The course kept one assumption from beginning to end, and it needs naming at the close: the
principles say how a design **should be**, not what to do. None of the sentences "keep the
boundary narrow," "let the bond flow in the stable direction," "keep what changes together in
one place" hand over a solution; they hand over a criterion. Yet the same boundary problem has
been solved the same way, over and over, across different codebases — and a solution like that
has a name, a known way of applying it, and known consequences.

The next course, **Design Patterns**, takes this up: classifying the classic patterns by the
problem they solve, applying a pattern without producing needless complexity — that is, without
the principles' measures getting worse — and using enterprise application patterns in data
access design. This course's measures still hold there; a pattern is a pattern to the extent
that it improves the measure.

