---
title: 'Clean Code'
source: 'https://academia.sh/en/courses/clean-code'
language: en
updated: '2026-08-23T07:01:11+00:00'
license: 'CC BY-SA 4.0'
lessons: 16
outcomes:
    - 'Makes naming and structure decisions against a readability measure'
    - 'Judges whether a function carries a single responsibility'
    - 'Applies refactoring in small, safe steps'
---

# Clean Code

Measuring the difference between code that works and code that is well designed: naming that states intent, a single level of abstraction, cyclomatic complexity, interface decisions such as null values and flag parameters, organizing code by its reason for change, and removing duplication in small steps.

## Naming and Structure

Measuring intent-revealing names at the call site, translating comments into code, the single level of abstraction rule, the cost of code format on reading diffs, computing cyclomatic complexity, and choosing between a conditional chain, a lookup table, and polymorphism.

### Meaningful Names

Source: https://academia.sh/en/courses/clean-code/meaningful-names

Treating naming as a measurable design decision: counting opaque arguments at the call site in a shortened and an intent-revealing version of the same shipment fee calculation, deriving how many candidate words an abbreviation could resolve to, and the relationship between name length and the scope a name lives in.

Completing a language curriculum yields one capability: writing code that produces the
requested behavior. The JavaScript and TypeScript courses delivered that; if the tests
pass, the program is correct. This curriculum opens with a different question. When a
choice must be made between two programs that produce the same behavior, what does the
choice rest on?

The question is unavoidable, because code is not written once and abandoned. A shipment
fee library gets a new zone coefficient six months later, and a volumetric weight divisor
changes a year after that. Whoever makes those changes **reads** the code first. Reading
cost is the actual measure of a design, and a claim like "more readable" does not settle
an argument until tied to a number. This lesson ties naming to two numbers: the number of
arguments at the call site whose meaning cannot be answered, and how many different
domain words an abbreviation could resolve to.

One library runs through the course: shipment fee calculation and routing. Its first
function computes a fee from a shipment's weight, volume, and zone.

#### Measure: The Question Answerable at the Call Site

The information a name carries is measured **where it is used**. Someone looking at a
call line asks: which parameter does this argument fill, what does its value mean, what
is the result a fee for? If answering requires opening another file, the name is not
carrying information.

To make this mechanical, call an argument **labeled** if the call text itself states
which parameter it fills — a named field, or a variable name of at least three letters.
Otherwise it is **opaque**: a bare number, a boolean, or a single-letter name. The number
of opaque arguments is a lower bound on the number of questions that cannot be answered
at the call site.

#### The Abbreviated Version

The first version works and passes its tests. It computes the volumetric weight, finds
the billable weight, selects the tier, applies the zone coefficient, subtracts the
discount, and does not drop below the minimum fee.

```sh
mkdir -p version-a version-b
```

```js
// version-a/data.mjs — sample shipment and tariff
export const s = { w: 2.4, wd: 30, l: 24, h: 18 };
export const t = { tr: [{ u: 1, r: 38 }, { u: 5, r: 26 }, { u: 10, r: 21 }, { u: Infinity, r: 17 }],
                   zc: { 1: 1, 2: 1.35, 3: 1.8 }, mf: 45 };
```

```js
// version-a/fee.mjs — shipment fee, abbreviated names
export function calc(s, t, z, x) {
  const v = (s.wd * s.l * s.h) / 3000;
  const a = Math.max(s.w, v);
  const k = t.tr.find((i) => a <= i.u);
  const f = k.r * a * t.zc[z];
  return Math.max(f * (1 - x), t.mf);
}
```

```js
// version-a/call.mjs — call site
import { calc } from "./fee.mjs";
import { s, t } from "./data.mjs";
console.log(calc(s, t, 2, 0.12).toFixed(2));
```

None of the four arguments at the call site explains itself. Is `2` a zone code, a tier
index, a quantity? Is `0.12` a discount rate, a tax rate? The answer sits in `fee.mjs`;
whoever is reading has to open a second file.

#### The Named Version

The second version performs the same arithmetic. What changes is the names, the
parameter shape, and where the numbers come from.

```js
// version-b/data.mjs — same data, with field names
export const ZONE = { SAME_CITY: 1, NEIGHBORING_ZONE: 2, DISTANT_ZONE: 3 };
export const CONTRACTED_CUSTOMER_DISCOUNT = 0.12;
export const sampleShipment = { weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18 };
export const domesticTariff = {
  weightTiers: [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
                { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: Infinity, ratePerKg: 17 }],
  zoneCoefficients: { 1: 1, 2: 1.35, 3: 1.8 },
  minimumFee: 45,
};
```

```js
// version-b/fee.mjs — same calculation, with intent-revealing names
const VOLUMETRIC_DIVISOR = 3000;

export function calculateShipmentFee({ shipment, tariff, zoneCode, discountRate }) {
  const volumetricWeightKg = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  const billableWeightKg = Math.max(shipment.weightKg, volumetricWeightKg);
  const tier = tariff.weightTiers.find((t) => billableWeightKg <= t.upperLimitKg);
  const zonedFee = tier.ratePerKg * billableWeightKg * tariff.zoneCoefficients[zoneCode];
  return Math.max(zonedFee * (1 - discountRate), tariff.minimumFee);
}
```

```js
// version-b/call.mjs — call site
import { calculateShipmentFee } from "./fee.mjs";
import { sampleShipment, domesticTariff, ZONE, CONTRACTED_CUSTOMER_DISCOUNT } from "./data.mjs";
console.log(calculateShipmentFee({ shipment: sampleShipment, tariff: domesticTariff,
  zoneCode: ZONE.NEIGHBORING_ZONE, discountRate: CONTRACTED_CUSTOMER_DISCOUNT }).toFixed(2));
```

Both versions are confirmed to produce the same result.

```sh
node version-a/call.mjs
node version-b/call.mjs
```

```
133.44
133.44
```

The behavior is identical. The difference is only in reading cost, and that cost is
countable.

#### Measurement

The tool below counts two things. First, the opaque arguments at a given call site.
Second, taking every name in the implementation that is two letters or shorter, how many
words in the domain vocabulary each one could resolve to — that is, how ambiguous the
abbreviation is.

```js
// name-measure.mjs — counts opaque call-site arguments and how many domain words an abbreviation could mean
import { readFileSync } from "node:fs";

// The library's domain vocabulary: words a name is expected to carry.
const GLOSSARY = ["coefficient", "discount", "divisor", "fee", "kilogram", "limit", "minimum",
  "rate", "shipment", "tariff", "tier", "volume", "volumetric", "weight", "zone"];

// An argument is labeled if the call text itself states which parameter it fills
// (a named field, or a variable name of at least three letters).
const labeled = (arg) => /^[A-Za-z_$][\w$]{2,}\s*:/.test(arg) || /^[A-Za-z_$][\w$]{2,}$/.test(arg);

function countOpaque(file, fnName) {
  const body = readFileSync(file, "utf8");
  const call = body.match(new RegExp(`\\b${fnName}\\(([^()]*)\\)`));
  const args = call[1].replace(/[{}]/g, "").split(",").map((s) => s.trim()).filter(Boolean);
  return { total: args.length, opaque: args.filter((a) => !labeled(a)).length };
}

function abbreviations(file) {
  const body = readFileSync(file, "utf8").replace(/^\/\/.*$/gm, "");
  const names = [...new Set(body.match(/[A-Za-z_$][\w$]*/g))].filter((a) => a.length <= 2);
  return names.map((name) => ({
    name,
    candidates: GLOSSARY.filter((g) => g.startsWith(name[0].toLowerCase())),
  }));
}

for (const [file, fn] of [["version-a/call.mjs", "calc"], ["version-b/call.mjs", "calculateShipmentFee"]]) {
  const r = countOpaque(file, fn);
  console.log(`${file.padEnd(20)} ${fn.padEnd(21)} arguments=${r.total}  opaque=${r.opaque}`);
}

console.log("\nabbreviation  candidate count  candidates");
const short = abbreviations("version-a/fee.mjs");
for (const s of short) console.log(`${s.name.padEnd(9)} ${String(s.candidates.length).padEnd(12)} ${s.candidates.join(", ") || "-"}`);
const unique = short.filter((s) => s.candidates.length === 1).length;
console.log(`\nversion-a abbreviation count = ${short.length}, resolve to one candidate = ${unique}`);
console.log(`version-b abbreviation count = ${abbreviations("version-b/fee.mjs").length}`);
```

```sh
node name-measure.mjs
```

```
version-a/call.mjs   calc                  arguments=4  opaque=4
version-b/call.mjs   calculateShipmentFee  arguments=4  opaque=0

abbreviation  candidate count  candidates
s         1            shipment
t         2            tariff, tier
z         1            zone
x         0            -
v         2            volume, volumetric
wd        1            weight
l         1            limit
h         0            -
a         0            -
w         1            weight
k         1            kilogram
tr        2            tariff, tier
i         0            -
u         0            -
f         1            fee
r         1            rate
zc        1            zone
mf        1            minimum

version-a abbreviation count = 18, resolve to one candidate = 10
version-b abbreviation count = 1
```

Four opaque arguments dropped to zero. The abbreviation table gives the second result: of
eighteen short names in the abbreviated implementation, ten resolve to exactly one
glossary word, five resolve to none, and three resolve to more than one. Ambiguity is not
the only failure mode. `w` genuinely stands for weight and resolves to `weight`; `wd`
stands for width but resolves to that same single candidate, `weight` — a confident,
wrong answer. `k` stands for the selected tier and resolves only to `kilogram`, another
confident, wrong answer. A single matching candidate is not proof the name is right.

The named version keeps exactly one short name: the `t` parameter passed to the `find`
callback. That leftover is not a coincidence; it is the subject of the next section.

#### Name Length Depends on Scope

A short name is not a defect by itself. The measure is the scope the name **lives in**.
The difference between the `t` inside a one-line callback and a module-level export named
`t` is the distance a reader travels to find the definition: on the same line in the
first case, in another file in the second.

A practical rule follows: a name's length should grow with the amount of code it stays
visible across. A loop counter `i` is defensible inside a three-line loop, not as an
exported constant. The reasoning runs in reverse too: an unnecessarily long name in a
narrow scope (`currentShipmentVolumetricWeightValue`) lengthens the line without adding
information.

#### What a Name Should Convey

The measurement says how much is missing; it does not say which information needs
adding. Four criteria do that work.

**Units and type live inside the name.** The difference between `weightKg` and `weight`
is that a mistake mixing grams with kilograms becomes visible before the code compiles.
The suffixes on `widthCm`, `upperLimitKg`, and `discountRate` do this work;
`discountRate` and `discountAmount` cannot substitute for each other.

**A name must not lie.** A variable named `zoneList` that actually holds a mapping is
worse than no name at all: whoever reads it proceeds on a false assumption. The cost of a
wrong name is every conclusion drawn before the mistake is found.

**One word per concept.** If `getTariff`, `fetchTariff`, and `retrieveTariff` all appear
in the same library, the reader assumes three different operations. The vocabulary stays
fixed throughout the course: a shipment's billable weight appears everywhere as
`billableWeightKg`.

**Searchability.** The number `3000` appears in hundreds of places across a codebase;
`VOLUMETRIC_DIVISOR` appears once. When the number needs to change, the name is what gets
searched for. Leaving a bare number unnamed costs not only reading, but changing the
code.

#### Summary

- Naming is not a stylistic preference but a measurable decision: the measure is the
  opaque argument count, a lower bound on the questions a call site cannot answer.
- In the abbreviated version, all four arguments were opaque; in the named version, none
  were. Both versions produce the same result (133.44); the difference is only in reading
  cost.
- The abbreviation-resolution table gave the second measure: of eighteen short names, ten
  resolve to exactly one domain word, five to none, and three to more than one — and a
  single matching candidate is not proof the name is right.
- Name length grows with the scope a name lives in; a short name inside a one-line
  callback and an exported short name are not the same thing.
- A good name carries a unit and a type, does not lie, uses one word per concept, and is
  searchable; naming a bare number reduces the cost of changing code, not just reading it.

#### Next Step

One thing stands out in the named version: the `VOLUMETRIC_DIVISOR` constant needed no
comment line, because its name already says what it is. In the abbreviated version, the
same information could only be given through a comment. Does this observation
generalize? Is the presence of a comment a sign that the code had to say something it
could not say itself? The next lesson answers this by measurement: how much of a comment
can be translated into a name, which comments cannot be, and at what point a comment
drifting apart from the code turns into a silent error.

### Names Instead of Comments

Source: https://academia.sh/en/courses/clean-code/names-instead-of-comments

Measuring the mismatch that follows from a comment being uncompiled, unchecked text: auditing whether the number claims in the comments of a shipment fee module match their counterparts in the code, the refactoring that turns claims into names, and the kinds of comments that cannot be turned into a name.

The previous lesson left an observation in the named version: the `VOLUMETRIC_DIVISOR`
constant needed no comment line, because its name already said what it was. The same
information could only be given through a comment in the abbreviated version. Does this
observation generalize? Is the presence of a comment a sign that the code had to say
something it could not say itself?

Tying the question to a number starts from the technical property of a comment. A comment
does not compile, does not run, is not checked. Code defends itself with its behavior on
every change; a comment does nothing and stays exactly where it was written. This
asymmetry produces a drift over time: the code goes one way, the comment goes another,
and the reader reads the comment.

#### The File, Six Months Later

The fee module of the shipment library lived for a while. In that time the tariff
changed: a fourth weight tier was added, the minimum fee went up, the contracted customer
discount was renegotiated. Whoever made the changes updated the code.

```sh
mkdir -p commented named
```

```js
// commented/fee.mjs
// Fee calculation.
// The volumetric divisor is 3000.
// There are three weight tiers: 1, 5, 10 kg.
// The minimum fee is 45.
// The contracted customer discount is 12%.
const B = 3000;
const A = 52;
const S = 0.15;
const K = [1, 5, 10, 30];
const U = [38, 26, 21, 17];

export function calculate(shipment, zoneCoefficient) {
  // volumetric weight
  const d = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / B;
  // the larger one is charged
  const a = Math.max(shipment.weightKg, d);
  // find the tier
  let i = 0;
  while (i < K.length - 1 && a > K[i]) i += 1;
  // the discount is applied before the minimum
  return Math.max(U[i] * a * zoneCoefficient * (1 - S), A);
}
```

The comments do two jobs at once. Some repeat **what** the code does (`// volumetric
weight`, `// find the tier`); these have moved the information a name should carry into a
comment line. Some assert domain facts: the number of tiers, the minimum fee, the
discount rate. The second group can be audited.

#### Measuring the Mismatch

The tool below separates a file's comments, extracts the numbers from the comments, and
checks whether each one is present among the code's numeric invariants. A number in a
comment with no counterpart in the code is proof that the comment has drifted from the
code. The file's first line is the marker carrying the filename, so it is excluded from
the count.

```js
// comment-check.mjs — counts comments and checks whether the numbers a comment claims match the code
import { readFileSync } from "node:fs";

const numbers = (text) => text.match(/\d+(?:\.\d+)?/g) ?? [];

function check(file) {
  // The first line is the filename marker; it is not counted as a comment.
  const lines = readFileSync(file, "utf8").split("\n").slice(1);
  const comments = lines.map((s) => s.match(/\/\/(.*)$/)?.[1]).filter((s) => s !== undefined);
  const code = lines.map((s) => s.replace(/\/\/.*$/, "")).join("\n");
  const inCode = new Set(numbers(code));
  const claims = comments.flatMap(numbers);
  return { comments: comments.length, claims: claims.length,
           missing: claims.filter((s) => !inCode.has(s)) };
}

for (const file of process.argv.slice(2)) {
  const r = check(file);
  console.log(`${file.padEnd(18)} comments=${r.comments}  numeric claims=${r.claims}  not in code=[${r.missing.join(", ")}]`);
}
```

```sh
node comment-check.mjs commented/fee.mjs
```

```
commented/fee.mjs  comments=9  numeric claims=6  not in code=[45, 12]
```

Nine comment lines, six numeric claims, two not present in the code. The comment says
"minimum fee 45," the code applies 52. The comment says "discount 12%," the code applies
0.15. It does not state the tier count as a number, so the "three tiers" claim is not
caught by this tool; the code carries four. The limit of the audit shows itself here too:
the tool compares only numeric claims, not claims written in plain words.

#### The Cost of Drift

Two mismatched numbers are not an abstract flaw. Someone reading the comment who wants to
verify a shipment's fee by hand uses the values the comment gives.

```js
// deviation-demo.mjs — the calculation the comment describes versus the calculation the code performs
import { calculate as commented } from "./commented/fee.mjs";

const shipment = { weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18 };
const ZONE_COEFFICIENT = 1.35;

// The calculation done by hand with the values the comment states: minimum 45, discount 12%.
const byComment = Math.max(26 * 4.32 * ZONE_COEFFICIENT * (1 - 0.12), 45);

console.log(`by comment = ${byComment.toFixed(2)}`);
console.log(`code result = ${commented(shipment, ZONE_COEFFICIENT).toFixed(2)}`);
```

```sh
node deviation-demo.mjs
```

```
by comment = 133.44
code result = 128.89
```

The gap of 4.55 produces no error message. The program runs correctly, the tests pass;
the only thing wrong is the documentation, and because the documentation sits inside the
code it is assumed to be correct. A wrong comment costs more than no comment.

#### Translating a Comment into a Name

The steps of the refactoring are mechanical. Every comment describing what the code does
turns into the name of the part doing that job: the comment `// volumetric weight`
becomes a function named `volumetricWeightKg`. Every comment asserting a domain fact
turns into the name of the constant holding that fact: the comment `// the minimum fee`
becomes the constant `MINIMUM_FEE`. The claim and the value now sit on the same line, and
drifting apart becomes impossible.

```js
// named/fee.mjs — the comments translated into names
const VOLUMETRIC_DIVISOR = 3000; // set by the carrier contract, not decided here
const MINIMUM_FEE = 52;
const CONTRACTED_CUSTOMER_DISCOUNT = 0.15;
const WEIGHT_TIERS = [
  { upperLimitKg: 1, ratePerKg: 38 },
  { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 },
  { upperLimitKg: 30, ratePerKg: 17 },
];

const volumetricWeightKg = (s) => (s.widthCm * s.lengthCm * s.heightCm) / VOLUMETRIC_DIVISOR;
const billableWeightKg = (s) => Math.max(s.weightKg, volumetricWeightKg(s));
const selectTier = (weightKg) =>
  WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg) ?? WEIGHT_TIERS.at(-1);
const applyDiscountBeforeMinimumFee = (fee) =>
  Math.max(fee * (1 - CONTRACTED_CUSTOMER_DISCOUNT), MINIMUM_FEE);

export function calculate(shipment, zoneCoefficient) {
  const weightKg = billableWeightKg(shipment);
  return applyDiscountBeforeMinimumFee(
    selectTier(weightKg).ratePerKg * weightKg * zoneCoefficient);
}
```

The upper limit of a tier and that tier's rate per kilogram now sit inside the same
object. In the previous version these lived in two separate arrays, tied together only by
matching indices; forgetting to update both arrays when a tier was added produced a
silent error. The name change fixed the structure along with it.

```sh
node comment-check.mjs commented/fee.mjs named/fee.mjs
```

```
commented/fee.mjs  comments=9  numeric claims=6  not in code=[45, 12]
named/fee.mjs      comments=1  numeric claims=0  not in code=[]
```

Nine comments dropped to one, six numeric claims to zero. Zero claims means the
possibility of a mismatch is also zero: a claim translated into a name changes together
with the code.

#### The Comment That Remains

One comment remains, and it needs to remain:

```
const VOLUMETRIC_DIVISOR = 3000; // set by the carrier contract, not decided here
```

This comment does not say what the code does; it says **why** it is so. No name can carry
where the number comes from, because that information sits outside the code. Whoever
deletes the comment assumes 3000 is a value open to improvement and tries to change it.

The kinds of comments that cannot be translated into a name are limited, and all of them
point outside the code:

- **Rationale:** the source of a value or a decision is a contract, standard, or
  organizational decision outside the codebase.
- **Warning:** an operation has a consequence not visible at first glance; a call's order
  matters, or crossing a limit is expensive.
- **Intentional deviation:** the code behaves differently from what is expected in one
  place, on purpose, and the comment records the deviation and its reason.
- **Legal and license text:** text required to be present at the top of a file.

Every comment outside these four kinds is a candidate: for a name, a function, or a test.
The comment "this function does not accept a negative weight," once translated into a
test or a guard clause, becomes a claim that is checked; left as a comment, it stays
unchecked.

#### Summary

- A comment does not compile and is not checked; the code changing while the comment
  stays put produces a silent error. The mismatch is measured by comparing the numeric
  claims in a comment against the code.
- The commented version of the fee module had nine comments, six numeric claims, and two
  claims with no counterpart in the code; the calculation by comment gave 133.44, the
  code's result was 128.89.
- After the refactoring that turns comments into names, the comment count dropped to one
  and the numeric claim count to zero; a claim translated into a name changes together
  with the code and cannot drift apart.
- The name change also fixed the structure: two arrays held together by matching indices
  became a single tier object.
- Comments that cannot be translated into a name point outside the code: rationale,
  warning, intentional deviation, and legal text. Every remaining comment is a candidate
  for a name, a function, or a test.

#### Next Step

Translating comments into names split a single function into four parts: volumetric
weight, billable weight, tier selection, and discount application. The split points were
set by where the comments happened to sit — an accidental measure. When should a function
be split? Is line count a measure, or can a ten-line function also contain something that
should be split? The next lesson pulls the measure out of line count and ties it to the
**single level of abstraction** rule: an analyzer counts the levels the names a function
calls belong to, and the two versions of the fee calculation are compared by that count.

### Small Functions and Classes

Source: https://academia.sh/en/courses/clean-code/small-functions-and-classes

Pulling the splitting measure out of line count and tying it to the single level of abstraction rule: an analyzer that counts how many separate abstraction levels a body carries vocabulary from, comparing level span across two versions of fee line generation, and applying the same measure to classes.

Translating comments into names split a single function into four parts, and the split
points were set by where the comments happened to sit. This is an accidental measure:
commenting habits vary from person to person. What is the measure for splitting? Line
count is the first answer that comes to mind, but a two-line function can also contain
something that should be split.

This lesson pulls the measure out of line count and ties it to the **single level of
abstraction** rule. The rule states: a function's body should speak in the vocabulary of
a single abstraction level. Because the levels of words can be counted, the rule can be
counted too.

#### The Library's Three Levels

The fee library has three separate vocabularies, and they change independently of each
other.

- **Measurement level:** a shipment's physical dimensions and the weight derived from
  them. Its words are `widthCm`, `lengthCm`, `heightCm`, `weightKg`,
  `VOLUMETRIC_DIVISOR`. When the carrier changes the volumetric divisor, only this level
  changes.
- **Fee policy level:** tiers, zone coefficients, discount, and minimum fee. Its words are
  `WEIGHT_TIERS`, `upperLimitKg`, `ratePerKg`, `ZONE_COEFFICIENTS`,
  `CONTRACTED_CUSTOMER_DISCOUNT`, `MINIMUM_FEE`. A tariff renegotiation changes this
  level.
- **Presentation level:** turning the result into text. Its words are `padEnd`,
  `toFixed`, `CURRENCY`. When the report's format changes, only this level changes.

How many levels' words appear in a given body can be counted. Two numbers are used:
**level count** (the number of levels whose words appear in the body) and **span** (the
gap in rank between the highest and lowest level present). The single level of
abstraction rule asks for the level count to be one.

#### The Version That Does Everything

The first version produces the fee line in a single function. Next to it, a second
two-line function is added to show that the measure is not line count.

```sh
mkdir -p big split class
```

```js
// big/fee.mjs — fee line generation in a single function
const VOLUMETRIC_DIVISOR = 3000;
const MINIMUM_FEE = 52;
const CONTRACTED_CUSTOMER_DISCOUNT = 0.15;
const CURRENCY = "TL";
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8 };
const WEIGHT_TIERS = [
  { upperLimitKg: 1, ratePerKg: 38 },
  { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 },
  { upperLimitKg: 30, ratePerKg: 17 },
];

export function shortSummary(shipment) {
  const kg = Math.max(shipment.weightKg, (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR);
  return `${WEIGHT_TIERS.find((t) => kg <= t.upperLimitKg).ratePerKg.toFixed(2)} ${CURRENCY}/kg`;
}

export function generateFeeLine(shipment, zoneCode) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  const weightKg = Math.max(shipment.weightKg, volumetric);
  const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
  const raw = tier.ratePerKg * weightKg * ZONE_COEFFICIENTS[zoneCode];
  const fee = Math.max(raw * (1 - CONTRACTED_CUSTOMER_DISCOUNT), MINIMUM_FEE);
  return `${shipment.code.padEnd(8)} ${weightKg.toFixed(2)} kg ${fee.toFixed(2)} ${CURRENCY}`;
}
```

#### The Level Measurer

The analyzer splits the source into function and class blocks, collects the names in each
body, and matches them against the level vocabulary. A function's **parameters and local
variables are not counted**: they are the function's own words, the bond between it and
the level above. What gets counted is the names the body reaches for from outside itself
— module constants, the field names it reads, and the built-in operations it calls.

```js
// level-measure.mjs — how many abstraction levels of vocabulary each function and class carries
import { readFileSync } from "node:fs";

// The library's three abstraction levels and each level's vocabulary.
const LEVELS = [
  ["measurement", ["widthCm", "lengthCm", "heightCm", "weightKg", "VOLUMETRIC_DIVISOR"]],
  ["fee", ["WEIGHT_TIERS", "upperLimitKg", "ratePerKg", "ZONE_COEFFICIENTS",
           "CONTRACTED_CUSTOMER_DISCOUNT", "MINIMUM_FEE"]],
  ["presentation", ["padEnd", "toFixed", "CURRENCY"]],
];
const KEYWORD = new Set(["if", "for", "while", "switch", "catch", "return", "function"]);
const braceDelta = (s) => (s.match(/{/g) ?? []).length - (s.match(/}/g) ?? []).length;

// Separates blocks running from an opening pattern to the matching closing brace.
function blocks(lines, pattern) {
  const output = [];
  let open = null, depth = 0;
  lines.forEach((line, i) => {
    if (open === null) {
      const m = line.match(pattern);
      if (!m || KEYWORD.has(m[1])) return;
      [open, depth] = [{ name: m[1], signature: m[2] ?? "", start: i, body: [] }, 0];
    } else open.body.push(line);
    depth += braceDelta(line);
    if (depth === 0) { output.push({ ...open, end: i }); open = null; }
  });
  return output;
}

// Parameters and local variables are a function's own words; they are not counted.
function levelsUsed(signature, body) {
  const own = new Set([...signature.matchAll(/\w+/g), ...body.matchAll(/(?:const|let)\s+(\w+)/g)]
    .map((m) => m[1] ?? m[0]));
  const names = new Set([...body.matchAll(/\w+/g)].map((m) => m[0]).filter((a) => !own.has(a)));
  return LEVELS.map(([d], i) => [d, i]).filter(([, i]) => LEVELS[i][1].some((s) => names.has(s)));
}

const printRow = (name, tag, found) => {
  const idx = found.map(([, n]) => n);
  const span = idx.length ? Math.max(...idx) - Math.min(...idx) : 0;
  console.log(`  ${name.padEnd(26)}${tag}  level=${found.length} span=${span}` +
    `  [${found.map(([d]) => d).join(", ") || "call only"}]`);
};

for (const file of process.argv.slice(2)) {
  const lines = readFileSync(file, "utf8").split("\n");
  const classes = blocks(lines, /^\s*(?:export\s+)?class\s+(\w+)/);
  console.log(file);
  for (const c of [...classes, null]) {
    const within = (f) => (c ? f.start > c.start && f.end <= c.end
                             : classes.every((k) => f.start < k.start || f.end > k.end));
    const members = blocks(lines, /^\s*(?:export\s+)?(?:function\s+)?(\w+)\s*\(([^)]*)\)\s*{/)
      .filter(within);
    const all = new Map();
    for (const f of members) {
      const found = levelsUsed(f.signature, f.body.join("\n"));
      for (const [d, i] of found) all.set(d, i);
      printRow((c ? `${c.name}.` : "") + f.name, `lines=${String(f.body.length - 1).padEnd(2)}`, found);
    }
    if (c) printRow(`= ${c.name}`, `members=${String(members.length).padEnd(4)}`, [...all]);
  }
}
```

#### The Split Version

The second version performs the same arithmetic. Each function speaks in a single
level's vocabulary; the topmost function does not touch a single level's word, it only
calls.

```js
// split/fee.mjs — same calculation, each function at a single level
const VOLUMETRIC_DIVISOR = 3000;
const MINIMUM_FEE = 52;
const CONTRACTED_CUSTOMER_DISCOUNT = 0.15;
const CURRENCY = "TL";
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8 };
const WEIGHT_TIERS = [
  { upperLimitKg: 1, ratePerKg: 38 },
  { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 },
  { upperLimitKg: 30, ratePerKg: 17 },
];

function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

function tierFee(weightKg) {
  const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
  return tier.ratePerKg * weightKg;
}

function zonedFee(fee, zoneCode) {
  return fee * ZONE_COEFFICIENTS[zoneCode];
}

function discountedFee(fee) {
  return Math.max(fee * (1 - CONTRACTED_CUSTOMER_DISCOUNT), MINIMUM_FEE);
}

function feeLine(shipmentCode, weightKg, fee) {
  return `${shipmentCode.padEnd(8)} ${weightKg.toFixed(2)} kg ${fee.toFixed(2)} ${CURRENCY}`;
}

export function generateFeeLine(shipment, zoneCode) {
  const weight = billableWeightKg(shipment);
  const fee = discountedFee(zonedFee(tierFee(weight), zoneCode));
  return feeLine(shipment.code, weight, fee);
}
```

Both versions are confirmed to produce the same line.

```js
// call.mjs — verifies both versions produce the same line
import { generateFeeLine as big } from "./big/fee.mjs";
import { generateFeeLine as split } from "./split/fee.mjs";

const shipment = { code: "GN-4172", weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18 };
console.log(big(shipment, 2));
console.log(split(shipment, 2));
```

```
GN-4172  4.32 kg 128.89 TL
GN-4172  4.32 kg 128.89 TL
```

#### Measurement

```sh
node level-measure.mjs big/fee.mjs split/fee.mjs
```

```
big/fee.mjs
  shortSummary              lines=2   level=3 span=2  [measurement, fee, presentation]
  generateFeeLine           lines=6   level=3 span=2  [measurement, fee, presentation]
split/fee.mjs
  billableWeightKg          lines=2   level=1 span=0  [measurement]
  tierFee                   lines=2   level=1 span=0  [fee]
  zonedFee                  lines=1   level=1 span=0  [fee]
  discountedFee             lines=1   level=1 span=0  [fee]
  feeLine                   lines=1   level=1 span=0  [presentation]
  generateFeeLine           lines=3   level=0 span=0  [call only]
```

The table puts line count and level count side by side and shows the two are
independent. `shortSummary` is two lines and carries all three levels at once: it
computes the volumetric weight, selects the tier, and formats the result. The split
version's `generateFeeLine` is three lines and does not touch a single level's word. If
brevity were the measure, the first would be accepted and no difference would be seen
against the second.

In the split version, five of the six functions sit at a single level, one at level
zero. Zero means the body consists only of calls: the topmost function states the
**order** the work happens in, not the work itself. Someone who wants to know which steps
the fee calculation passes through, not how it is computed, reads only those three lines.

#### Where Splitting Stops

The measure carries its own stopping condition. Once the level count reaches one, there
is nothing left to extract; splitting a single level in two does not produce a new level,
it produces two functions at the same level and increases the call count. `zonedFee` is a
single line and cannot be split any smaller.

There is a limit in the other direction too. `discountedFee` looks like it does two
things: it applies the discount and clamps to the minimum fee. These can be separated,
but both belong to the fee policy level and **their order carries meaning** — the
discount is applied before the minimum fee, and the reverse gives a different result. Two
steps at the same level whose order is binding shift the responsibility of preserving
that order onto the caller once they are separated. The splitting decision here does not
look at level count; it looks at where the order gets preserved.

#### The Same Rule for Classes

A class is functions that share state, gathered together; the measure becomes the sum of
that same measure. A class's smallness is measured not by its number of methods, but by
how many levels its members are spread across in total.

```js
// class/single.mjs — a single class holding the state of all three levels
const VOLUMETRIC_DIVISOR = 3000;
const CURRENCY = "TL";
const WEIGHT_TIERS = [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }];

export class ShipmentFee {
  constructor(shipment) {
    this.shipment = shipment;
  }
  weight() {
    const volumetric = (this.shipment.widthCm * this.shipment.lengthCm * this.shipment.heightCm) / VOLUMETRIC_DIVISOR;
    return Math.max(this.shipment.weightKg, volumetric);
  }
  fee() {
    const kg = this.weight();
    return WEIGHT_TIERS.find((t) => kg <= t.upperLimitKg).ratePerKg * kg;
  }
  line() {
    return `${this.fee().toFixed(2)} ${CURRENCY}`;
  }
}
```

Every method of this class sits at a single level; the function measure sees no defect
here. The defect is in the total: three methods belong to three separate levels, so the
class has three separate reasons to change. In the split version, every unit stays at a
single level.

```js
// class/split.mjs — the same work, each class at a single level
const VOLUMETRIC_DIVISOR = 3000;
const CURRENCY = "TL";
const WEIGHT_TIERS = [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }];

export class Measurement {
  constructor(shipment) {
    this.shipment = shipment;
  }
  billableKg() {
    const volumetric = (this.shipment.widthCm * this.shipment.lengthCm * this.shipment.heightCm) / VOLUMETRIC_DIVISOR;
    return Math.max(this.shipment.weightKg, volumetric);
  }
}

export class Tariff {
  tierFee(kg) {
    return WEIGHT_TIERS.find((t) => kg <= t.upperLimitKg).ratePerKg * kg;
  }
}

export function feeLine(fee) {
  return `${fee.toFixed(2)} ${CURRENCY}`;
}
```

```sh
node level-measure.mjs class/single.mjs class/split.mjs
```

```
class/single.mjs
  ShipmentFee.constructor   lines=1   level=0 span=0  [call only]
  ShipmentFee.weight        lines=2   level=1 span=0  [measurement]
  ShipmentFee.fee           lines=2   level=1 span=0  [fee]
  ShipmentFee.line          lines=1   level=1 span=0  [presentation]
  = ShipmentFee             members=4     level=3 span=2  [measurement, fee, presentation]
class/split.mjs
  Measurement.constructor   lines=1   level=0 span=0  [call only]
  Measurement.billableKg    lines=2   level=1 span=0  [measurement]
  = Measurement             members=2     level=1 span=0  [measurement]
  Tariff.tierFee            lines=1   level=1 span=0  [fee]
  = Tariff                  members=1     level=1 span=0  [fee]
  feeLine                   lines=1   level=1 span=0  [presentation]
```

Rows beginning with `= ` are the class total. `ShipmentFee` spreads four members across
three levels; `Measurement` sits at one level with two members, `Tariff` at one level
with one member. They got smaller not because the member count shrank, but because the
level count dropped to one. A class with four methods that all sit at the measurement
level is smaller than a class with three methods spread across three levels.

#### Summary

- The measure for splitting is not line count but how many abstraction levels' words a
  body carries; the fee library's three levels are measurement, fee policy, and
  presentation.
- The measurement does not count parameters or local variables; what gets counted is the
  names a body reaches for from outside itself.
- The two-line `shortSummary` carries three levels; the three-line `generateFeeLine` in
  the split version touches none: brevity and level count are independent measures.
- In the split version, five of six functions sit at a single level and one consists only
  of calls; both versions produce the same line.
- Splitting stops once the level count reaches one; when two steps at the same level have
  a binding order, the splitting decision looks at where that order gets preserved.
- A class's smallness is measured not by method count but by the total number of levels
  its members span: a four-member class spread across three levels was split into two
  classes that each stay at one level.

#### Next Step

In the split version, every function speaks at a single level, but nothing has yet been
said about the file itself: where lines end, which declarations sit next to each other,
how much indentation there should be. These are usually treated as a stylistic
preference, and the argument about them usually gets settled by taste. Yet the format
decision has a measurable consequence: the same content change produces a different
number of diff lines in different formats. The next lesson writes the tariff table in
two different formats and applies the same change to both, then compares the number of
lines in the `diff` output.

### Indentation and Code Formatting

Source: https://academia.sh/en/courses/clean-code/indentation-and-code-formatting

Taking code format out of the realm of style and measuring it by diff cost: the diff lines produced by the same content in two indentation schemes, the diff line counts the same revision produces in an aligned and a plain form of the tariff table, and separating out the diff lines that carry no content.

The previous lesson brought every function down to a single level of abstraction, but
nothing was yet said about the file itself: where lines end, which declarations sit next
to each other, how much indentation there should be. These decisions are usually treated
as style, and the argument about them usually gets settled by taste.

Format has a measurable consequence. Every change to a codebase produces a **diff**, and
someone else reads that diff and approves it. The same content change produces a
different number of diff lines in different formats, and some of those lines carry no
information at all. This lesson ties format to two numbers: the number of diff lines a
change produces, and how many of those carry no content.

#### Indentation Shows the Structure Itself

Indentation is the only signal that carries the depth of nested blocks to the eye. When
structure and indentation part ways, whoever is reading has to find where a block ends by
counting curly braces by hand. The two files below carry the same sequence of words; only
their indentation differs.

```sh
mkdir -p tidy messy aligned plain
```

```js
// tidy/fee.mjs — indentation matches structural depth
const MINIMUM_FEE = 52;
const WEIGHT_TIERS = [
  { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 30, ratePerKg: 17 },
];

export function tierFee(weightKg, zoneCoefficient) {
  const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
  if (tier === undefined) {
    throw new RangeError("weight outside tier range");
  }
  return Math.max(tier.ratePerKg * weightKg * zoneCoefficient, MINIMUM_FEE);
}
```

```js
// messy/fee.mjs — same content, indentation does not match structure
const MINIMUM_FEE = 52;
const WEIGHT_TIERS = [
    { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 30, ratePerKg: 17 },
];

export function tierFee(weightKg, zoneCoefficient) {
    const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
      if (tier === undefined) {
    throw new RangeError("weight outside tier range");
      }
  return Math.max(tier.ratePerKg * weightKg * zoneCoefficient, MINIMUM_FEE);
}
```

In the second file, the `throw` line is indented less than the condition that holds it.
A reader looking at that line cannot tell from the indentation alone whether it sits
inside the condition or outside it. The same ambiguity is in the first entry of the
`WEIGHT_TIERS` array: the two entries sit at the same structural depth but in different
columns.

The difference between the two files is measurable. The first line of each file is the
marker carrying the filename, so it does not enter the comparison.

```sh
diff <(tail -n +2 tidy/fee.mjs) <(tail -n +2 messy/fee.mjs)
```

```
3c3
<   { upperLimitKg: 5, ratePerKg: 26 },
---
>     { upperLimitKg: 5, ratePerKg: 26 },
8,9c8,9
<   const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
<   if (tier === undefined) {
---
>     const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
>       if (tier === undefined) {
11c11
<   }
---
>       }
```

Eight diff lines, and none of them describe a behavior change. This is the first cost of
format disagreement: whoever fixes the indentation produces an eight-line diff without
changing any content, and someone else has to read that diff.

#### The Tariff Table's Two Formats

The second measurement answers a more concrete question: how many diff lines does the
same **content** change produce in two different formats? The tariff module was written
in two formats. In the first, the equals signs are aligned and the tier table is packed
two entries per line; in the second, declarations carry a single space and every tier
sits on its own line.

```js
// aligned/tariff.mjs — aligned declarations, packed tier table
export const VOLUMETRIC_DIVISOR           = 3000;
export const MINIMUM_FEE                  = 52;
export const CONTRACTED_CUSTOMER_DISCOUNT = 0.15;
export const CURRENCY                     = "TL";

export const WEIGHT_TIERS = [
  { upperLimitKg:  1, ratePerKg: 38 }, { upperLimitKg:  5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 },
];
```

```js
// plain/tariff.mjs — single-space declarations, one tier per line
export const VOLUMETRIC_DIVISOR = 3000;
export const MINIMUM_FEE = 52;
export const CONTRACTED_CUSTOMER_DISCOUNT = 0.15;
export const CURRENCY = "TL";

export const WEIGHT_TIERS = [
  { upperLimitKg: 1, ratePerKg: 38 },
  { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 },
  { upperLimitKg: 30, ratePerKg: 17 },
];
```

The carrier revisited the tariff and three things changed: the rate for the five
kilogram tier dropped from 26 to 25, a new tier opened up to fifty kilograms, and the
`CONTRACTED_CUSTOMER_DISCOUNT` constant was renamed to `CONTRACT_DISCOUNT`. The same
three changes are applied to both files.

```js
// aligned/tariff-new.mjs — same revision, alignment preserved
export const VOLUMETRIC_DIVISOR = 3000;
export const MINIMUM_FEE        = 52;
export const CONTRACT_DISCOUNT  = 0.15;
export const CURRENCY           = "TL";

export const WEIGHT_TIERS = [
  { upperLimitKg:  1, ratePerKg: 38 }, { upperLimitKg:  5, ratePerKg: 25 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 },
  { upperLimitKg: 50, ratePerKg: 14 },
];
```

```js
// plain/tariff-new.mjs — tariff revision applied
export const VOLUMETRIC_DIVISOR = 3000;
export const MINIMUM_FEE = 52;
export const CONTRACT_DISCOUNT = 0.15;
export const CURRENCY = "TL";

export const WEIGHT_TIERS = [
  { upperLimitKg: 1, ratePerKg: 38 },
  { upperLimitKg: 5, ratePerKg: 25 },
  { upperLimitKg: 10, ratePerKg: 21 },
  { upperLimitKg: 30, ratePerKg: 17 },
  { upperLimitKg: 50, ratePerKg: 14 },
];
```

In the aligned version, the shortened name moved the alignment column, so all four
declarations were rewritten. In the plain version, only the declaration whose name
actually changed was rewritten.

#### The Diff Measurer

The tool below diffs each file pair and splits the diff lines in two. If a removed
line, once its whitespace is collapsed, also shows up among the added lines, that pair is
a **format-only** change; it carries no content. What remains is a content change.

```js
// diff-measure.mjs — how many of the diff lines between two files carry content
import { execSync } from "node:child_process";

// The first line is the filename marker; it does not enter the comparison.
const diff = (before, after) =>
  execSync(`diff <(tail -n +2 ${before}) <(tail -n +2 ${after}) || true`, { shell: "/bin/bash" })
    .toString().split("\n");
const content = (s) => s.slice(1).replace(/\s+/g, " ").trim();

const PAIRS = [
  ["tidy/fee.mjs", "messy/fee.mjs"],
  ["aligned/tariff.mjs", "aligned/tariff-new.mjs"],
  ["plain/tariff.mjs", "plain/tariff-new.mjs"],
];

for (const [before, after] of PAIRS) {
  const lines = diff(before, after);
  const removed = lines.filter((s) => s.startsWith("< ")).map(content);
  const added = lines.filter((s) => s.startsWith("> ")).map(content);
  const matched = removed.filter((s) => added.includes(s)).length;
  console.log(`${before.padEnd(18)} -> ${after.padEnd(22)} changed=${removed.length + added.length}` +
    `  format only=${matched * 2}  content=${removed.length + added.length - matched * 2}`);
}
```

```
tidy/fee.mjs       -> messy/fee.mjs          changed=8  format only=8  content=0
aligned/tariff.mjs -> aligned/tariff-new.mjs changed=11  format only=6  content=5
plain/tariff.mjs   -> plain/tariff-new.mjs   changed=5  format only=0  content=5
```

The three rows say the following. The indentation fix produces eight diff lines and all
eight are format; the content diff is zero. The tariff revision's content diff is the
same in both formats: five lines. In the aligned format, six more lines are added next to
those five.

#### Whoever Reads the Diff

What the numbers mean is the job of whoever reads the diff. In the eleven-line diff, five
lines carry a decision and six do not; the reader does not know beforehand which is
which and has to compare all of them. In the five-line diff, there is no such sorting to
do.

The packed table has a second effect too. In the aligned version's diff output, the
changed table row carries two tiers at once: one changed, one did not. The reader has to
search character by character for where in the row the change sits. In the plain
version, the changed row carries a single tier, so the comparison ends with the row
itself.

This is where the format decision's measure comes from: **a line should carry one piece
of information, and one piece of information's format should not depend on its
neighbor.** Alignment breaks the second condition; when one name's length sets the
whitespace count on neighboring lines, a declaration whose name changes rewrites its
neighbors too. Packing more than one item per line breaks the first condition.

#### The Format Decision Gets Made Once

The measurement does not say exactly which format to choose; whether indentation should
be two or four spaces does not show up in these numbers. What it says is that the
decision should be made **once, at the project level**, and not reopened file by file.
When two indentation habits live in the same project side by side, every file turns into
whoever last worked on it, and every such turn produces a diff line that carries no
content.

For this reason the format decision is a matter for a configuration file, not a
discussion. A tool from the formatter class applies the rules to the source
mechanically; the decision gets made once, and applying it is not left to the individual.
The format the tool picks may not line up with anyone's preference exactly; the measured
cost is larger than the cost of a preference mismatch.

#### Summary

- The measure of a format decision is the number of diff lines it produces: every change
  produces a diff, and someone else reads that diff.
- A file with only its indentation broken produced eight diff lines and all eight were
  content-free; a format mismatch has a cost even when nothing changes.
- The same tariff revision produced five diff lines in the plain format and eleven in the
  aligned format; the content diff was five lines in both, the remaining six came only
  from alignment.
- A line should carry one piece of information, and a piece of information's format
  should not depend on the neighboring lines; alignment breaks the second condition,
  packing several items per line breaks the first.
- The format decision gets made once at the project level and applied mechanically; a
  decision remade file by file produces a content-free diff every time.

#### Next Step

Format determines how code looks to the eye, not the structure itself. A properly
indented function can still be hard to read: nested conditions, multiple exit points, and
compound logical expressions do not get fixed by indentation. This difficulty has a
number too. The next lesson writes a measurer that counts a function's decision points,
compares cyclomatic complexity and the number of linearly independent paths across two
versions of the fee calculation, and then applies the transformations that lower the
number.

### Reducing Cyclomatic Complexity

Source: https://academia.sh/en/courses/clean-code/reducing-cyclomatic-complexity

Measuring and reducing the number of branches: writing a measurer that counts decision points, comparing cyclomatic complexity between a nested and a simplified version of the fee calculation, and moving branching into data with guard clause and lookup table transformations.

Format decisions determine how code looks to the eye, not its structure. A properly
indented function can still be hard to read: nested conditions, multiple exit points, and
compound logical expressions do not get fixed by indentation. The measure of this
difficulty is how many places the control flow splits into.

**Cyclomatic complexity** is that number. It gives the count of linearly independent
paths through a function's control flow graph, and it is computed with a single rule:
the number of decision points, plus one. A decision point is any structure that splits
the flow in two — `if`, `for`, `while`, `case`, `catch`, `&&`, `||`, `??`, and the
ternary conditional. The number corresponds to two things: how many states a reader has
to hold in mind while reading the function, and the lower bound on a test suite that
covers every branch.

#### The Version Where Decisions Pile Into One Function

Over time the fee function took on three more jobs: input validation, a discount by
customer type, and a coefficient by zone. All of them sit in the same function, nested.

```sh
mkdir -p complex simple
```

```js
// complex/fee.mjs — validation, discount, and zone decisions nested together
const VOLUMETRIC_DIVISOR = 3000;

export function calculateFee(shipment, tariff, customer) {
  if (shipment.weightKg > 0) {
    if (shipment.widthCm > 0 && shipment.lengthCm > 0 && shipment.heightCm > 0) {
      const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
      const weight = shipment.weightKg > volumetric ? shipment.weightKg : volumetric;
      let fee = 0;
      for (const tier of tariff.weightTiers) {
        if (fee === 0 && weight <= tier.upperLimitKg) {
          fee = tier.ratePerKg * weight;
        }
      }
      if (fee === 0) {
        throw new RangeError("weight outside tier range");
      }
      if (customer.type === "contracted") {
        fee = fee * 0.85;
      } else if (customer.type === "corporate") {
        fee = fee * 0.92;
      }
      if (shipment.zoneCode === 3 || shipment.zoneCode === 4) {
        fee = fee * 1.8;
      } else if (shipment.zoneCode === 2) {
        fee = fee * 1.35;
      }
      return fee < tariff.minimumFee ? tariff.minimumFee : fee;
    } else {
      throw new RangeError("dimensions must be positive");
    }
  } else {
    throw new RangeError("weight must be positive");
  }
}
```

#### The Simplified Version

The second version applies three transformations. **Guard clause:** the validation
conditions are inverted and moved to the top, invalid input is rejected early, and the
actual calculation comes out from the bottom of the indentation. **Lookup table:** the
discount and zone decisions move from a chain of conditionals into a data structure.
**Extraction:** the remaining work moves into its own functions.

```js
// simple/fee.mjs — same behavior, decisions moved to a table and guard clauses
const VOLUMETRIC_DIVISOR = 3000;
const DISCOUNT_RATES = { contracted: 0.15, corporate: 0.08 };
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8, 4: 1.8 };

function validateDimensions(shipment) {
  if (shipment.weightKg <= 0) throw new RangeError("weight must be positive");
  const dimensions = [shipment.widthCm, shipment.lengthCm, shipment.heightCm];
  if (dimensions.some((d) => d <= 0)) throw new RangeError("dimensions must be positive");
}

function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

function tierFee(weightKg, tariff) {
  const tier = tariff.weightTiers.find((t) => weightKg <= t.upperLimitKg);
  if (tier === undefined) throw new RangeError("weight outside tier range");
  return tier.ratePerKg * weightKg;
}

export function calculateFee(shipment, tariff, customer) {
  validateDimensions(shipment);
  const weight = billableWeightKg(shipment);
  const discount = 1 - (DISCOUNT_RATES[customer.type] ?? 0);
  const coefficient = ZONE_COEFFICIENTS[shipment.zoneCode] ?? 1;
  return Math.max(tierFee(weight, tariff) * discount * coefficient, tariff.minimumFee);
}
```

For the comparison to mean anything, both versions are shown to keep the same behavior.
Seven cases are checked: four valid calculations and three errors.

```js
// compare.mjs — verifies both versions give the same result on the same inputs
import { calculateFee as complex } from "./complex/fee.mjs";
import { calculateFee as simple } from "./simple/fee.mjs";

const tariff = {
  weightTiers: [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
    { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }],
  minimumFee: 52,
};
const box = { widthCm: 30, lengthCm: 24, heightCm: 18 };
const CASES = [
  ["contracted, zone 2", { ...box, weightKg: 2.4, zoneCode: 2 }, "contracted"],
  ["corporate, zone 3", { ...box, weightKg: 6, zoneCode: 3 }, "corporate"],
  ["individual, zone 1", { widthCm: 10, lengthCm: 10, heightCm: 10, weightKg: 0.4, zoneCode: 1 }, "individual"],
  ["corporate, zone 4", { ...box, weightKg: 12, zoneCode: 4 }, "corporate"],
  ["zero weight", { ...box, weightKg: 0, zoneCode: 1 }, "individual"],
  ["zero width", { ...box, widthCm: 0, weightKg: 2, zoneCode: 1 }, "individual"],
  ["outside tier range", { ...box, weightKg: 40, zoneCode: 1 }, "individual"],
];

const result = (fn, shipment, type) => {
  try {
    return fn(shipment, tariff, { type }).toFixed(2);
  } catch (error) {
    return `error: ${error.message}`;
  }
};

for (const [name, shipment, type] of CASES) {
  const a = result(complex, shipment, type);
  const b = result(simple, shipment, type);
  console.log(`${name.padEnd(20)} ${a === b ? "same  " : "DIFFER"} ${a}`);
}
```

```
contracted, zone 2   same   128.89
corporate, zone 3    same   208.66
individual, zone 1   same   52.00
corporate, zone 4    same   337.82
zero weight          same   error: weight must be positive
zero width           same   error: dimensions must be positive
outside tier range   same   error: weight outside tier range
```

#### The Measurer

The measurer splits the source into functions, counts decision-point patterns in each
body, and gives complexity as the decision count plus one. The module row is the sum of
the values computed per function.

```js
// complexity-measure.mjs — counts each function's decision points and cyclomatic complexity
import { readFileSync } from "node:fs";

// Every structure that branches the control flow is a decision point.
const DECISION = [
  ["if", /\bif\s*\(/g], ["for", /\bfor\s*\(/g], ["while", /\bwhile\s*\(/g],
  ["case", /\bcase\s+/g], ["catch", /\bcatch\s*\(/g],
  ["&&", /&&/g], ["||", /\|\|/g], ["??", /\?\?/g], ["?:", /\?(?!\?)[^:\n]*:/g],
];

function functions(source) {
  const output = [];
  let open = null, depth = 0;
  for (const line of source.split("\n")) {
    if (open === null) {
      const m = line.match(/^(?:export\s+)?function\s+(\w+)/);
      if (m === null) continue;
      [open, depth] = [{ name: m[1], body: [] }, 0];
    }
    open.body.push(line);
    depth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
    if (depth === 0) { output.push({ name: open.name, body: open.body.join("\n") }); open = null; }
  }
  return output;
}

for (const file of process.argv.slice(2)) {
  console.log(file);
  let total = 0;
  for (const { name, body } of functions(readFileSync(file, "utf8"))) {
    const counts = DECISION.map(([label, pattern]) => [label, (body.match(pattern) ?? []).length]);
    const decisions = counts.reduce((t, [, n]) => t + n, 0);
    const breakdown = counts.filter(([, n]) => n > 0).map(([l, n]) => `${l}:${n}`).join(" ");
    total += decisions + 1;
    console.log(`  ${name.padEnd(24)} decisions=${String(decisions).padEnd(3)}M=${String(decisions + 1).padEnd(3)}${breakdown || "-"}`);
  }
  console.log(`  ${"= module".padEnd(24)} total M=${total}`);
}
```

```sh
node complexity-measure.mjs complex/fee.mjs simple/fee.mjs
```

```
complex/fee.mjs
  calculateFee             decisions=15 M=16 if:8 for:1 &&:3 ||:1 ?::2
  = module                 total M=16
simple/fee.mjs
  validateDimensions       decisions=2  M=3  if:2
  billableWeightKg         decisions=0  M=1  -
  tierFee                  decisions=1  M=2  if:1
  calculateFee             decisions=2  M=3  ??:2
  = module                 total M=9
```

Fifteen decision points piled into a single function mean sixteen independent paths. In
the simplified version, the highest value is three and the module total drops to nine.
The two numbers say different things: the module total says how many branches exist in
the work as a whole, the per-function value says how much of it one person has to hold in
mind at once.

#### Branches in the Same Function Multiply

Why can the total drop to nine? Part of it comes from branching moving into data: the
discount chain went from two conditions to two table rows, the zone chain from two
conditions to four table rows. The rest of the gain comes from the split itself.

Branches sitting in the same function **multiply**. In the complex version, once
validation passes, the discount gives three outcomes (contracted, corporate, or
neither), the zone gives three outcomes (distant, neighboring, or same-city), and the
minimum fee comparison gives two outcomes. Since these are independent of each other,
the number of combinations that determine how the function ends is 3 × 3 × 2 = 18, and
all of them sit inside a single body.

When the same three decisions are distributed across separate functions, the
combination count **adds**: each function is tested separately with its own branches,
because the only connection between them is passing a single value along. The table
transformation carries this further: `DISCOUNT_RATES` and `ZONE_COEFFICIENTS` are data
rows, so they produce no branches at all. Adding a new customer type adds an `else if`
branch to the complex version and increases its complexity by one; it adds a table row
to the simplified version and does not increase complexity at all.

#### What the Number Does Not Say

Cyclomatic complexity has limits, and using it without knowing them produces the wrong
decisions.

**The number and clarity are not the same thing.** A long `switch` block produces a high
value but reads at a glance; a single condition inside two nested loops produces a lower
value and is harder to follow. The measure counts how many ways the flow splits; it does
not count how similar the resulting paths are to each other.

**The number is lowered by splitting, but splitting is not free.** Code that distributes
fifteen decision points across fifteen functions is spotless in the measure's eyes and
hard to read: the reader now has to follow a chain of calls instead. This is where the
previous lesson's measure comes back in: split points are chosen by abstraction level,
not to lower the number.

**A high value is not a defect, it is a signal.** When the measurement flags a function, the
question is not "how should this be split," it is "should all of these decisions really be made
here." The complex version's eight `if` statements were an example of this: three belonged to
validation, two to the discount, two to the zone; the remaining one was the tier selection inside
the loop — the very place the fee is computed.

#### Summary

- Cyclomatic complexity is the decision count plus one, and it gives the number of
  independent paths through a function's control flow; decision points are `if`, `for`,
  `while`, `case`, `catch`, `&&`, `||`, `??`, and the ternary conditional.
- In the nested version, a single function carried fifteen decision points and M = 16;
  after the guard clause, lookup table, and extraction transformations, the highest
  function value was 3 and the module total was 9.
- Both versions produce the same result in all seven cases; the measurement was done
  without changing behavior.
- Independent branches in the same body multiply (3 × 3 × 2 = 18 combinations); split
  across separate functions they add; a decision moved into a table produces no branch
  at all.
- The measure counts how many ways the flow splits, not how clear the paths are; a high
  value points not to a place that needs splitting but to a pile of decisions that needs
  questioning.

#### Next Step

Zone coefficients moved from a chain of conditionals into a table, and complexity
dropped. Is this transformation always correct? A table returns a **value** for a key;
what if the counterpart is not a value but a behavior, or if every tariff type computes
its fee by a different method entirely? At that point a third option appears:
polymorphism. The next lesson writes three versions of the same behavior — conditional
chain, lookup table, and polymorphism — and ties the choice to a measurable question: how
many files and how many lines get touched when a new tariff type is added.

### Choosing the Right Structure

Source: https://academia.sh/en/courses/clean-code/choosing-the-right-structure

Writing three versions of the same behavior with a conditional chain, a lookup table, and polymorphism, and tying the choice to a measure: the number of files and lines touched when a new tariff type is added, how many places a type name appears, and the cost of a type carrying its own state.

The previous lesson moved zone coefficients from a conditional chain into a table, and
complexity dropped. This transformation is not always correct. A table returns a
**value** for a key; when the counterpart is not a value but a behavior, or when every
option carries its own data, a third option appears: polymorphism.

Choosing among the three looks like a matter of style. It has a measurable side: a
design's cost shows up in the change made to it. This lesson writes the same behavior
in three structures, then applies the same change to all three and compares the number
of files and lines touched.

#### The Shared Calculation

All three designs start from the same base fee: tier rate times zone coefficient. Where
they diverge is where the tariff type gets selected. There are three types (standard,
express, economy), and each type appears in three operations at once: fee, delivery day,
label.

```sh
mkdir -p conditional table polymorphic
```

```js
// common.mjs — base fee calculation shared by the three designs
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8 };
const WEIGHT_TIERS = [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }];

export function baseFee(shipment) {
  const tier = WEIGHT_TIERS.find((t) => shipment.weightKg <= t.upperLimitKg);
  return tier.ratePerKg * shipment.weightKg * ZONE_COEFFICIENTS[shipment.zoneCode];
}
```

#### Three Designs

**Conditional chain.** The type decision gets made again in every operation.

```js
// conditional/fee.mjs — tariff type selected by a conditional chain in every operation
import { baseFee } from "../common.mjs";

export function fee(shipment, type) {
  const base = baseFee(shipment);
  if (type === "standard") return base;
  if (type === "express") return base * 1.6 + 25;
  if (type === "economy") return Math.max(base * 0.8, 65);
  throw new RangeError(`unknown tariff: ${type}`);
}

export function deliveryDays(type) {
  if (type === "standard") return 3;
  if (type === "express") return 1;
  if (type === "economy") return 6;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function label(type) {
  if (type === "standard") return "Standard";
  if (type === "express") return "Express";
  if (type === "economy") return "Economy";
  throw new RangeError(`unknown tariff: ${type}`);
}
```

**Lookup table.** The type decision gets made once; a type is one data row.

```js
// table/fee.mjs — tariff types in a single table, one type per row
import { baseFee } from "../common.mjs";

const TARIFFS = {
  standard: { label: "Standard", days: 3, calculate: (base) => base },
  express: { label: "Express", days: 1, calculate: (base) => base * 1.6 + 25 },
  economy: { label: "Economy", days: 6, calculate: (base) => Math.max(base * 0.8, 65) },
};

function tariff(type) {
  const selected = TARIFFS[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;
```

**Polymorphism.** A type is a class; the classes implement the same members and are
recorded in a registry.

```js
// polymorphic/types.mjs — every type implements the same three members
export class Standard {
  label = "Standard";
  days = 3;
  calculate(base) { return base; }
}

export class Express {
  label = "Express";
  days = 1;
  calculate(base) { return base * 1.6 + 25; }
}

export class Economy {
  label = "Economy";
  days = 6;
  calculate(base) { return Math.max(base * 0.8, 65); }
}
```

```js
// polymorphic/fee.mjs — types are recorded in a registry, selection is made from it
import { baseFee } from "../common.mjs";
import { Standard, Express, Economy } from "./types.mjs";

const REGISTRY = { standard: new Standard(), express: new Express(), economy: new Economy() };

function tariff(type) {
  const selected = REGISTRY[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;
```

The three designs are confirmed to produce the same results.

```js
// compare.mjs — verifies the three designs give the same results
import * as conditional from "./conditional/fee.mjs";
import * as table from "./table/fee.mjs";
import * as polymorphic from "./polymorphic/fee.mjs";

const shipment = { weightKg: 4.2, zoneCode: 2 };
for (const type of ["standard", "express", "economy"]) {
  const row = [conditional, table, polymorphic].map((m) =>
    `${m.label(type)}/${m.deliveryDays(type)}/${m.fee(shipment, type).toFixed(2)}`);
  const same = row.every((s) => s === row[0]);
  console.log(`${type.padEnd(10)} ${same ? "same  " : "DIFFER"} ${row[0]}`);
}
```

```
standard   same   Standard/3/147.42
express    same   Express/1/260.87
economy    same   Economy/6/117.94
```

#### Change: a Fourth Type

A cold chain tariff opens: the fee is 2.2 times the base fee plus a forty-unit
refrigeration charge, delivery time two days. The change is applied to a copy of all
three designs.

```sh
cp -r conditional conditional-new && cp -r table table-new && cp -r polymorphic polymorphic-new
```

```js
// conditional-new/fee.mjs — one more branch in every chain
import { baseFee } from "../common.mjs";

export function fee(shipment, type) {
  const base = baseFee(shipment);
  if (type === "standard") return base;
  if (type === "express") return base * 1.6 + 25;
  if (type === "economy") return Math.max(base * 0.8, 65);
  if (type === "cold-chain") return base * 2.2 + 40;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function deliveryDays(type) {
  if (type === "standard") return 3;
  if (type === "express") return 1;
  if (type === "economy") return 6;
  if (type === "cold-chain") return 2;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function label(type) {
  if (type === "standard") return "Standard";
  if (type === "express") return "Express";
  if (type === "economy") return "Economy";
  if (type === "cold-chain") return "Cold Chain";
  throw new RangeError(`unknown tariff: ${type}`);
}
```

```js
// table-new/fee.mjs — one more row in the table
import { baseFee } from "../common.mjs";

const TARIFFS = {
  standard: { label: "Standard", days: 3, calculate: (base) => base },
  express: { label: "Express", days: 1, calculate: (base) => base * 1.6 + 25 },
  economy: { label: "Economy", days: 6, calculate: (base) => Math.max(base * 0.8, 65) },
  "cold-chain": { label: "Cold Chain", days: 2, calculate: (base) => base * 2.2 + 40 },
};

function tariff(type) {
  const selected = TARIFFS[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;
```

```js
// polymorphic-new/cold-chain.mjs — the new type, in its own file
export class ColdChain {
  label = "Cold Chain";
  days = 2;
  calculate(base) { return base * 2.2 + 40; }
}
```

```js
// polymorphic-new/fee.mjs — one more entry in the registry
import { baseFee } from "../common.mjs";
import { Standard, Express, Economy } from "./types.mjs";
import { ColdChain } from "./cold-chain.mjs";

const REGISTRY = { standard: new Standard(), express: new Express(), economy: new Economy(),
  "cold-chain": new ColdChain() };

function tariff(type) {
  const selected = REGISTRY[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;
```

#### Measurement

The measurer compares each design's old and new directories file by file; a removed
line is one that changed or was deleted, an added line is one that was inserted.

```js
// change-measure.mjs — how many files and lines the same type addition touches in three designs
import { execSync } from "node:child_process";
import { readdirSync } from "node:fs";

// The first line is the filename marker; it does not enter the comparison.
const diff = (before, after) =>
  execSync(`diff <(tail -n +2 ${before} 2>/dev/null) <(tail -n +2 ${after} 2>/dev/null) || true`,
    { shell: "/bin/bash" }).toString().split("\n");

for (const design of ["conditional", "table", "polymorphic"]) {
  const names = [...new Set([...readdirSync(design), ...readdirSync(`${design}-new`)])].sort();
  let touched = 0, removed = 0, added = 0;
  console.log(design);
  for (const name of names) {
    const lines = diff(`${design}/${name}`, `${design}-new/${name}`);
    const c = lines.filter((s) => s.startsWith("< ")).length;
    const g = lines.filter((s) => s.startsWith("> ")).length;
    if (c + g > 0) touched += 1;
    [removed, added] = [removed + c, added + g];
    console.log(`  ${name.padEnd(18)} removed=${c}  added=${g}`);
  }
  console.log(`  ${"= total".padEnd(18)} files=${touched}  removed=${removed}  added=${added}`);
}
```

```
conditional
  fee.mjs            removed=0  added=3
  = total            files=1  removed=0  added=3
table
  fee.mjs            removed=0  added=1
  = total            files=1  removed=0  added=1
polymorphic
  cold-chain.mjs     removed=0  added=5
  fee.mjs            removed=1  added=3
  types.mjs          removed=0  added=0
  = total            files=2  removed=1  added=8
```

The second measurement counts how many places the type name appears. This number is
how many places need to be found when a type changes.

```sh
grep -c economy conditional/fee.mjs table/fee.mjs polymorphic/fee.mjs
```

```
conditional/fee.mjs:3
table/fee.mjs:1
polymorphic/fee.mjs:1
```

#### What the Numbers Say

For this change, the cheapest design is the table: one file, one line. The conditional
chain added three lines, because the type decision gets made in three separate places.
The source of these three lines is in the second measurement: the name `economy` appears
in three places in the conditional chain, one place in the other two.

The gap grows with the number of operations. When a fourth operation is added for
tariff types (an insurance rate, say), the conditional chain gets a fourth function and
that function re-counts every type; the table gets one field added to every row. With
operation count N and type count T, the conditional chain carries N × T decisions, the
table carries T rows.

Polymorphism is the most expensive of the three for this change: two files, eight added
lines. Five of the eight lines are the new type's own file; three are the addition to
the registry. In contrast, `types.mjs` was never opened: the definitions of the existing
types went untouched. In the table, too, the existing rows did not change, but the new
row went inside the existing table object.

The real difference is not in the line count, it is in what a type can carry. A table row
is a data record; every row has to fill the same fields in the same way. A type can carry
its own state.

```js
// contract-tariff.mjs — a tariff type that carries its own state: one behavior definition, three instances
import { baseFee } from "./common.mjs";

class ContractTariff {
  constructor(label, days, multiplier, flatFee) {
    Object.assign(this, { label, days, multiplier, flatFee });
  }
  calculate(base) { return base * this.multiplier + this.flatFee; }
}

const CONTRACTS = [
  new ContractTariff("Customer A", 2, 1.1, 0),
  new ContractTariff("Customer B", 3, 0.9, 15),
  new ContractTariff("Customer C", 1, 1.4, 30),
];

const shipment = { weightKg: 4.2, zoneCode: 2 };
for (const c of CONTRACTS) {
  console.log(`${c.label.padEnd(10)} ${c.days} days  ${c.calculate(baseFee(shipment)).toFixed(2)}`);
}
console.log(`tariff count=${CONTRACTS.length}  behavior definitions=1`);
```

```
Customer A 2 days  162.16
Customer B 3 days  147.68
Customer C 1 days  236.39
tariff count=3  behavior definitions=1
```

Three contract tariffs were produced from a single behavior definition. If the contract
count were three hundred instead of three, the line count would not change; the only
thing that would grow is data. The conditional chain would add three branches per
contract, the table one row per contract.

#### The Selection Criterion

The measurements support three rules.

**A conditional chain** fits when both the number of types and the number of operations
are small and fixed; keeping the decision in one place, in plain view, is a gain. The
moment the same distinction is repeated in a second place, its cost starts multiplying.

**A lookup table** is cheapest when the options share the same shape as data records and
selection is nothing more than looking up a key. Its limit is that every row has to fill
the same fields; the moment one option needs one extra piece of information, every row's
shape changes.

**Polymorphism** pays off when each option carries its own state, performs more than one
operation together, or the number of options is set by data. It carries a fixed cost —
two files and eight lines for this change — and that cost is wasted when the options are
plain data.

#### Summary

- The same behavior was written in three structures, and all three produced the same
  results; the comparison was made with behavior held constant.
- Adding a fourth tariff type required one line in one file for the table, three lines in
  one file for the conditional chain, and eight lines in two files for polymorphism.
- The type name appears in three places in the conditional chain and one in the other
  two; with operation count N and type count T, the conditional chain carries N × T
  decisions, the table carries T rows.
- Under polymorphism, the existing types' file was never opened; the cost is a separate
  file for the new type and one line in the registry.
- A table row is a fixed-shape data record; a type carries its own state. Three contract
  tariffs were produced from a single behavior definition, and the line count does not
  grow as the contract count grows.

#### Next Step

Throughout this topic, the decisions were about code's **internal** structure: names,
levels, format, branching, and option structure. What a function tells the outside has
not yet been addressed. The `tariff` function throws an error for an unknown type, while
the common response to the same problem is to return null. A signature that returns null
tells the caller nothing, and the error shows up not where the gap was produced but where
it was used. The next topic opens by measuring that distance: in a call chain that passes
a null value along, how many calls later does the error become visible.

## Interface and Data Decisions

Designing what a function tells the outside world: making the null value visible in the contract, splitting a boolean flag parameter into two separate functions, the principle of least visibility, organizing code by reason to change into actors, and doing the same job the same way throughout a project.

### Do Not Pass Null

Source: https://academia.sh/en/courses/clean-code/dont-pass-null

Measuring how visible a null value is in a contract: how many calls after a null-returning chain an error surfaces, how that same distance drops to zero in a version that shows the missing case in the result type, and replacing signatures that pass null as an argument with an empty collection.

The decisions so far concerned the code's internal structure: names, levels of
abstraction, format, branching, and the shape of options. What a function tells the
outside world has not been addressed yet. The `tariff` function in the previous lesson
threw an error for an unknown type; the common solution to the same problem is to return
a null value.

A signature that returns a null value tells the caller nothing. Someone looking at the
`findCarrier(zoneCode)` signature assumes the result is always a carrier; they learn it
can come back null only by reading the implementation. When the assumption turns out to
be wrong, the error surfaces not where the null value was produced but where it was used.
That distance can be measured.

#### The Null Value's Journey

The routing module selects a carrier for a shipment, determines the transfer count, and
calculates the delivery date. Each function writes its name to a trace when called; that
trace will be used for measurement.

```sh
mkdir -p absent contract
```

```js
// absent/route.mjs — a null value is returned when no carrier is found
export const TRACE = [];
const trace = (name) => TRACE.push(name);

const CARRIERS = { 1: { name: "local", deliveryDays: 1 }, 2: { name: "zonal", deliveryDays: 3 },
  3: { name: "remote", deliveryDays: 5 } };

export function findCarrier(zoneCode) {
  trace("findCarrier");
  return CARRIERS[zoneCode] ?? null;
}

export function selectRoute(shipment) {
  trace("selectRoute");
  return { carrier: findCarrier(shipment.zoneCode), transferCount: shipment.zoneCode > 2 ? 1 : 0 };
}

export function transferDelay(route) {
  trace("transferDelay");
  return route.transferCount * 2;
}

export function carrierDelay(route) {
  trace("carrierDelay");
  return route.carrier.deliveryDays;
}

export function deliveryDate(route, today) {
  trace("deliveryDate");
  return today + transferDelay(route) + carrierDelay(route);
}

export function deliveryPlan(shipment, today) {
  trace("deliveryPlan");
  const route = selectRoute(shipment);
  return { route, date: deliveryDate(route, today) };
}

export function processOrder(shipment, today) {
  trace("processOrder");
  return `${shipment.code}: day ${deliveryPlan(shipment, today).date}`;
}
```

When an undefined zone code arrives, `findCarrier` returns null, `selectRoute` packages
this null value as a field and returns it, `deliveryPlan` takes the route, `deliveryDate`
calls the two delay functions, and the error surfaces in the second of them. The null
value travels by leaving the place it was produced and settling inside a data structure.

#### The Null Value Visible in the Contract

In the second version, exactly one thing changes: the **result type** of the function that
looks up the carrier. It now returns either a carrier or not-found information, and the
two are distinguishable. The caller must check which one it got before using the result.

```js
// contract/route.mjs — the null value appears in the result type
export const TRACE = [];
const trace = (name) => TRACE.push(name);

const CARRIERS = { 1: { name: "local", deliveryDays: 1 }, 2: { name: "zonal", deliveryDays: 3 },
  3: { name: "remote", deliveryDays: 5 } };

// The result takes one of two shapes: { found: true, carrier } or { found: false, zoneCode }.
export function selectCarrier(zoneCode) {
  trace("selectCarrier");
  const carrier = CARRIERS[zoneCode];
  return carrier === undefined ? { found: false, zoneCode } : { found: true, carrier };
}

export function selectRoute(shipment) {
  trace("selectRoute");
  const result = selectCarrier(shipment.zoneCode);
  if (result.found === false) {
    throw new RangeError(`no carrier defined for zone ${result.zoneCode}`);
  }
  return { carrier: result.carrier, transferCount: shipment.zoneCode > 2 ? 1 : 0 };
}

export function transferDelay(route) {
  trace("transferDelay");
  return route.transferCount * 2;
}

export function carrierDelay(route) {
  trace("carrierDelay");
  return route.carrier.deliveryDays;
}

export function deliveryDate(route, today) {
  trace("deliveryDate");
  return today + transferDelay(route) + carrierDelay(route);
}

export function deliveryPlan(shipment, today) {
  trace("deliveryPlan");
  const route = selectRoute(shipment);
  return { route, date: deliveryDate(route, today) };
}

export function processOrder(shipment, today) {
  trace("processOrder");
  return `${shipment.code}: day ${deliveryPlan(shipment, today).date}`;
}
```

The downstream flow — the delay calculations, the delivery date, the plan, and the
summary — is identical in both versions. The only thing that changes is where the null
value is handled.

#### Measurement

The measurer feeds the same invalid shipment to both versions and records three things:
the call trace, the distance between the call that produces the null value and the call
where the error appears, and which of the module's functions appear in the stack trace at
the moment of the error.

```js
// measure.mjs — distance between the call that produces the null value and the call where the error appears
import { TRACE as absentTrace, processOrder as absentProcess } from "./absent/route.mjs";
import { TRACE as contractTrace, processOrder as contractProcess } from "./contract/route.mjs";

const shipment = { code: "GN-4172", zoneCode: 9 };

function measure(title, process, trace, producer) {
  console.log(title);
  try {
    console.log(`  result: ${process(shipment, 12)}`);
  } catch (error) {
    const frames = error.stack.split("\n").slice(1)
      .map((s) => s.match(/at (\w+)/)?.[1]).filter((a) => trace.includes(a));
    console.log(`  call trace: ${trace.join(" -> ")}`);
    console.log(`  call that produces the null value #${trace.indexOf(producer)}, call where the error appears #${trace.length - 1},` +
      ` calls in between = ${trace.length - 1 - trace.indexOf(producer)}`);
    console.log(`  module frames in the stack trace: ${frames.join(", ")}`);
    console.log(`  error: ${error.constructor.name}: ${error.message}`);
  }
}

measure("version that returns a null value", absentProcess, absentTrace, "findCarrier");
measure("version with the null value in the contract", contractProcess, contractTrace, "selectCarrier");
```

```
version that returns a null value
  call trace: processOrder -> deliveryPlan -> selectRoute -> findCarrier -> deliveryDate -> transferDelay -> carrierDelay
  call that produces the null value #3, call where the error appears #6, calls in between = 3
  module frames in the stack trace: carrierDelay, deliveryDate, deliveryPlan, processOrder
  error: TypeError: Cannot read properties of null (reading 'deliveryDays')
version with the null value in the contract
  call trace: processOrder -> deliveryPlan -> selectRoute -> selectCarrier
  call that produces the null value #3, call where the error appears #3, calls in between = 0
  module frames in the stack trace: selectRoute, deliveryPlan, processOrder
  error: RangeError: no carrier defined for zone 9
```

The distance dropped from three to zero. The number represents the path a debugger must
walk: in the null-returning version, the error appears inside `carrierDelay`, and nothing
is wrong there. The stack trace does not help either, because the `findCarrier` function
that produced the null value has already returned; its name is absent from the trace. The
debugger must find a cause that is absent from all four listed frames.

The error messages are also part of the measurement. The first is a type error and says
nothing about the domain; the second names the zone code and the missing thing by name.

#### Passing a Null Value

The reverse direction of the same problem is passing a null value **as an argument**. The
discount calculation is an example: passing null when there is no discount produces two
meanings in the signature — "a discount object" and "no discount".

```js
// absent/discount.mjs — a missing discount or campaign is represented as an absent value
export function calculateFee(baseFee, discount, campaign) {
  const rate = discount === null ? 0 : discount.rate;
  const extra = campaign === null ? 0 : campaign.extraDiscount;
  return baseFee * (1 - rate - extra);
}

export const EXAMPLES = [
  ["no discount", calculateFee(100, null, null)],
  ["contracted", calculateFee(100, { rate: 0.15 }, null)],
  ["two discounts", calculateFee(100, { rate: 0.15 }, { extraDiscount: 0.05 })],
];
```

The natural counterpart of the null value is an **empty collection**. If the number of
discounts can be zero, one, or more, the parameter is a list rather than a single object;
the absence of discounts is an empty list and supports the same operations.

```js
// contract/discount.mjs — an empty array carries the absent case, the signature accepts no such value
export function calculateFee(baseFee, discounts) {
  const total = discounts.reduce((t, d) => t + d.rate, 0);
  return baseFee * (1 - total);
}

export const EXAMPLES = [
  ["no discount", calculateFee(100, [])],
  ["contracted", calculateFee(100, [{ rate: 0.15 }])],
  ["two discounts", calculateFee(100, [{ rate: 0.15 }, { rate: 0.05 }])],
];
```

```js
// discount-measure.mjs — the two signatures produce identical results, and the null value count
import { EXAMPLES as absentExamples } from "./absent/discount.mjs";
import { EXAMPLES as contractExamples } from "./contract/discount.mjs";

for (const [i, [label, value]] of absentExamples.entries()) {
  const other = contractExamples[i][1];
  console.log(`${label.padEnd(14)} null=${value.toFixed(2)}  contract=${other.toFixed(2)}` +
    `  ${value.toFixed(2) === other.toFixed(2) ? "same" : "DIFFERENT"}`);
}
```

```
no discount    null=100.00  contract=100.00  same
contracted     null=85.00  contract=85.00  same
two discounts  null=80.00  contract=80.00  same
```

```sh
for d in absent contract; do echo "$d/discount.mjs: $(grep -o null $d/discount.mjs | wc -l | tr -d ' ') null value(s)"; done
```

```
absent/discount.mjs: 5 null value(s)
contract/discount.mjs: 0 null value(s)
```

The same three results are produced with zero null values instead of five. The gain is not
only in the count: the empty-collection signature stays unchanged when a new kind of
discount is added, while the null-based signature demands one more parameter and one more
check for every new discount.

#### Ways to Make the Null Value Visible

The measurement does not say which technique to choose; all three options bring the
distance to zero, and their differences lie elsewhere.

**Fail fast.** If the null value is a symptom of a programming error, it is thrown where
it is produced. `selectRoute` does this: an undefined zone code is a data defect, and
continuing the calculation makes no sense. This option leaves no decision to the caller.

**Empty collection.** If the null value means "none at all" and the operation is still
meaningful with zero elements, an empty list, an empty map, or zero is used. The discount
example is this case; the caller writes no check, because the absence is already a
supported case.

**Explicit result type.** If the null value is an expected outcome and the caller should
decide what to do, the result takes one of two shapes, and which one it is can be read.
`selectCarrier` does this: one caller can choose to throw, another can choose to fall back
to a default carrier.

What the three share is that the null value becomes **part of the signature**. Returning a
null value, by contrast, hides it from the signature; no one who skips the implementation
knows it exists.

#### Summary

- A signature that returns a null value hides it; the caller cannot verify their
  assumption without reading the implementation.
- In the measurement, the error appeared three calls after the null value was produced;
  the error surfaced inside `carrierDelay`, and the function that produced the null value
  did not appear in the stack trace.
- In the version that shows the null value in the result type, the distance dropped to
  zero, and the error message turned from a type error into a domain error that names the
  zone code.
- When a null value is passed as an argument, every parameter demands a check; the
  signature with the empty collection produced the same three results with zero null
  values instead of five.
- The three ways to make the null value visible are fail fast, empty collection, and
  explicit result type; all three make it part of the signature.

#### Next Step

The `calculateFee` signature is now free of null values, but a similar problem returns in
a different shape. The fee library also calculates return shipments, and the two
calculations are largely the same; adding a boolean value to the signature to carry the
difference seems reasonable. Such a parameter says not a single word at the call site and
opens a branch inside the function. The next lesson measures its cost through testing: how
many cases are needed to test the single function with a flag against the two separate
functions.

### Boolean Flag Parameters

Source: https://academia.sh/en/courses/clean-code/boolean-flag-parameters

Measuring the cost of parameters that carry a boolean value through testing: comparing, with node --test, the number of tests needed to cover the branches of a single function with two flags against two split functions, the number of branches the flag opens in the body, and the opaque arguments at the call site.

The previous lesson removed null values from the signature. A similar problem returns in
a different shape. The fee library also calculates return shipments, and the two
calculations are largely the same: the same tier selection, the same weight, a different
multiplier, and a different minimum fee. Adding a boolean value to the signature to carry
the difference seems reasonable.

A boolean flag parameter declares in the signature that the function does **two different
jobs**. Its cost shows up in two places: the branches it opens in the body, and the
argument at the call site that says nothing. Both can be counted.

#### The Two-Flag Version

The function takes two flags: whether the shipment is a return, and whether it is
insured.

```sh
mkdir -p flagged split
```

```js
// flagged/fee.mjs — the return and insurance decisions are carried by two flags
export function calculateFee(shipment, tariff, isReturn, insured) {
  const tier = tariff.weightTiers.find((t) => shipment.weightKg <= t.maxKg);
  const base = tier.ratePerKg * shipment.weightKg;
  const fee = isReturn ? base * 0.6 : base;
  const insurance = insured && isReturn === false ? shipment.declaredValue * 0.01 : 0;
  const minimum = isReturn ? tariff.minimumReturnFee : tariff.minimumFee;
  return Math.max(fee + insurance, minimum);
}
```

The flag appears once in the signature and shows up three more times in the body: in the
fee multiplier, in suppressing the insurance, and in choosing the minimum fee.

```sh
tail -n +2 flagged/fee.mjs | grep -c isReturn
```

```
4
```

The number shows that the flag is not a single switch. `isReturn` is not an on-off
toggle; it is a distinction that splits the function's body in two from end to end. The
second flag depends on the first: insurance applies only to non-return shipments, so the
`isReturn === false` condition also enters the insurance check.

#### The Split Version

In the second version, both flags disappear. The `isReturn` flag is split into two
separate functions; the `insured` flag was already repeating information present in the
data — a shipment with a declared value of zero also has a zero insurance fee.

```js
// split/fee.mjs — two separate operations, two separate functions
function baseFee(shipment, tariff) {
  const tier = tariff.weightTiers.find((t) => shipment.weightKg <= t.maxKg);
  return tier.ratePerKg * shipment.weightKg;
}

export function shipmentFee(shipment, tariff) {
  const insurance = shipment.declaredValue * 0.01;
  return Math.max(baseFee(shipment, tariff) + insurance, tariff.minimumFee);
}

export function returnFee(shipment, tariff) {
  return Math.max(baseFee(shipment, tariff) * 0.6, tariff.minimumReturnFee);
}
```

The rule "no insurance applies to a return shipment" is no longer a condition; it is the
`returnFee` function itself. Breaking the rule requires changing the function; in the
flagged version, passing the wrong combination at a single call site was enough.

#### Measuring Through Testing

Both versions are tested with the same tariff and the same two shipments. The criterion
is fixed: every branch is covered by at least one test. In the flagged version, the two
flags have four combinations, and each combination must be tested separately for the case
where the minimum fee applies and the case where it does not.

```js
// tariff.mjs — the tariff and shipments shared by both test suites
export const TARIFF = {
  weightTiers: [{ maxKg: 1, ratePerKg: 38 }, { maxKg: 5, ratePerKg: 26 },
    { maxKg: 10, ratePerKg: 21 }, { maxKg: 30, ratePerKg: 17 }],
  minimumFee: 52,
  minimumReturnFee: 40,
};
export const LARGE = { weightKg: 4, declaredValue: 2000 };
export const SMALL = { weightKg: 0.5, declaredValue: 0 };
```

```js
// test-flagged.mjs — four combinations of two flags, each with the minimum-fee boundary
import { test } from "node:test";
import assert from "node:assert/strict";
import { calculateFee } from "./flagged/fee.mjs";
import { TARIFF, LARGE, SMALL } from "./tariff.mjs";

test("shipment, uninsured, above minimum", () => {
  assert.equal(calculateFee(LARGE, TARIFF, false, false), 104);
});
test("shipment, uninsured, below minimum", () => {
  assert.equal(calculateFee(SMALL, TARIFF, false, false), 52);
});
test("shipment, insured, above minimum", () => {
  assert.equal(calculateFee(LARGE, TARIFF, false, true), 124);
});
test("shipment, insured, below minimum", () => {
  assert.equal(calculateFee(SMALL, TARIFF, false, true), 52);
});
test("return, uninsured, above minimum", () => {
  assert.equal(calculateFee(LARGE, TARIFF, true, false), 62.4);
});
test("return, uninsured, below minimum", () => {
  assert.equal(calculateFee(SMALL, TARIFF, true, false), 40);
});
test("return, insured, insurance suppressed", () => {
  assert.equal(calculateFee(LARGE, TARIFF, true, true), 62.4);
});
test("return, insured, below minimum", () => {
  assert.equal(calculateFee(SMALL, TARIFF, true, true), 40);
});
```

In the split version, there is no combination; each function has a single boundary.

```js
// test-split.mjs — two functions, each with the minimum-fee boundary
import { test } from "node:test";
import assert from "node:assert/strict";
import { shipmentFee, returnFee } from "./split/fee.mjs";
import { TARIFF, LARGE, SMALL } from "./tariff.mjs";

test("shipment fee, above minimum", () => {
  assert.equal(shipmentFee(LARGE, TARIFF), 124);
});
test("shipment fee, below minimum", () => {
  assert.equal(shipmentFee(SMALL, TARIFF), 52);
});
test("return fee, above minimum", () => {
  assert.equal(returnFee(LARGE, TARIFF), 62.4);
});
test("return fee, below minimum", () => {
  assert.equal(returnFee(SMALL, TARIFF), 40);
});
```

The count is read from the runner's own summary. Because the duration fields change from
run to run, only the counter lines are kept.

```sh
for t in test-flagged.mjs test-split.mjs; do
  echo "== $t"
  node --test "$t" 2>&1 | grep -E '(tests|pass|fail) [0-9]+$'
done
```

```
== test-flagged.mjs
ℹ tests 8
ℹ pass 8
ℹ fail 0
== test-split.mjs
ℹ tests 4
ℹ pass 4
ℹ fail 0
```

Eight tests against four. Both versions produce the same four results (104, 124, 62.4,
and 40, together with the minimum-fee boundaries); the difference comes from the flag
combinations needing to be tested separately.

The second measurement is at the call site. The opaque-argument criterion defined in the
first lesson applies directly to boolean values: a bare `true` on a call line does not say
which parameter it fills.

```sh
for t in test-flagged.mjs test-split.mjs; do
  echo "$t: $(grep -o 'true\|false' $t | wc -l | tr -d ' ') opaque boolean argument(s)"
done
```

```
test-flagged.mjs: 16 opaque boolean argument(s)
test-split.mjs: 0 opaque boolean argument(s)
```

Someone reading the line `calculateFee(LARGE, TARIFF, true, false)` cannot tell whether
the second `false` turns off insurance or some other option; they have to open the
signature. The line `returnFee(LARGE, TARIFF)` leaves no question to ask.

#### When the Number of Flags Grows

Two flags produced four combinations. Adding a third flag raises the combination count to
eight; the number of tests grows at the same rate. In the split design, a third job is a
third function, and the total grows by one instead of two. The flagged signature
multiplies, the split signature adds.

This is the same branching behavior measured in the previous lesson: independent
decisions sitting in the same body multiply. A flag parameter carries this multiplication
**outside** the function; what now produces the combinations is not the conditions in the
body but the values the caller passes in.

#### Where a Flag Is Legitimate

Not every boolean parameter is a defect. The distinguishing question is: does the flag
change **what** the function does, or **how** it does it?

The `isReturn` flag changed what was being done — there were two separate jobs, and both
were squeezed into one signature. By contrast, a "show the cents" flag passed to a
formatting function determines a detail of the same job; the job is single. Such a
parameter is still opaque at the call site, and the opacity disappears once it is passed
as a named field (`{ showCents: true }`).

The second criterion is whether the flag overlaps with the data. The `insured` flag was an
example of this: the declared value already said whether insurance would apply. Keeping
the same information in two places lets the two drift apart — a shipment marked insured
but with a declared value of zero is inconsistent, and the flagged signature accepts that
inconsistency.

#### Summary

- A boolean flag parameter declares that a function does two jobs; its cost shows up in
  the branches in the body and the opaque argument at the call site.
- The `isReturn` flag appeared once in the signature and in four lines of the file: the
  fee multiplier, the suppression of insurance, and the choice of minimum fee each
  depended on it separately.
- Covering the branches of the two flags required eight tests; testing the same behavior
  as two separate functions was completed with four tests.
- Sixteen opaque boolean arguments at the call sites dropped to zero; the split signatures
  leave no question on the call line.
- As the number of flags grows, combinations multiply while separate functions add; a
  flag is defensible only when it determines how a job is done rather than what it is,
  and does not overlap with the data.

#### Next Step

Signature decisions determine what a function tells the outside world. The same question
can be asked at the module level: how many names does a module expose? In the
`split/fee.mjs` file, `baseFee` was not exported; the two fee functions were. This
decision is not arbitrary; every exposed name is an entry added to that module's
immutable surface. The next lesson measures this surface: how many of a module's exposed
names are actually used, and how much each exposed name narrows the module's freedom to
change.

### Scope and Visibility

Source: https://academia.sh/en/courses/clean-code/scope-and-visibility

Measuring the principle of least visibility with an import graph: comparing the public surface, the number of dependent files, the unused export surface, and the number of freely changeable names between two versions of the same fee package — one that exports every name and one that exports only two.

Signature decisions determine what a function tells the outside world. The same question
is asked at the module level: how many names does a module expose to the outside? In the
previous lesson's `split/fee.mjs` file, `baseFee` was not exported; the two fee functions
were. This decision is not arbitrary.

**Scope** is the zone of code where a name is visible. Every name defined in a module
file is visible only in that file by default; the `export` keyword carries that name
outside the file. Every exposed name is a promise the module cannot take back: the moment
another file uses that name, the name is no longer the module's own property. This lesson
counts the cost of that promise.

#### The Package That Exposes Every Name

The fee package consists of four files: the calculation itself and three modules that use
it — report, label, invoice. In the first version, the calculation module exposes every
one of its names.

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

```js
// wide/fee.mjs — every name in the module is exported
export const VOLUMETRIC_DIVISOR = 3000;
export const MINIMUM_FEE = 52;
export const REGION_FACTORS = { 1: 1, 2: 1.35, 3: 1.8 };
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 10, ratePerKg: 21 },
  { maxKg: 30, ratePerKg: 17 }];

export function volumetricWeightKg(shipment) {
  return (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
}

export function billableWeightKg(shipment) {
  return Math.max(shipment.weightKg, volumetricWeightKg(shipment));
}

export function selectTier(weightKg) {
  return WEIGHT_TIERS.find((t) => weightKg <= t.maxKg);
}

export function tierFee(weightKg) {
  return selectTier(weightKg).ratePerKg * weightKg;
}

export function calculateFee(shipment) {
  const weightKg = billableWeightKg(shipment);
  return Math.max(tierFee(weightKg) * REGION_FACTORS[shipment.zoneCode], MINIMUM_FEE);
}
```

Every name that is exposed gets used, because it is available.

```js
// wide/report.mjs — direct references to two internal names and one constant
import { calculateFee, selectTier, WEIGHT_TIERS } from "./fee.mjs";

export function report(shipments) {
  const lines = shipments.map((s) =>
    `${s.code} tier<=${selectTier(s.weightKg).maxKg}kg fee=${calculateFee(s).toFixed(2)}`);
  return lines.concat(`tier count=${WEIGHT_TIERS.length}`);
}
```

```js
// wide/label.mjs — direct reference to the volumetric divisor
import { billableWeightKg, VOLUMETRIC_DIVISOR } from "./fee.mjs";

export function label(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg (divisor ${VOLUMETRIC_DIVISOR})`;
}
```

```js
// wide/invoice.mjs — recalculates the tier fee separately
import { calculateFee, tierFee } from "./fee.mjs";

export function invoice(shipment) {
  const weightKg = Math.max(shipment.weightKg,
    (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / 3000);
  return `${shipment.code} tier=${tierFee(weightKg).toFixed(2)} total=${calculateFee(shipment).toFixed(2)}`;
}
```

`invoice.mjs` stands out: even though the volumetric divisor is exported, the number
`3000` is hardcoded in that file. A wide surface does not guarantee consistency, it only
increases the number of options.

#### The Package That Exposes Two Names

In the second version, the fee module exposes two names: the fee itself and the fee's
breakdown. The intermediate values the consuming modules need are given by name in the
breakdown; the module's constants and intermediate functions do not leave it.

```js
// narrow/fee.mjs — two names exported, the rest stays inside the module
const VOLUMETRIC_DIVISOR = 3000;
const MINIMUM_FEE = 52;
const REGION_FACTORS = { 1: 1, 2: 1.35, 3: 1.8 };
const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 10, ratePerKg: 21 },
  { maxKg: 30, ratePerKg: 17 }];

function volumetricWeightKg(shipment) {
  return (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
}

function billableWeightKg(shipment) {
  return Math.max(shipment.weightKg, volumetricWeightKg(shipment));
}

function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

function tierFee(kg) {
  return selectTier(kg).ratePerKg * kg;
}

export function calculateFee(shipment) {
  const kg = billableWeightKg(shipment);
  return Math.max(tierFee(kg) * REGION_FACTORS[shipment.zoneCode], MINIMUM_FEE);
}

export function feeBreakdown(shipment) {
  const kg = billableWeightKg(shipment);
  return { weightKg: kg, volumetricDivisor: VOLUMETRIC_DIVISOR, tierCount: WEIGHT_TIERS.length,
    tierMaxKg: selectTier(kg).maxKg, tierFee: tierFee(kg) };
}
```

```js
// narrow/report.mjs — only through the breakdown and the fee
import { calculateFee, feeBreakdown } from "./fee.mjs";

export function report(shipments) {
  const lines = shipments.map((s) =>
    `${s.code} tier<=${feeBreakdown(s).tierMaxKg}kg fee=${calculateFee(s).toFixed(2)}`);
  return lines.concat(`tier count=${feeBreakdown(shipments[0]).tierCount}`);
}
```

```js
// narrow/label.mjs — the divisor value comes from the breakdown
import { feeBreakdown } from "./fee.mjs";

export function label(shipment) {
  const breakdown = feeBreakdown(shipment);
  return `${shipment.code} ${breakdown.weightKg.toFixed(2)}kg (divisor ${breakdown.volumetricDivisor})`;
}
```

```js
// narrow/invoice.mjs — the tier fee comes from the breakdown
import { calculateFee, feeBreakdown } from "./fee.mjs";

export function invoice(shipment) {
  return `${shipment.code} tier=${feeBreakdown(shipment).tierFee.toFixed(2)}` +
    ` total=${calculateFee(shipment).toFixed(2)}`;
}
```

It is verified that the two packages produce the same output.

```js
// compare.mjs — verifies the two packages produce the same output
import { report as wideReport } from "./wide/report.mjs";
import { label as wideLabel } from "./wide/label.mjs";
import { invoice as wideInvoice } from "./wide/invoice.mjs";
import { report as narrowReport } from "./narrow/report.mjs";
import { label as narrowLabel } from "./narrow/label.mjs";
import { invoice as narrowInvoice } from "./narrow/invoice.mjs";

const SHIPMENTS = [
  { code: "GN-4172", weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18, zoneCode: 2 },
  { code: "GN-4173", weightKg: 0.4, widthCm: 10, lengthCm: 10, heightCm: 10, zoneCode: 1 },
];

const wide = [...wideReport(SHIPMENTS), ...SHIPMENTS.map(wideLabel), ...SHIPMENTS.map(wideInvoice)];
const narrow = [...narrowReport(SHIPMENTS), ...SHIPMENTS.map(narrowLabel), ...SHIPMENTS.map(narrowInvoice)];

for (const [i, line] of wide.entries()) {
  console.log(`${line === narrow[i] ? "same     " : "DIFFERENT"} ${line}`);
}
```

```
same      GN-4172 tier<=5kg fee=151.63
same      GN-4173 tier<=1kg fee=52.00
same      tier count=4
same      GN-4172 4.32kg (divisor 3000)
same      GN-4173 0.40kg (divisor 3000)
same      GN-4172 tier=112.32 total=151.63
same      GN-4173 tier=15.20 total=52.00
```

#### The Surface Measurer

The measurer reads the modules in a directory, extracts the names each module exposes and
keeps internal, then counts from the import lines which name is used by which files. The
last column is the number of names the module can change on its own: every name with no
external dependent counts toward it.

```js
// surface-measure.mjs — number of names each module exposes, their dependents, and freedom to change
import { readFileSync, readdirSync } from "node:fs";

const exportedNames = (m) => [...m.matchAll(/^export\s+(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]);
const internalNames = (m) => [...m.matchAll(/^(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]);
const imports = (m) => [...m.matchAll(/import\s*{([^}]*)}\s*from\s*"\.\/([\w.-]+)"/g)]
  .map((e) => [e[2], e[1].split(",").map((s) => s.trim()).filter(Boolean)]);

for (const dir of process.argv.slice(2)) {
  const files = readdirSync(dir).filter((a) => a.endsWith(".mjs")).sort();
  const text = Object.fromEntries(files.map((a) => [a, readFileSync(`${dir}/${a}`, "utf8")]));
  const usage = new Map();
  for (const m of Object.values(text)) {
    for (const [target, names] of imports(m)) {
      for (const name of names) usage.set(`${target}:${name}`, (usage.get(`${target}:${name}`) ?? 0) + 1);
    }
  }
  console.log(dir);
  for (const file of files) {
    const exported = exportedNames(text[file]);
    const internal = internalNames(text[file]);
    const dependents = exported.map((name) => [name, usage.get(`${file}:${name}`) ?? 0]);
    const bound = dependents.filter(([, n]) => n > 0);
    console.log(`  ${file.padEnd(12)} exported=${exported.length}  internal=${internal.length}` +
      `  has dependents=${bound.length}  unused export surface=${exported.length - bound.length}` +
      `  freely changeable=${exported.length + internal.length - bound.length}`);
    for (const [name, n] of bound) console.log(`    ${name.padEnd(24)} dependent files=${n}`);
  }
}
```

```sh
node surface-measure.mjs wide narrow
```

```
wide
  fee.mjs      exported=9  internal=0  has dependents=6  unused export surface=3  freely changeable=3
    VOLUMETRIC_DIVISOR       dependent files=1
    WEIGHT_TIERS             dependent files=1
    billableWeightKg         dependent files=1
    selectTier               dependent files=1
    tierFee                  dependent files=1
    calculateFee             dependent files=2
  invoice.mjs  exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
  label.mjs    exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
  report.mjs   exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
narrow
  fee.mjs      exported=2  internal=8  has dependents=2  unused export surface=0  freely changeable=8
    calculateFee             dependent files=2
    feeBreakdown             dependent files=3
  invoice.mjs  exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
  label.mjs    exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
  report.mjs   exported=1  internal=0  has dependents=0  unused export surface=1  freely changeable=1
```

The calculation module's surface dropped from nine to two. More important than that is
the last column: in the wide version, the module can change three of its own names
without asking anyone; in the narrow version, eight. The difference is that six names are
bound from the outside.

The three consuming modules showing `unused export surface=1` is a limitation of the measurement:
these are the package's outer face, and nothing inside the package itself uses them. The
measure only sees the bindings within the given directory.

#### The Cost of Renaming

Freedom to change is not an abstract concept. The cost of renaming a name is the number
of files it appears in.

```sh
for d in wide narrow; do
  echo "$d: selectTier $(grep -rl selectTier $d | wc -l | tr -d ' ') file(s)," \
    "VOLUMETRIC_DIVISOR $(grep -rl VOLUMETRIC_DIVISOR $d | wc -l | tr -d ' ') file(s)"
done
```

```
wide: selectTier 2 file(s), VOLUMETRIC_DIVISOR 2 file(s)
narrow: selectTier 1 file(s), VOLUMETRIC_DIVISOR 1 file(s)
```

The difference looks like two files against one, because the package is small. The real
meaning of the measure is in the ratio: in the narrow version, these two names are
**inside the module**, and the count stays at one no matter how many outside files there
are. In the wide version, the count grows together with the number of files that import
that name.

Converting the tier selection to a `Map`-based structure, moving the volumetric divisor to
a field that varies by carrier, reading the minimum fee from the tariff — all of these are
the module's internal business in the narrow version. In the wide version, each one also
requires changing the outside files.

#### The Principle of Least Visibility

The principle is one sentence: **a name is defined in the narrowest scope that can do its
job.** Applying it takes three steps.

**The default is closed.** A new name first stays inside the file. Exporting is done once
a consumer actually appears and that consumer has no other way.

**Widening is easy, narrowing is hard.** Opening a closed name is a one-line change and
breaks no file. Closing an open name requires changing every file that uses it; the
`dependent files` column in the measurement is exactly this cost.

**The surface carries decisions, not intermediate values.** `feeBreakdown` was an example
of this: what the consuming modules needed was not the intermediate functions but the
results of the calculation. Giving the result that a constant determines, instead of the
constant itself, preserves that constant's freedom to change.

The same principle works inside a function too. When a variable is defined outside the
block where it is used, every line outside that block becomes able to change it; someone
reading the code also has to follow every line in between to understand what the value is.

#### Summary

- Every exposed name is an expensive promise to take back; its measure is the number of
  files that import it.
- In two packages that produce the same behavior, the calculation module's surface was
  nine names and two; the unused export surface dropped from three to zero.
- The number of names the module can change without asking anyone rose from three to
  eight; the difference is six names bound from the outside.
- Exposing decisions rather than intermediate values preserves the constants' freedom to
  change; `feeBreakdown` gives results, not constants.
- Widening is a one-line change, narrowing requires changing every dependent file; that is
  why the default is closed.

#### Next Step

Visibility decisions determine what a module gives to the outside. One question remains:
what criterion split these modules apart? The fee calculation sits in one file, the report
in another; the split looks functional. But what actually strains a codebase is different
people touching the same file for different reasons. If a rounding change that accounting
wants and a tier change that operations wants meet in the same file, the two decisions
wait on each other. The next lesson measures this meeting point: how many times two
different reasons for change touch the same file, and what that number becomes when code
is organized by actor.

### Organizing Code by Actor

Source: https://academia.sh/en/courses/clean-code/organizing-code-by-actor

Measuring the grouping of code by reason to change instead of by technical layer: how many actors' decisions are defined in a file, how many files each actor spans, and how many files a single accounting change touches, compared across two layouts of the same fee package.

Visibility decisions determine what a module gives to the outside. One question remains:
what criterion split these modules apart? The fee calculation sits in one file,
formatting in another; the split looks technical and reasonable.

What actually strains a codebase is different people touching the same file for
different reasons. If a rounding change that accounting wants and a tier change that
operations wants meet in the same file, the two decisions wait on each other; one breaks
the other's tests. This lesson counts how many separate **actors** a file serves.

#### What Is an Actor

An actor is the source of a change request: a role, a unit, a stakeholder. The fee
library has three.

- **Operations** decides how the shipping is done: weight tiers, the volumetric divisor,
  transfer count, delivery time.
- **Accounting** decides how the money is counted: the tax rate, the rounding rule, the
  format of the invoice line.
- **Customer communication** decides what is told to the customer: the label text, the
  delivery message.

The three change independently of each other. When the tax rate changes, there is no
reason for the weight tiers to change. The criterion follows from this: a file should
change at the request of a single actor.

#### Layout by Layer

The first layout separates files by technical function: calculation, flow, format. This
split looks similar to the layer responsibilities established in the The Data Access
Layer and Business Logic course, but there the split was determined by dependency
direction; here it is only the kind of job.

```sh
mkdir -p layer actor
```

```js
// layer/calc.mjs — operations' tier rules and accounting's tax rules together
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];
export const VAT_RATE = 0.2;

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function round(amount) {
  return Math.round(amount * 100) / 100;
}

export function vat(net) {
  return round(net * VAT_RATE);
}
```

```js
// layer/flow.mjs — operations' transfer rule and the customer's delivery message together
export function transferCount(shipment) {
  return shipment.zoneCode > 2 ? 2 : 1;
}

export function deliveryDays(shipment) {
  return 1 + transferCount(shipment);
}

export function deliveryMessage(shipment) {
  return `${shipment.code} your shipment will be delivered in ${deliveryDays(shipment)} day(s)`;
}
```

```js
// layer/format.mjs — accounting's invoice format and the customer's label text together
import { netFee, vat, round, billableWeightKg } from "./calc.mjs";
import { transferCount } from "./flow.mjs";

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(2)} vat=${tax.toFixed(2)} total=${round(net + tax).toFixed(2)}`;
}

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}
```

Each file is internally consistent. Yet `calc.mjs` carries both the tiers and the tax,
`format.mjs` carries both the invoice and the label, and `flow.mjs` carries both the
transfer and the customer message.

#### Layout by Actor

The second layout groups the same functions by their actor.

```js
// actor/operations.mjs — tier, volume, and transfer decisions
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function transferCount(shipment) {
  return shipment.zoneCode > 2 ? 2 : 1;
}

export function deliveryDays(shipment) {
  return 1 + transferCount(shipment);
}
```

```js
// actor/accounting.mjs — tax, rounding, and the invoice format
import { netFee } from "./operations.mjs";

export const VAT_RATE = 0.2;

export function round(amount) {
  return Math.round(amount * 100) / 100;
}

export function vat(net) {
  return round(net * VAT_RATE);
}

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(2)} vat=${tax.toFixed(2)} total=${round(net + tax).toFixed(2)}`;
}
```

```js
// actor/customer.mjs — text visible to the customer
import { billableWeightKg, transferCount, deliveryDays } from "./operations.mjs";

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}

export function deliveryMessage(shipment) {
  return `${shipment.code} your shipment will be delivered in ${deliveryDays(shipment)} day(s)`;
}
```

```js
// compare.mjs — verifies the two layouts produce the same output
import { invoiceLine as lInvoice, labelText as lLabel } from "./layer/format.mjs";
import { deliveryMessage as lMessage } from "./layer/flow.mjs";
import { invoiceLine as aInvoice } from "./actor/accounting.mjs";
import { labelText as aLabel, deliveryMessage as aMessage } from "./actor/customer.mjs";

const shipment = { code: "GN-4172", weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18, zoneCode: 3 };
for (const [layer, actor] of [[lInvoice, aInvoice], [lLabel, aLabel], [lMessage, aMessage]]) {
  const a = layer(shipment), b = actor(shipment);
  console.log(`${a === b ? "same     " : "DIFFERENT"} ${a}`);
}
```

```
same      GN-4172 net=112.32 vat=22.46 total=134.78
same      GN-4172 4.32kg 2 transfer(s)
same      GN-4172 your shipment will be delivered in 3 day(s)
```

#### Measurement: How Many Actors in a File

The measurer takes each actor's vocabulary and checks which actors' decisions are
**defined** in each file. An imported name is not that file's decision; only definitions
are counted.

```js
// actor-measure.mjs — which actor's decisions are defined in each file, how many files each actor spans
import { readFileSync, readdirSync } from "node:fs";

const ACTOR = {
  operations: ["VOLUMETRIC_DIVISOR", "WEIGHT_TIERS", "billableWeightKg", "selectTier",
    "netFee", "transferCount", "deliveryDays"],
  accounting: ["VAT_RATE", "round", "vat", "invoiceLine"],
  customer: ["labelText", "deliveryMessage"],
};

// Only definitions are counted; a name imported from another file is not that file's decision.
const definitions = (m) => [...m.matchAll(/^export\s+(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]);

for (const dir of process.argv.slice(2)) {
  const files = readdirSync(dir).filter((a) => a.endsWith(".mjs")).sort();
  const spread = Object.fromEntries(Object.keys(ACTOR).map((a) => [a, 0]));
  let overlapping = 0;
  console.log(dir);
  for (const file of files) {
    const names = new Set(definitions(readFileSync(`${dir}/${file}`, "utf8")));
    const owners = Object.entries(ACTOR)
      .filter(([, words]) => words.some((w) => names.has(w))).map(([a]) => a);
    for (const a of owners) spread[a] += 1;
    if (owners.length > 1) overlapping += 1;
    console.log(`  ${file.padEnd(14)} actors=${owners.length}  [${owners.join(", ")}]`);
  }
  const tally = Object.entries(spread).map(([a, n]) => `${a}=${n}`).join("  ");
  console.log(`  files serving multiple actors=${overlapping}   files per actor: ${tally}`);
}
```

```sh
node actor-measure.mjs layer actor
```

```
layer
  calc.mjs       actors=2  [operations, accounting]
  flow.mjs       actors=2  [operations, customer]
  format.mjs     actors=2  [accounting, customer]
  files serving multiple actors=3   files per actor: operations=2  accounting=2  customer=2
actor
  accounting.mjs actors=1  [accounting]
  customer.mjs   actors=1  [customer]
  operations.mjs actors=1  [operations]
  files serving multiple actors=0   files per actor: operations=1  accounting=1  customer=1
```

In the layer layout, all three files serve multiple actors; each one can change at the
request of two separate actors. In the actor layout, no file serves multiple actors. The
second number says the same thing in reverse: in the layer layout, each actor's decisions
are spread across two files; in the actor layout, they are gathered into one.

#### A Real Change

The claim of the numbers is tested with a request. Accounting changes two things: the
value-added tax rate drops from twenty percent to ten percent, and amounts are rounded to
whole currency units instead of cents. The second decision also affects the format of the
invoice line, because the cents digit is now meaningless.

```sh
cp -r layer layer-new && cp -r actor actor-new
```

```js
// layer-new/calc.mjs — the tax rate and rounding rule changed
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];
export const VAT_RATE = 0.1;

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function round(amount) {
  return Math.round(amount);
}

export function vat(net) {
  return round(net * VAT_RATE);
}
```

```js
// layer-new/format.mjs — the cents digit dropped from the invoice line
import { netFee, vat, round, billableWeightKg } from "./calc.mjs";
import { transferCount } from "./flow.mjs";

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(0)} vat=${tax.toFixed(0)} total=${round(net + tax).toFixed(0)}`;
}

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}
```

```js
// actor-new/accounting.mjs — the same two decisions, in one file
import { netFee } from "./operations.mjs";

export const VAT_RATE = 0.1;

export function round(amount) {
  return Math.round(amount);
}

export function vat(net) {
  return round(net * VAT_RATE);
}

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(0)} vat=${tax.toFixed(0)} total=${round(net + tax).toFixed(0)}`;
}
```

```sh
for d in layer actor; do
  n=0
  for f in "$d"/*.mjs; do
    diff -q <(tail -n +2 "$f") <(tail -n +2 "$d-new/$(basename "$f")") > /dev/null || n=$((n + 1))
  done
  echo "$d layout: the accounting change touched $n file(s)"
done
```

```
layer layout: the accounting change touched 2 file(s)
actor layout: the accounting change touched 1 file(s)
```

In the layer layout, `format.mjs` also changed, and that file also holds `labelText`,
which belongs to customer communication. A change opened at accounting's request ended up
touching customer communication's file. In the actor layout, `customer.mjs` was never
opened.

The weight in the label text staying at two decimal places also shows this: the cents
decision belonged to money, not to weight. Because the two formats sit in the same file
in the layer layout, what makes the distinction is not the file but the attention of the
person making the change.

#### The Limit of the Split

Grouping by actor does not replace technical splits; it stands at a right angle to them.
An application has both a layer split (which determines dependency direction) and an
actor split (which determines reason to change). When the two conflict, the choice is
made by which split changes more often: if tax changes three times a year and the data
source changes once a year, the top-level split goes by actor.

The second limit is that actors also change over time. If accounting and pricing are the
same unit today, `accounting.mjs` carries both; the day they split, the file splits too.
The measurement shows this early: when a file's actor count rises to two, it is time to
split.

The third limit is that the measure depends on the vocabulary. The actor vocabulary is
written by hand, and if it is written wrong, the measurement comes out wrong too. What
keeps the vocabulary correct is read not from the code but from where the change requests
come from.

#### Summary

- An actor is the source of a change request; the criterion is that a file changes at the
  request of a single actor.
- In the layer layout, all three files carried two actors' decisions; in the actor
  layout, the number of files serving multiple actors is zero.
- The number of files per actor dropped from two to one; each actor's decisions were
  gathered into a single place.
- The tax-rate and rounding change touched two files in the layer layout and one in the
  actor layout; the second file touched in the layer layout also carried customer
  communication's text.
- An actor split does not replace a layer split; when the two conflict, the more
  frequently changing split stays on top, and when a file's actor count rises to two, it
  is time to split.

#### Next Step

In the actor layout, `invoiceLine` moved into a single file, but the library still has
places where the same job is done in more than one form: in one place an amount is
written with `toFixed`, in another it is formatted by hand; in one place a not-found case
throws an error, in another it returns an empty result. None of this is wrong, only
different. That difference also has a cost, and it can be counted. The next lesson writes
a scan that finds how many separate forms the same job takes, and repeats the measurement
after unification.

### Consistency

Source: https://academia.sh/en/courses/clean-code/consistency

Measuring whether the same job is done the same way throughout a project: a scan that finds the number of forms in a package that does three separate jobs in three separate ways, repeating the measurement after unification, and counting the changes that merging forms causes in outputs and error behavior.

In the actor layout, every decision got an owner. Something still nags in the library: in
one file, an amount is written with `toFixed`, in another it is rounded by hand with the
currency prepended; in one place a not-found case is reported by throwing an error, in
another by returning a null value.

None of this is wrong. All three work, all three pass their tests. They are merely
**different**, and the cost of that difference accumulates on the reader: the answer to
the same question is searched for anew in every file. This lesson ties the difference to
a number — how many separate forms the same job takes.

#### The Criterion: Formats per Job

A codebase has jobs that repeat: writing an amount of money, reporting a not-found case,
exposing a module. For each job, the number of distinct writing forms used in that
codebase is the measure of consistency. The target is always one; which form it is comes
second.

The number corresponds directly to a cost. If a job is done in three forms, someone who
needs that job in a new place sees three examples and has to decide which one to follow.
When making that decision, they usually take whichever file they last looked at as the
example; the form count does not shrink, the distribution just becomes random.

#### Three Jobs, Three Separate Forms

The three modules in the package do three separate jobs: the fee line, the label line,
the daily total. All three write an amount, all three report a not-found case, all three
expose something.

```sh
mkdir -p mixed unified
```

```js
// mixed/fee.mjs — fee line
export function feeLine(shipment, tariff) {
  const tier = tariff.tiers.find((t) => shipment.weightKg <= t.maxKg);
  if (tier === undefined) throw new RangeError("tier not found");
  return `${shipment.code} ${(tier.ratePerKg * shipment.weightKg).toFixed(2)} TL`;
}
```

```js
// mixed/label.mjs — label line
export const labelLine = (shipment, zoneNames) => {
  const zone = zoneNames[shipment.zoneCode];
  if (zone === undefined) return null;
  return `${shipment.code} ${zone} ${shipment.declaredValue.toFixed(2)}TL`;
};
```

```js
// mixed/report.mjs — daily total
function dailyTotal(shipments, day) {
  const dayShipments = shipments.filter((s) => s.day === day);
  if (dayShipments.length === 0) return { error: "no shipment for that day" };
  const total = Math.round(dayShipments.reduce((t, s) => t + s.fee, 0) * 100) / 100;
  return `day ${day} TL ${total}`;
}

export { dailyTotal };
```

The three files look as if they were written independently of each other, and that may
well be the case: each one was written at a different time, by a different person,
following that day's habit. The result is three separate writing conventions inside the
same package.

#### The Unified Version

In the second version, a single form was chosen for each job: the amount is written
with `toFixed(2)` and the currency is appended with a space, a not-found case is reported
by throwing a domain error, and the export is done at the top of the declaration.

```js
// unified/fee.mjs — fee line
export function feeLine(shipment, tariff) {
  const tier = tariff.tiers.find((t) => shipment.weightKg <= t.maxKg);
  if (tier === undefined) throw new RangeError("tier not found");
  return `${shipment.code} ${(tier.ratePerKg * shipment.weightKg).toFixed(2)} TL`;
}
```

```js
// unified/label.mjs — label line
export function labelLine(shipment, zoneNames) {
  const zone = zoneNames[shipment.zoneCode];
  if (zone === undefined) throw new RangeError("zone not found");
  return `${shipment.code} ${zone} ${shipment.declaredValue.toFixed(2)} TL`;
}
```

```js
// unified/report.mjs — daily total
export function dailyTotal(shipments, day) {
  const dayShipments = shipments.filter((s) => s.day === day);
  if (dayShipments.length === 0) throw new RangeError("no shipment for that day");
  return `day ${day} ${dayShipments.reduce((t, s) => t + s.fee, 0).toFixed(2)} TL`;
}
```

#### The Form Scanner

The scanner carries the patterns of the forms to recognize for each job, finds which
forms appear in the files of a directory, and counts how many separate forms are used
per job.

```js
// form-scan.mjs — counts how many separate forms the same job takes
import { readFileSync, readdirSync } from "node:fs";

const JOBS = {
  "money format": [["toFixed + space + TL", /toFixed\(2\)\} TL/], ["toFixed + adjoining TL", /toFixed\(2\)\}TL/],
    ["Math.round + TL prefix", /Math\.round\(/]],
  "not-found handling": [["throwing an error", /throw new \w*Error/], ["null value", /return null/],
    ["error object", /return \{ error:/]],
  "exporting": [["export function", /^export function /m], ["export const arrow", /^export const \w+ = \(/m],
    ["export block at the end", /^export \{/m]],
};

for (const dir of process.argv.slice(2)) {
  const files = readdirSync(dir).filter((a) => a.endsWith(".mjs")).sort();
  const text = files.map((a) => [a, readFileSync(`${dir}/${a}`, "utf8")]);
  console.log(dir);
  let total = 0;
  for (const [job, forms] of Object.entries(JOBS)) {
    const found = forms
      .map(([name, pattern]) => [name, text.filter(([, m]) => pattern.test(m)).map(([f]) => f)])
      .filter(([, files]) => files.length > 0);
    total += found.length;
    console.log(`  ${job.padEnd(20)} forms=${found.length}`);
    for (const [name, f] of found) console.log(`    ${name.padEnd(26)} ${f.join(", ")}`);
  }
  console.log(`  total forms=${total}  (1 per job is the target, target=${Object.keys(JOBS).length})`);
}
```

```sh
node form-scan.mjs mixed unified
```

```
mixed
  money format         forms=3
    toFixed + space + TL       fee.mjs
    toFixed + adjoining TL     label.mjs
    Math.round + TL prefix     report.mjs
  not-found handling   forms=3
    throwing an error          fee.mjs
    null value                 label.mjs
    error object               report.mjs
  exporting            forms=3
    export function            fee.mjs
    export const arrow         label.mjs
    export block at the end    report.mjs
  total forms=9  (1 per job is the target, target=3)
unified
  money format         forms=1
    toFixed + space + TL       fee.mjs, label.mjs, report.mjs
  not-found handling   forms=1
    throwing an error          fee.mjs, label.mjs, report.mjs
  exporting            forms=1
    export function            fee.mjs, label.mjs, report.mjs
  total forms=3  (1 per job is the target, target=3)
```

The total form count dropped from nine to three, and three equals the number of jobs —
meaning every job is done in a single form. The file lists also flipped: before, every
form had a single file; now, the single form has three files.

#### The Cost of Unification

Merging forms is not a free operation. Merging the money format changes the output
text; merging the not-found form changes the error behavior. The changes need to be
counted.

```js
// compare.mjs — the effect of unification on outputs and not-found behavior
import { feeLine as mFee } from "./mixed/fee.mjs";
import { labelLine as mLabel } from "./mixed/label.mjs";
import { dailyTotal as mTotal } from "./mixed/report.mjs";
import { feeLine as uFee } from "./unified/fee.mjs";
import { labelLine as uLabel } from "./unified/label.mjs";
import { dailyTotal as uTotal } from "./unified/report.mjs";

const TARIFF = { tiers: [{ maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }] };
const REGIONS = { 1: "Same City", 2: "Neighboring Zone" };
const SHIPMENT = { code: "GN-4172", weightKg: 2.4, zoneCode: 2, declaredValue: 1500, day: 3, fee: 62.4 };
const LIST = [SHIPMENT, { ...SHIPMENT, code: "GN-4173", fee: 51.2 }];

const attempt = (fn) => {
  try {
    const result = fn();
    return typeof result === "object" && result !== null ? JSON.stringify(result) : String(result);
  } catch (error) {
    return `${error.constructor.name}: ${error.message}`;
  }
};

const CASES = [
  ["fee", () => mFee(SHIPMENT, TARIFF), () => uFee(SHIPMENT, TARIFF)],
  ["label", () => mLabel(SHIPMENT, REGIONS), () => uLabel(SHIPMENT, REGIONS)],
  ["total", () => mTotal(LIST, 3), () => uTotal(LIST, 3)],
  ["label/missing", () => mLabel({ ...SHIPMENT, zoneCode: 9 }, REGIONS),
    () => uLabel({ ...SHIPMENT, zoneCode: 9 }, REGIONS)],
  ["total/missing", () => mTotal(LIST, 7), () => uTotal(LIST, 7)],
];

for (const [label, mixed, unified] of CASES) {
  const a = attempt(mixed), b = attempt(unified);
  console.log(`${label.padEnd(14)} ${a === b ? "same   " : "changed"} ${a.padEnd(34)} -> ${b}`);
}
```

```
fee            same    GN-4172 62.40 TL                   -> GN-4172 62.40 TL
label          changed GN-4172 Neighboring Zone 1500.00TL -> GN-4172 Neighboring Zone 1500.00 TL
total          changed day 3 TL 113.6                     -> day 3 113.60 TL
label/missing  changed null                               -> RangeError: zone not found
total/missing  changed {"error":"no shipment for that day"} -> RangeError: no shipment for that day
```

Four of the five cases changed. Three are presentation decisions: the currency suffix,
the number of decimal places, and the ordering are now the same everywhere. One is a
behavior decision: two functions now throw an error instead of returning a null value or
an error object, and every place that calls them must be updated accordingly.

This is the real cost of consistency work. The measurement mechanizes the task but does
not make the decision; the scanner says how many forms there are, not which one becomes
the standard. The line `day 3 TL 113.6` becoming `day 3 113.60 TL` is a choice, and once
made, it cannot be taken back on its own.

#### Choosing the Standard

Three criteria are useful for deciding which one becomes the standard.

**Prevalence.** The form already most used in the codebase is the one that can be
standardized with the fewest changes. The scanner's file list gives this information
directly.

**Alignment with the outward-facing surface.** If a form is visible to a user or
another system, that expectation comes before internal preference. If the money format
appears in invoice output, what determines the standard is accounting's format.

**Resistance to misuse.** In the not-found example, throwing an error is more resistant
than returning a null value; one of the earlier lessons measured this — a null value
carries the error far from where it was produced.

None of the criteria involve "which looks nicer." Where a consistency discussion gets
stuck is comparing forms aesthetically; the real gain, however, comes from the form
being **single**, not from which one it is. Once the decision is made, the task
mechanizes: the scanner is run on the codebase, and every job with a form count greater
than one is listed.

Something is still missing in the unified version. Three files write the same money
format three times; the form is single, but the writing is repeated in three places.
These are separate problems: one is the same job being done in different forms, the
other is the same information sitting in more than one place.

#### Summary

- The measure of consistency is how many separate forms a job takes in a codebase; the
  target is always one, and which form it is comes second.
- In the three-module package, the money format, the not-found report, and the export
  each had three forms; the total of nine forms dropped to three, matching the number
  of jobs.
- Unification is not free: four of five cases changed; three were presentation
  decisions, one was an error-behavior decision, and it concerns every call site.
- Choosing the standard uses the criteria of prevalence, alignment with the
  outward-facing surface, and resistance to misuse; aesthetic comparison does not end the
  discussion.
- The form being single and that form being written in a single place are separate
  problems; the second one is still there after unification.

#### Next Step

The decisions in this topic were all **local**: what a name says, what a signature
accepts, what a module exposes, whom a file serves, in what form a job gets done. Each
one could be given by looking at a single file and checked by looking at a single file.

As a codebase grows, a cost appears that does not fit this scale. The same information
starts sitting in more than one place, and when one copy is updated, the others fall
behind. Flexibility not needed today gets built in today and becomes a load added onto
every change until the day it turns out not to be needed. Changing one decision spreads
not to a single file but to a set of places whose size is not known in advance. The next
topic addresses these three costs, starting by telling apart the kinds of duplication:
the same information sitting in two places is not the same thing as two pieces of code
that merely happen to look alike.

## Simplicity Principles

Separating knowledge duplication from incidental similarity, evolving a design in small steps, the measured cost of premature generalization, the extract–move–rename refactoring techniques, and securing behavior preservation with tests.

### Do Not Repeat Yourself

Source: https://academia.sh/en/courses/clean-code/dont-repeat-yourself

Separating two pieces of code that merely happen to look alike from information that genuinely lives in one place, counting how many tier boundaries diverge, and measuring the wrong abstraction that merging two call sites into one function produces by its flag count.

The Interface and Data Decisions topic closed with the same task done the same way
throughout the project. What the decisions covered so far had in common was that they
were **local**: a name stating its intent, a signature never accepting a null value, a
function staying at a single level of abstraction. Each one could be checked by looking
at a single file, often a single line.

As a codebase grows, a cost appears that this kind of local check cannot catch. The
same information sits in more than one place, and one day someone updates it while
another spot is forgotten; flexibility not needed today gets written in today and turns
into a branch every future reader pays for; changing one decision starts being measured
by how many files it touches. This topic covers three principles through that cost. The
first is **do not repeat yourself**: the same information should live in exactly one
place.

The principle is often misread as never writing the same lines twice, and that reading
is wrong. Two situations need to be told apart: the same **information** sitting in two
places, and two pieces of code that merely happen to **look alike** right now.

#### Knowledge Duplication

In the shipment pricing and routing library, weight tiers appear in two modules. The
first computes the base fee.

```js
// fee.mjs — base fee by weight tier (cents)
const TIER = [
  { maxGrams: 1000, cents: 4990 },
  { maxGrams: 5000, cents: 7490 },
  { maxGrams: 20000, cents: 12900 },
  { maxGrams: Infinity, cents: 24900 },
];

export function weightFee(grams) {
  return TIER.find((t) => grams <= t.maxGrams).cents;
}
```

The second shows which tier a shipment falls into on the operations screen.

```js
// summary.mjs — tier badge shown on the operations screen
const TIER = [
  { maxGrams: 1000, name: 'small' },
  { maxGrams: 5000, name: 'medium' },
  { maxGrams: 20000, name: 'large' },
  { maxGrams: Infinity, name: 'heavy' },
];

export function tierName(grams) {
  return TIER.find((t) => grams <= t.maxGrams).name;
}
```

The two tables do not look alike: one holds cents, the other holds a name. What
repeats is not code — it is **the information about where the tier boundaries sit**.
Whether that information is genuinely one thing can be measured: compare the gram
values at which each function's output changes.

```js
// boundaries.mjs — compares the tier boundaries of two files
import { weightFee } from './fee.mjs';
import { tierName } from './summary.mjs';

function boundaries(f) {
  const s = [];
  for (let g = 2; g <= 25000; g++) if (f(g) !== f(g - 1)) s.push(g);
  return s;
}

const fee = boundaries(weightFee);
const badge = boundaries(tierName);
const diverging = fee.filter((g) => !badge.includes(g)).length
  + badge.filter((g) => !fee.includes(g)).length;

console.log('fee boundaries:', fee.join(' '));
console.log('badge boundaries:', badge.join(' '));
console.log('diverging boundary count:', diverging);
```

```
fee boundaries: 1001 5001 20001
badge boundaries: 1001 5001 20001
diverging boundary count: 0
```

Today it is zero. The cost of duplication does not show up today — it shows up at the
first change. Suppose a 10-kilogram intermediate tier is added to the tariff, and the
person making the change finds the fee file but never sees the badge file.

```js
// fee.mjs — 10 kg tier added
const TIER = [
  { maxGrams: 1000, cents: 4990 },
  { maxGrams: 5000, cents: 7490 },
  { maxGrams: 10000, cents: 9900 },
  { maxGrams: 20000, cents: 12900 },
  { maxGrams: Infinity, cents: 24900 },
];

export function weightFee(grams) {
  return TIER.find((t) => grams <= t.maxGrams).cents;
}
```

```sh
node boundaries.mjs
```

```
fee boundaries: 1001 5001 10001 20001
badge boundaries: 1001 5001 20001
diverging boundary count: 1
```

The diverging boundary count rose to one. In domain terms: for every shipment between
5,001 and 10,000 grams, the customer is now charged the 99.00 TL tier fee while the
operations screen still shows the "large" badge. The two modules say something
different about the same shipment, and no test broke, because each module's tests were
written against its own table.

The fix is to move the information to a single place.

```js
// tariff.mjs — single source of tier data
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}
```

```js
// fee.mjs — reads from the tariff
import { tier } from './tariff.mjs';

export function weightFee(grams) {
  return tier(grams).cents;
}
```

```js
// summary.mjs — reads from the same tariff
import { tier } from './tariff.mjs';

export function tierName(grams) {
  return tier(grams).name;
}
```

```sh
node boundaries.mjs
```

```
fee boundaries: 1001 5001 10001 20001
badge boundaries: 1001 5001 10001 20001
diverging boundary count: 0
```

What was gained is not line count. The number of files touched to change a tier
boundary dropped from two to one, and the possibility of divergence disappeared along
with it: the two modules read the same line, so they cannot disagree.

#### Incidental Similarity

The second face of the principle covers code that looks alike without carrying the
same information. The library produces two document lines: one is the label stuck on
the package, the other is the invoice line sent to accounting.

```js
// document.mjs — version 0: two call sites, identical text today
export function shipmentLabel(s) {
  return `${s.no} ${s.zone} ${(s.cents / 100).toFixed(2)} TL`;
}

export function invoiceLine(s) {
  return `${s.no} ${s.zone} ${(s.cents / 100).toFixed(2)} TL`;
}
```

The two bodies are character-for-character identical. A surface reading of do not
repeat yourself says to merge them. But the two lines being the same is not
**information**, it is a **coincidence**: warehouse operations decides the label's
format, accounting decides the invoice line's, and the two authorities decide without
knowing about each other. The Organizing Code by Actor lesson's criterion applies here:
pieces that change for different reasons should not share a body.

Seeing what the merge produces requires writing the merged version. Once the first two
requests for divergence arrive, the shared function takes this shape.

```js
// document.mjs — version 1: two call sites merged into one function
export function documentLine(s, { addVat = false, showRoute = false, isoCurrency = false } = {}) {
  const amount = addVat ? Math.round(s.cents * 1.2) : s.cents;
  const field = showRoute ? s.route : s.zone;
  const unit = isoCurrency ? 'TRY' : 'TL';
  return `${s.no} ${field} ${(amount / 100).toFixed(2)} ${unit}`;
}

export const shipmentLabel = (s) => documentLine(s, { showRoute: true });
export const invoiceLine = (s) => documentLine(s, { addVat: true, isoCurrency: true });
```

This is three of the boolean flag parameters that the Interface and Data Decisions
topic advised splitting apart, gathered into a single signature. How much of the flag
space is actually used can be counted.

```js
// paths.mjs — compares the flag space with the paths actually used
import { documentLine } from './document.mjs';

const flags = ['addVat', 'showRoute', 'isoCurrency'];
const shipment = { no: 'G-1042', zone: 'B2', route: 'IST-ANK', cents: 7490 };

const all = new Set();
for (let m = 0; m < 2 ** flags.length; m++) {
  const options = Object.fromEntries(flags.map((f, i) => [f, Boolean(m & (1 << i))]));
  all.add(documentLine(shipment, options));
}
const used = new Set([
  documentLine(shipment, { showRoute: true }),
  documentLine(shipment, { addVat: true, isoCurrency: true }),
]);

console.log('flag count:', flags.length);
console.log('combination count:', all.size);
console.log('used by call sites:', used.size);
console.log('never exercised combinations:', all.size - used.size);
```

```
flag count: 3
combination count: 8
used by call sites: 2
never exercised combinations: 6
```

Six of the eight combinations never run. Those six paths still get read, still get
maintained, and everyone reading the function has to assume they are possible. This is
the **wrong abstraction**: a shared body without a shared contract.

#### The Measured Cost of a Wrong Abstraction

The real cost shows up not in the flag count but in the two call sites constraining
each other. The contract of the two call sites is pinned down with tests.

```js
// document.test.mjs — the contract of the two call sites
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentLabel, invoiceLine } from './document.mjs';

const shipment = { no: 'G-1042', zone: 'B2', route: 'IST-ANK', cents: 7490 };

test('the label writes the route code and the amount without VAT', () => {
  assert.equal(shipmentLabel(shipment), 'G-1042 IST-ANK 74.90 TL');
});

test('the invoice line writes the zone and the amount with VAT in the ISO unit', () => {
  assert.equal(invoiceLine(shipment), 'G-1042 B2 89.88 TRY');
});
```

```sh
node --test --test-reporter=tap document.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - the label writes the route code and the amount without VAT
ok 2 - the invoice line writes the zone and the amount with VAT in the ISO unit
# tests 2
# pass 2
# fail 0
```

Accounting announces a new rule: the invoice amount will be rounded to whole lira.
This is a decision that concerns only the invoice line. The change is written into the
shared function's amount calculation.

```js
// document.mjs — version 2: whole-lira rounding written into the shared spot
export function documentLine(s, { addVat = false, showRoute = false, isoCurrency = false } = {}) {
  const raw = addVat ? Math.round(s.cents * 1.2) : s.cents;
  const amount = Math.round(raw / 100) * 100;
  const field = showRoute ? s.route : s.zone;
  const unit = isoCurrency ? 'TRY' : 'TL';
  return `${s.no} ${field} ${(amount / 100).toFixed(2)} ${unit}`;
}

export const shipmentLabel = (s) => documentLine(s, { showRoute: true });
export const invoiceLine = (s) => documentLine(s, { addVat: true, isoCurrency: true });
```

The invoice test's expectation is updated for the new rule; the label test is left as
it was, because no decision about the label changed.

```js
// document.test.mjs — invoice expectation updated for the new requirement
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentLabel, invoiceLine } from './document.mjs';

const shipment = { no: 'G-1042', zone: 'B2', route: 'IST-ANK', cents: 7490 };

test('the label writes the route code and the amount without VAT', () => {
  assert.equal(shipmentLabel(shipment), 'G-1042 IST-ANK 74.90 TL');
});

test('the invoice line writes the amount rounded to whole lira', () => {
  assert.equal(invoiceLine(shipment), 'G-1042 B2 90.00 TRY');
});
```

```sh
node --test --test-reporter=tap document.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
not ok 1 - the label writes the route code and the amount without VAT
ok 2 - the invoice line writes the amount rounded to whole lira
# tests 2
# pass 1
# fail 1
```

A decision belonging to accounting broke an output belonging to warehouse operations.
The label started printing 75.00 TL instead of 74.90 TL. In numbers: restricting the
rounding to the invoice alone needs a fourth flag, taking the flag count from three to
four and the combination count from eight to sixteen, while the paths actually used
stay at two. Every request for divergence doubles the combination space and leaves the
used-path count unchanged.

#### The Cost of Separation

The right move is to undo the merge. The two bodies are split apart again, each one
carried back to the decision it belongs to.

```js
// document.mjs — version 3: the similarity was incidental, split back into two functions
export function shipmentLabel(s) {
  return `${s.no} ${s.route} ${(s.cents / 100).toFixed(2)} TL`;
}

export function invoiceLine(s) {
  const withVat = Math.round(s.cents * 1.2);
  const wholeLira = Math.round(withVat / 100) * 100;
  return `${s.no} ${s.zone} ${(wholeLira / 100).toFixed(2)} TRY`;
}
```

```sh
node --test --test-reporter=tap document.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'ternary operator count: %s\n' "$(grep -c ' ? ' document.mjs)"
```

```
ok 1 - the label writes the route code and the amount without VAT
ok 2 - the invoice line writes the amount rounded to whole lira
# tests 2
# pass 2
# fail 0
ternary operator count: 0
```

The flag count dropped from three to zero, and the branch count from three to zero.
The only thing that still looks repeated is `s.no` and the `toFixed(2)` call; neither
is information, both are syntax. From here, the two functions change independently,
with no chance of one breaking the other.

The criterion that tells the two situations apart is one question: when one part
changes, is the other **required** to change too? For tier boundaries it was required;
when the boundary shifted in one place, it had to shift in the other, and because it
did not, the system fell out of sync. For document lines it was not required; the
accounting decision should not have touched the warehouse output, and it broke a test
because it did. The duplication question is answered by **reasons to change**, not by
lines.

#### Summary

- Do not repeat yourself is about the singleness of information, not line similarity;
  duplicated information and code that merely looks alike are different situations.
- Duplicated information's divergence can be measured: keeping the tier table in two
  files let a one-sided change raise the diverging boundary count from zero to one,
  with no test breaking.
- Moving the information to a single source dropped the files touched from two to one
  and removed the chance of the modules falling out of sync.
- Merging two incidentally similar bodies produces a wrong abstraction: three flags,
  eight combinations, and only two paths actually used.
- A wrong abstraction's cost is that call sites constrain each other; a rounding
  decision belonging only to the invoice broke the label test and needed a fourth flag
  to keep the two apart.
- The separation decision comes from one question: when one part changes, must the
  other change too?

#### Next Step

Two changes were made in this lesson, both small: a table was moved into a single
file, and a function was split in two. The small size is not an accident — it is a
method. The next lesson meets the same requirement two ways — designed all at once,
and evolved in small steps — and compares the lines touched at each step against how
many steps finished green.

### Keep It Simple and Refactor Often

Source: https://academia.sh/en/courses/clean-code/keep-it-simple-and-refactor-often

Meeting the same fee requirement with a solution designed in one step and a solution that evolves through five small steps, measuring the lines changed at each step, and comparing the suspect zone a red test points to.

The previous lesson made two changes: a table was moved into a single file, and a
function was split in two. Both were small, and both preserved existing behavior. This
lesson shows in numbers what a small step makes cheaper.

The principle's name is **keep it simple**: a requirement is met with the least
structure that satisfies it; whatever structure is still missing gets added later,
once it is genuinely needed. A second sentence goes with the principle — **refactor
often**. Moving forward with little structure does not excuse postponing the work of
tidying up what accumulates; refactoring is part of every step.

The Test-Driven Development topic examined step size from the angle of **feedback
time**: it measured how long a round from red to green takes. Here the same quantity
is measured from another angle: when a test turns red, **which set of lines** the
search for the fault covers.

#### Requirement

A fee calculation with four rules is being added to the shipment pricing library.

1. The base fee is read from the weight tier.
2. The base fee is multiplied by the shipping zone's factor.
3. The result cannot fall below the minimum fee.
4. If the volumetric weight exceeds the actual weight, the tier is read from the
   volumetric weight.

The tier table was set up in the previous lesson and is used unchanged.

```js
// tariff.mjs — tier table carried over from the previous lesson
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}
```

#### Full Design in One Step

Reading all four rules at once, the first solution that comes to mind is to satisfy
them all in a single pass.

```js
// fee.mjs — version written all at once, in a single step
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function shipmentFee(s) {
  const volumeGrams = Math.ceil((s.width * s.length * s.height) / 3);
  const effectiveGrams = Math.max(s.grams, volumeGrams);
  const base = tier(effectiveGrams).cents;
  const withMinimum = Math.max(base, MINIMUM_CENTS);
  return Math.round(withMinimum * ZONE_FACTOR[s.zone]);
}
```

All four rules are pinned down with a test each.

```js
// fee.test.mjs — all four rules
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'fee.mjs line count: %s\n' "$(wc -l < fee.mjs | tr -d ' ')"
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
not ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
# tests 4
# pass 3
# fail 1
fee.mjs line count: 13
```

The third test is red: the minimum fee was applied before the zone multiplication, so
the minimum amount itself got multiplied by the zone factor too. The fault itself is
one line, but the suspect zone the run points to is the entire file: because all
four rules were added in the same pass, which rule the test failed for cannot be read
from the run. The suspect line count is 13.

#### Same Requirement, Five Steps

The same four rules are split into steps, each closed by its own test. The first step
satisfies only the first rule.

```js
// fee.mjs — step 1: weight tier only
import { tier } from './tariff.mjs';

export function shipmentFee(s) {
  return tier(s.grams).cents;
}
```

```sh
: > previous.mjs
cat > fee.test.mjs <<'SON'
// fee.test.mjs — step 1
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
cp fee.mjs previous.mjs
```

```
ok 1 - the tier fee is read from the weight
# tests 1
# pass 1
# fail 0
lines changed in this step: 6
```

The second step adds the zone factor. The only thing that changes is the
multiplication.

```js
// fee.mjs — step 2: zone factor
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

export function shipmentFee(s) {
  return Math.round(tier(s.grams).cents * ZONE_FACTOR[s.zone]);
}
```

```sh
cat >> fee.test.mjs <<'SON'

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
cp fee.mjs previous.mjs
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
# tests 2
# pass 2
# fail 0
lines changed in this step: 6
```

The third step adds the minimum fee, and this is exactly where the one-step design's
decision gets made: is the minimum fee applied before or after the multiplication? The
first attempt picks before.

```js
// fee.mjs — step 3: minimum fee (first attempt)
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function shipmentFee(s) {
  const base = Math.max(tier(s.grams).cents, MINIMUM_CENTS);
  return Math.round(base * ZONE_FACTOR[s.zone]);
}
```

```sh
cat >> fee.test.mjs <<'SON'

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
not ok 3 - the minimum fee is applied after the zone multiplication
# tests 3
# pass 2
# fail 1
lines changed in this step: 6
```

The test that turns red is the same, the fault is the same. What changes is where the
search happens: the lines changed in this step number 6. In the one-step design, that
same red left the entire 13-line file suspect, with all four rules tangled together
inside those 13 lines. Here the suspect zone is only the minimum-fee lines.

```js
// fee.mjs — step 3: minimum fee applied after zone multiplication
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(s.grams).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
cp fee.mjs previous.mjs
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
# tests 3
# pass 3
# fail 0
lines changed in this step: 6
```

The fourth step adds the volumetric weight.

```js
// fee.mjs — step 4: volumetric weight
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function shipmentFee(s) {
  const volumeGrams = Math.ceil((s.width * s.length * s.height) / 3);
  const effectiveGrams = Math.max(s.grams, volumeGrams);
  const withZoneFactor = Math.round(tier(effectiveGrams).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}
```

```sh
cat >> fee.test.mjs <<'SON'

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
printf 'shipmentFee body: %s lines\n' "$(awk '/^export function shipmentFee/,/^}/' fee.mjs | wc -l | tr -d ' ')"
cp fee.mjs previous.mjs
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
# tests 4
# pass 4
# fail 0
lines changed in this step: 6
shipmentFee body: 6 lines
```

#### Fifth Step: Refactoring

All four rules are met and the tests are green. The second half of the principle takes
over here: what has accumulated gets tidied up before the next requirement arrives.
The fee function carries two separate levels of abstraction — how the weight is
determined and how the fee is computed. Determining the weight gets its own name.

```js
// fee.mjs — step 5: effective weight extracted
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'lines changed in this step: %s\n' "$(diff previous.mjs fee.mjs | grep -c '^[<>]')"
printf 'shipmentFee body: %s lines\n' "$(awk '/^export function shipmentFee/,/^}/' fee.mjs | wc -l | tr -d ' ')"
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
# tests 4
# pass 4
# fail 0
lines changed in this step: 10
shipmentFee body: 4 lines
```

No rule was added in this step, no test changed, and the number of lines changed came
out larger than in the earlier steps. That is normal in refactoring steps: the measure
is not the line count changed, it is the run staying green. The fee function's body
dropped from six lines to four, and the weight-determination rule can now be tested
separately, under its own name.

#### Measuring Step Size

Two paths finished on the same file; what got measured differs.

| Metric | One step | Five steps |
|---|---|---|
| Run count | 1 | 6 |
| Runs that finished green | 0 | 5 |
| Suspect lines while red | 13 | 6 |
| Lines changed in one step | 13 | 6 (10 in the refactoring step) |

The one-step path's single run finished red, and which rule the fault belonged to
could not be read from the run; the failing test was the minimum-fee test, but the
minimum-fee line had been added in the same pass as the volumetric-weight and
zone-factor lines. On the five-step path, red appeared exactly in the step that added
the one rule it belonged to.

In domain terms: the more rules a requirement contains, the cost of satisfying all of
them in a single pass grows not with the rule count but with the number of
**orderings among the rules**. Four rules admit more than one order of application,
and a single pass picks all of them at once. Splitting into steps makes each of those
choices get made one at a time.

A common misreading of keep it simple is "never build structure." The fifth step
shows the opposite: structure does get built, but **after** the requirement arrives
and while the tests are green. What the principle forbids is not structure — it is
structure that gets ahead of the requirement.

#### Summary

- Keep it simple says to move forward with the least structure that meets a
  requirement; refactor often says to tidy up whatever accumulates at the end of
  every step.
- When the same four rules were met in a single pass, the one run finished red with a
  13-line suspect zone; split into five steps, the same fault appeared in a 6-line
  step.
- Splitting into steps turns decisions about rule ordering into choices made one at a
  time; in a single pass, all of those decisions get made at once and without
  justification.
- The line count changed in a refactoring step can grow; that step's measure is not
  the line count, it is the tests staying green.
- The principle does not forbid structure; it forbids structure that gets ahead of the
  requirement.

#### Next Step

In this lesson, every step answered a requirement that had already arrived. What
structure written for a requirement that has not arrived costs was not measured. The
next lesson adds an extension point and a configuration layer to the fee calculation
today, counts how many branches and unexercised paths they produce, and then shows
that the requirement that actually arrives does not fit that structure.

### You Are Not Going to Need It

Source: https://academia.sh/en/courses/clean-code/you-arent-gonna-need-it

Measuring an extension point and a configuration layer added to the fee calculation today by their decision-point count, branch coverage, and unexercised-path count; showing with a failing run that the incoming requirement does not fit that structure, and discarding the generalization.

In the previous lesson, every step answered a requirement that had already arrived,
and the four rules were met in five steps. What structure written today for a
requirement that has not arrived costs was not measured. This lesson adds an extension
point and a configuration layer to the fee calculation today, counts the decision
points and never-exercised paths the two produce, and then shows that the requirement
that actually arrives does not fit that structure.

The principle's name is **you are not going to need it**: a capability does not get
written until the need for it actually shows up. The principle is a ban on
prediction — not a claim that a developer cannot predict a future requirement
correctly, but a statement that the **cost of a wrong prediction** is measurable.

The starting point is the previous lesson's last step.

```js
// tariff.mjs — single source of tier data
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}
```

```js
// fee.mjs — carried over from the previous lesson's last step
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}
```

```js
// fee.test.mjs — four tests carried over from the previous lesson
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});
```

The measurement needs a counter: a script that counts a file's **decision points** —
the places where the program picks one of two separate paths.

```js
// branches.mjs — counts decision points in the given files
import { readFileSync } from 'node:fs';

const PATTERN = /\bif\b|\bcase\b|\?\?|&&|\|\||\?\.|\?(?![?.])/g;

let total = 0;
for (const path of process.argv.slice(2)) {
  const n = (readFileSync(path, 'utf8').match(PATTERN) ?? []).length;
  console.log(`${path}: ${n} decision points`);
  total += n;
}
console.log(`total: ${total}`);
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
node branches.mjs fee.mjs
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
# tests 4
# pass 4
# fail 0
fee.mjs: 0 decision points
total: 0
```

#### Two Kinds of Flexibility Written In Today

The tariff rules will change over time. That is a correct observation and a wrong
justification: knowing that change is coming does not mean knowing what shape it will
take. Two structures get written in today regardless. The first is a configuration
layer that makes every detail of the calculation selectable from outside.

```js
// configuration.mjs — configuration layer written in today
export const DEFAULTS = {
  applyVolume: true,
  volumeDivisor: 3,
  applyMinimum: true,
  minimumCents: 6990,
  rounding: 'nearest',
  factorSource: 'fixed',
};

export function configure(options = {}) {
  return { ...DEFAULTS, ...options };
}
```

The second is an **extension point** that lets future fee rules plug into the
calculation from outside.

```js
// extension.mjs — extension point for fee rules
const rules = [];

export function addRule(name, priority, apply) {
  rules.push({ name, priority, apply });
  rules.sort((a, b) => a.priority - b.priority);
}

export function resetRules() {
  rules.length = 0;
}

export function applyRules(cents, s, config) {
  let result = cents;
  for (const r of rules) {
    result = r.apply(result, s, config);
  }
  return result;
}
```

The fee calculation is rewritten to use both. Behavior stays the same: with the
default configuration and no registered rule, the results do not change.

```js
// fee.mjs — version generalized in today
import { tier } from './tariff.mjs';
import { configure } from './configuration.mjs';
import { applyRules } from './extension.mjs';

const FIXED_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

function factor(s, config) {
  if (config.factorSource === 'external') return config.factorTable?.[s.zone] ?? 1;
  return FIXED_FACTOR[s.zone] ?? 1;
}

function round(cents, config) {
  if (config.rounding === 'none') return cents;
  if (config.rounding === 'up') return Math.ceil(cents);
  return Math.round(cents);
}

export function effectiveWeight(s, config) {
  if (!config.applyVolume) return s.grams;
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / config.volumeDivisor));
}

export function shipmentFee(s, options = {}) {
  const config = configure(options);
  const withZoneFactor = round(tier(effectiveWeight(s, config)).cents * factor(s, config), config);
  const withMinimum = config.applyMinimum ? Math.max(withZoneFactor, config.minimumCents) : withZoneFactor;
  return applyRules(withMinimum, s, config);
}
```

#### The Cost Paid, Measured

The tests did not change and stay green. What changed is the size of the structure
the tests are able to **cover**. Alongside decision points, branch coverage — which
shows which paths the run actually goes through — is measured too.

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
node branches.mjs fee.mjs configuration.mjs extension.mjs
node --test --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | sed -n '/start of coverage report/,/end of coverage report/p'
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
# tests 4
# pass 4
# fail 0
fee.mjs: 8 decision points
configuration.mjs: 0 decision points
extension.mjs: 0 decision points
total: 8
# start of coverage report
# ------------------------------------------------------------------
# file              | line % | branch % | funcs % | uncovered lines
# ------------------------------------------------------------------
# configuration.mjs | 100.00 |   100.00 |  100.00 | 
# extension.mjs     |  63.16 |    66.67 |   33.33 | 5-7 10-11 16-17
# fee.mjs           | 100.00 |    45.45 |  100.00 | 
# tariff.mjs        | 100.00 |   100.00 |  100.00 | 
# ------------------------------------------------------------------
# all files         |  90.41 |    63.16 |   80.00 | 
# ------------------------------------------------------------------
# end of coverage report
```

Three numbers are worth reading. The decision-point count rose from zero to eight;
none of them comes from a domain rule, all of them test which value an option holds.
The fee file's line coverage is 100 percent, but its branch coverage is 45.45 percent:
every line runs, but more than half of the paths do not. In the extension file,
`addRule` and `resetRules` are never called, so function coverage stays at 33.33
percent, and the uncovered lines are listed one by one.

There is no way around these paths staying unmaintained: raising branch coverage to
100 percent means writing at least one more test for each of the six extra decision
points. No call site today wants the behavior those tests would exercise. The
configuration layer carries six fields, and the number of options any call site passes
is zero; the number of rules registered on the extension list is zero too.

#### Incoming Requirement

A rule arrives for contract customers: a customer with more than fifty shipments in a
billing period gets a twelve percent discount on **every shipment in that period**.
The extension point was written exactly for rules like this one; the rule tries to
plug in there.

```js
// period.mjs — attempt to write the period discount through the extension point
import { shipmentFee } from './fee.mjs';
import { addRule, resetRules } from './extension.mjs';

const THRESHOLD = 50;
const RATE = 0.88;

export function periodFee(shipments) {
  resetRules();
  let count = 0;
  addRule('period-discount', 10, (cents) => {
    count += 1;
    return count > THRESHOLD ? Math.round(cents * RATE) : cents;
  });
  return shipments.reduce((t, s) => t + shipmentFee(s), 0);
}
```

```js
// period.test.mjs — the single test for the incoming requirement
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { periodFee } from './period.mjs';

const period = Array.from({ length: 60 }, () => ({
  width: 20, length: 15, height: 10, grams: 3000, zone: 'B1',
}));

test('all shipments in a period past the threshold total at the discounted rate', () => {
  assert.equal(periodFee(period), Math.round(60 * 7490 * 0.88));
});
```

```sh
node --test --test-reporter=tap period.test.mjs \
  | grep -E '^ *(ok|not ok|expected:|actual:|# (tests|pass|fail))'
```

```
not ok 1 - all shipments in a period past the threshold total at the discounted rate
  expected: 395472
  actual: 440410
# tests 1
# pass 0
# fail 1
```

The 44,938-cent gap shows that the extension point generalized along **the wrong
axis**. A rule's signature is `(cents, shipment, config)`: a rule sees a single
shipment. The incoming requirement decides a shipment's fee based on **the size of the
set** instead; by the time the fifty-first shipment is processed, the previous fifty
have already been summed. The extension could only meet this by walking the set
twice, and at that point the caller doing the summing has taken over the whole job —
the extension point serves no purpose.

The generalization had assumed that future change would arrive as **a rule per
shipment**. The change that actually arrived is per period, not per shipment. The same
holds for the configuration layer: none of its six fields covers the discount
threshold or the discount rate.

#### Discarding the Generalization

The size of the discarded structure can be counted.

```sh
printf 'lines in the discarded files: %s\n' \
  "$(cat configuration.mjs extension.mjs period.mjs | wc -l | tr -d ' ')"
rm configuration.mjs extension.mjs period.mjs period.test.mjs
```

```
lines in the discarded files: 48
```

In its place, the incoming requirement is met directly: a function that computes the
period fee, one threshold, one rate.

```js
// fee.mjs — generalization discarded, the incoming requirement met directly
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  if (shipments.length <= DISCOUNT_THRESHOLD) return total;
  return Math.round(total * DISCOUNT_RATE);
}
```

```js
// fee.test.mjs — the four earlier tests and two for the period discount
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee, periodFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
const period = (count) => Array.from({ length: count }, () => ({ ...box, grams: 3000, zone: 'B1' }));

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});

test('all shipments in a period past the threshold total at the discounted rate', () => {
  assert.equal(periodFee(period(60)), Math.round(60 * 7490 * 0.88));
});

test('a period under the threshold gets no discount', () => {
  assert.equal(periodFee(period(10)), 10 * 7490);
});
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
node branches.mjs fee.mjs
node --test --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | sed -n '/start of coverage report/,/end of coverage report/p'
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
# tests 6
# pass 6
# fail 0
fee.mjs: 1 decision points
total: 1
# start of coverage report
# -----------------------------------------------------------
# file       | line % | branch % | funcs % | uncovered lines
# -----------------------------------------------------------
# fee.mjs    | 100.00 |   100.00 |  100.00 | 
# tariff.mjs | 100.00 |   100.00 |  100.00 | 
# -----------------------------------------------------------
# all files  | 100.00 |   100.00 |  100.00 | 
# -----------------------------------------------------------
# end of coverage report
```

One decision point is left, and it corresponds to a domain rule: whether the period
is under the threshold or not. Branch coverage is 100 percent; there is no path in the
codebase the run does not go through. The fee file imports one module; the previous
version imported three.

| Metric | Generalized | Direct |
|---|---|---|
| Decision points | 8 | 1 |
| Fee file's branch coverage | 45.45% | 100% |
| All files' line coverage | 90.41% | 100% |
| Modules imported | 3 | 1 |
| Meets the incoming requirement | no | yes |

#### What the Principle Forbids

This lesson and the previous one measured different things. Keep it simple asks for
the least structure for a requirement that **has** arrived; you are not going to need
it forbids writing structure for a requirement that **has not** arrived. One concerns
the shape of today's work, the other a prediction about tomorrow's.

The principle's boundary lies in the cost of discarding. Here, the 48 discarded lines
stayed inside the library; they affected no external consumer and could be undone with
a single commit in the `git` repository. The same calculation gives a different answer
for a published interface or a persisted data format that requires a backward
migration: there, the cost of a wrong prediction can exceed the cost of not predicting
at all. The question that makes the principle applicable is not "will this be needed
later," it is **"how much more expensive is adding it when needed than adding it
today."** In the fee library, that gap was close to zero: the period discount was
written in six lines, with no extension point at all.

#### Summary

- You are not going to need it forbids writing structure today for a requirement that
  has not arrived; the measured quantity is the cost of a wrong prediction.
- The configuration layer and extension point written in today raised the fee file's
  decision-point count from zero to eight and dropped its branch coverage to 45.45
  percent.
- Line coverage at 100 percent does not mean the paths run; unexercised paths show up
  in branch and function coverage.
- The requirement that actually arrived was per period, not per shipment; the
  extension point's signature could not express that, and the single-test run
  finished red with a 44,938-cent gap.
- Discarding the generalization deleted 48 lines; the direct solution met the same
  requirement with one decision point, 100 percent branch coverage, and six tests.
- The principle's criterion is not "will this be needed later," it is how much more
  expensive adding it when needed is than adding it today.

#### Next Step

After the generalization is discarded, the remaining file works but is not tidy: the
zone factor table keeps information that belongs to the tariff inside the fee file,
the threshold comparison in the period calculation stays unnamed, and the name `s`
does not say that it stands for a shipment. The next lesson fixes these three flaws
with three mechanical techniques — extract, move, and rename — applies each one
through its intermediate steps, and shows the test run after every step.

### Refactoring Techniques

Source: https://academia.sh/en/courses/clean-code/refactoring-techniques

Applying the extract, move, and rename techniques to the fee library through their intermediate steps; taking a test run after every step and measuring the steps by coverage, file count, and name-occurrence count.

The previous lesson discarded the generalization and met the incoming requirement
directly. The file that remains works, but carries three flaws: the zone factor table
keeps information that belongs to the tariff inside the fee file, the threshold
comparison in the period calculation stays unnamed, and the name `s` does not say it
stands for a shipment. This lesson fixes the three flaws with three mechanical
techniques — **extract**, **move**, and **rename** — applies each through its
intermediate steps, and shows the run output after every step.

The red-green-refactor cycle was established in the Test-Driven Development topic:
there, tests drove the design, and the third phase was set aside for tidying up. Here
the subject is not the cycle but **the techniques themselves**, applied in that third
phase. No test gets written in this lesson, no expectation changes, and no run turns
red; the tests are only the instrument reporting whether behavior was preserved.

The starting point is the previous lesson's ending state.

```js
// tariff.mjs — single source of tier data
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}
```

```js
// fee.mjs — previous lesson's end state
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  if (shipments.length <= DISCOUNT_THRESHOLD) return total;
  return Math.round(total * DISCOUNT_RATE);
}
```

```js
// fee.test.mjs — six tests carried over from the previous lesson
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee, periodFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
const period = (count) => Array.from({ length: count }, () => ({ ...box, grams: 3000, zone: 'B1' }));

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});

test('all shipments in a period past the threshold total at the discounted rate', () => {
  assert.equal(periodFee(period(60)), Math.round(60 * 7490 * 0.88));
});

test('a period under the threshold gets no discount', () => {
  assert.equal(periodFee(period(10)), 10 * 7490);
});
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|# (tests|pass|fail))'
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
# tests 6
# pass 6
# fail 0
```

#### Common Shape of the Three Techniques

All three techniques are made of the same three parts. **Precondition:** a run exists
that will show the change did not alter behavior, and it is green. **Intermediate
state:** the old and the new stand side by side for a while; the code runs and the
tests are green, but the structure lives in two places. **Removal:** the old one is
deleted and the intermediate state closes.

The intermediate state is what makes these techniques work. When a change is made in
a single move, a red run cannot tell whether the fault is in the new structure or in
how the call sites were migrated. Split into steps, each run answers exactly one
question.

#### Extract

The threshold comparison in the period calculation stays unnamed: the line comparing
the period length against `DISCOUNT_THRESHOLD` applies a rule, but does not say the
rule's name. Extraction moves an expression or a block of statements out into a unit
with its own name.

The first step writes the new function and **touches no call site**.

```js
// fee.mjs — extract step 1: new function written, call site untouched
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  if (shipments.length <= DISCOUNT_THRESHOLD) return total;
  return Math.round(total * DISCOUNT_RATE);
}
```

```sh
node --test --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | grep -E '^# ((tests|pass|fail) |file |fee\.mjs)'
```

```
# tests 6
# pass 6
# fail 0
# file       | line % | branch % | funcs % | uncovered lines
# fee.mjs    |  92.31 |   100.00 |   80.00 | 14-15
```

The run is green, but the intermediate state's cost shows up in coverage: line
coverage dropped from 100 percent to 92.31, function coverage to 80 percent, and lines
14–15 got listed as uncovered. The new function is not called yet — the mark
extraction leaves when stopped halfway: a name nobody uses.

The second step migrates the call site and deletes the old expression.

```js
// fee.mjs — extract step 2: call site switched to the new function
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```sh
node --test --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | grep -E '^# ((tests|pass|fail) |file |fee\.mjs)'
```

```
# tests 6
# pass 6
# fail 0
# file       | line % | branch % | funcs % | uncovered lines
# fee.mjs    | 100.00 |   100.00 |  100.00 | 
```

Coverage came back. The period function's body dropped from four lines to three, and
the discount rule can now be tested separately, under its own name. This is
extraction's measurable gain: the number of testable units grew while behavior stayed
the same.

#### Move

The zone factor table sits in the fee file, but it is information belonging to the
tariff: a business decision like the weight tiers, changed by the same authority.
Moving is done in three steps, because the import relationship concerns two files at
once.

The first step creates the new function in the target module; the source file is
untouched.

```js
// tariff.mjs — move step 1: function created in the target module
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'factor table tariff.mjs: %s fee.mjs: %s\n' \
  "$(grep -c 'B2: 1.25' tariff.mjs)" "$(grep -c 'B2: 1.25' fee.mjs)"
```

```
# tests 6
# pass 6
# fail 0
factor table tariff.mjs: 1 fee.mjs: 1
```

The table now sits in two files. This is exactly the knowledge duplication from the
first lesson, and it is a deliberately produced, temporary state; the next two steps
of the move exist to close that duplication.

The second step migrates the call site to the new function. The old constant stays in
the file, now unused.

```js
// fee.mjs — move step 2: call site switched to the tariff's function
import { tier, zoneFactor } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(s) {
  return Math.max(s.grams, Math.ceil((s.width * s.length * s.height) / 3));
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * zoneFactor(s.zone));
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'ZONE_FACTOR occurrences in fee.mjs: %s\n' "$(grep -c ZONE_FACTOR fee.mjs)"
```

```
# tests 6
# pass 6
# fail 0
ZONE_FACTOR occurrences in fee.mjs: 1
```

The occurrence count dropping to one is the precondition for the removal step: if a
name occurs only in its own declaration, nothing reads it anywhere. The third step
deletes the declaration.

```sh
grep -v 'ZONE_FACTOR' fee.mjs > temp.mjs && mv temp.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'factor table tariff.mjs: %s fee.mjs: %s\n' \
  "$(grep -c 'B2: 1.25' tariff.mjs)" "$(grep -c 'B2: 1.25' fee.mjs)"
```

```
# tests 6
# pass 6
# fail 0
factor table tariff.mjs: 1 fee.mjs: 0
```

The factor table now lives in a single file. Moving is measured not by line count but
by the number of files a piece of information sits in: it dropped from two to one, and
whoever changes tariff decisions from here on looks at a single file.

#### Rename

The move produced a new flaw. The tariff module now has two lookup functions: one by
weight, one by zone. The name `tier` was enough while the module had a single lookup;
once the second arrived, it stopped saying which dimension it looked up by. Renaming's
justification is usually born this way — the name does not get worse, its context
does.

An exported name concerns every file that imports it. This is why renaming is also
three steps, and the first step keeps the old name alive.

```js
// tariff.mjs — rename step 1: new name added alongside the old one
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

export function weightTier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}

export const tier = weightTier;

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
```

```
# tests 6
# pass 6
# fail 0
old-name occurrences: 3
```

The second step migrates the call sites to the new name. A second rename happens in
the same step: the parameter `s` becomes `shipment`. Fitting two renames into one step
is not carelessness — it follows from a difference in scope; `s` is scoped to a single
function, nobody outside sees it, and so it needs no intermediate name.

```js
// fee.mjs — rename step 2: call site switched to the new name
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
```

```
# tests 6
# pass 6
# fail 0
old-name occurrences: 1
```

It dropped from three to one; the one remaining occurrence is the link keeping the old
name alive. The third step deletes it.

```sh
grep -v 'export const tier = weightTier;' tariff.mjs > temp.mjs
mv temp.mjs tariff.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|# (tests|pass|fail))'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
# tests 6
# pass 6
# fail 0
old-name occurrences: 0
```

The word "tier" stays in the test names, and it should: what appears there is not a
code name, it is the domain's word. Renaming changes a symbol, not the domain's
vocabulary.

#### The Steps' Balance Sheet

| Step | Technique | Run | Measured |
|---|---|---|---|
| 1 | extract | green | function coverage 100% → 80% |
| 2 | extract | green | function coverage 80% → 100% |
| 3 | move | green | factor table 1 file → 2 files |
| 4 | move | green | old constant's occurrences 2 → 1 |
| 5 | move | green | factor table 2 files → 1 file |
| 6 | rename | green | old name's occurrences 3 |
| 7 | rename | green | old name's occurrences 3 → 1 |
| 8 | rename | green | old name's occurrences 1 → 0 |

In all eight steps the run finished green, and the test file never changed: six
tests, six expectations, zero edits. Two measures moved backward at one step each —
the table grew to two files at the third step, coverage dropped at the first. Both are
the mark of an intermediate state, and both closed at the next step. This is where
doing refactoring step by step earns its meaning: intermediate states are
unavoidable, and the only remedy is keeping them short.

#### Summary

- Extract, move, and rename share the same three parts: a green precondition run, an
  intermediate state where old and new stand side by side, and a removal step.
- Extraction's intermediate state shows up in coverage; before the call site was
  migrated, function coverage dropped to 80 percent, line coverage to 92.31 percent,
  and two lines got listed as uncovered.
- Moving's intermediate state is a deliberately produced knowledge duplication; the
  factor table grew from one file to two, then back to one once the call site moved.
- The removal step's precondition is measurable: once the old name's occurrence count
  drops to one, the name occurs only in its own declaration.
- Exported names change in three steps; names scoped to a single function change in
  one — the difference comes from scope.
- All eight steps finished green and none of the six tests changed; the techniques
  changed only structure, not behavior.

#### Next Step

In this lesson, the green run taken after every step counted as proof that behavior
was preserved. That count itself was never tested: do the six tests actually cover the
fee calculation's entire behavior? The next lesson shows an extraction step, done
without a test, that silently changes behavior; measures that the same six tests fail
to catch it; turns the test suite's catching power into a number; and shows the moment
a strengthened suite turns that same step red.

### Refactoring Under Test

Source: https://academia.sh/en/courses/clean-code/refactoring-under-test

Finding, through a scan, behavior that silently changed in an extraction step done without a test; measuring a six-test suite's catching power with mutations; and having the strengthened suite turn that same step red.

In the previous lesson, the green run taken after every step counted as proof that
behavior was preserved. That count itself was never tested: do the six tests actually
cover the fee calculation's entire behavior? This lesson first shows behavior that
silently changes in an extraction step done without a test, measures that the same six
existing tests fail to catch it, turns the test suite's catching power into a number,
and shows the moment a strengthened suite turns that same step red.

The tests have a single job here: **behavior preservation**. Writing tests, setting up
fake dependencies, and letting a test drive the design are topics of the Software
Quality and Testing curriculum. Here, the tests answer only one question — do the code
before and after refactoring give the same answer to the same inputs?

The starting point is the previous lesson's ending state.

```js
// tariff.mjs — previous lesson's end state
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

export function weightTier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
```

```js
// fee.mjs — previous lesson's end state
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```js
// fee.test.mjs — six tests carried over from the previous lesson
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee, periodFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
const period = (count) => Array.from({ length: count }, () => ({ ...box, grams: 3000, zone: 'B1' }));

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});

test('all shipments in a period past the threshold total at the discounted rate', () => {
  assert.equal(periodFee(period(60)), Math.round(60 * 7490 * 0.88));
});

test('a period under the threshold gets no discount', () => {
  assert.equal(periodFee(period(10)), 10 * 7490);
});
```

So the comparison can be made, a copy of the pre-refactoring version is kept.

```sh
cp fee.mjs fee-previous.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
```

```
# tests 6
# pass 6
# fail 0
```

#### Behavior That Silently Changes

The change is the same extraction as in the previous lesson: the period discount's
threshold decision gets its own name. While it is being named, the comparison's
direction shifts.

```js
// fee.mjs — extraction: threshold decision extracted as discountApplies
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountApplies(count) {
  return count >= DISCOUNT_THRESHOLD;
}

export function discountRate(count) {
  return discountApplies(count) ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|# (tests|pass|fail))'
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
# tests 6
# pass 6
# fail 0
```

All six tests are green. In a codebase without tests, this step would have gone
unnoticed; in a codebase with tests, it went unnoticed too. The way to see whether the
change actually preserved behavior is to compare the two versions on the same inputs.

```js
// diff.mjs — compares the two versions' results on the same inputs
import { periodFee as previous } from './fee-previous.mjs';
import { periodFee as current } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
let tried = 0;
const diverging = [];

for (const grams of [500, 3000, 12000]) {
  for (let count = 1; count <= 120; count++) {
    const period = Array.from({ length: count }, () => ({ ...box, grams, zone: 'B1' }));
    tried += 1;
    const a = previous(period);
    const b = current(period);
    if (a !== b) diverging.push(`count=${count} grams=${grams} previous=${a} current=${b}`);
  }
}

console.log('periods tried:', tried);
console.log('results changed:', diverging.length);
console.log('first example:', diverging[0] ?? 'none');
```

```
periods tried: 360
results changed: 3
first example: count=50 grams=500 previous=349500 current=307560
```

Three of the three hundred sixty periods changed, and all three land at the same
point: periods of exactly fifty shipments. In domain terms, a contract customer
sitting exactly at the threshold gets billed 3,075.60 TL instead of 3,495.00 TL. The
green run did not say anything wrong; it did not answer the question **nobody asked
it**. Tests pin down their own inputs, not the whole input space.

#### Measuring the Protection

The quantity that needs measuring, then, is the test suite's **catching power**: how
many kinds of behavior change turn the run red? This is measured by applying
deliberate, small corruptions to the code and counting the run's reaction. Each
corruption sits at the level of a single character or a single number and is undone
after the measurement; these are called **mutations**.

```sh
cp fee.mjs fee-extracted.mjs
cp fee-previous.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
```

```
# tests 6
# pass 6
# fail 0
```

```js
// protection.mjs — measures how many behavior changes the test suite catches
import { readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';

const MUTATION = [
  ['threshold comparison', 'fee.mjs', 'count > DISCOUNT_THRESHOLD', 'count >= DISCOUNT_THRESHOLD'],
  ['minimum fee', 'fee.mjs', 'MINIMUM_CENTS = 6990', 'MINIMUM_CENTS = 6890'],
  ['discount rate', 'fee.mjs', 'DISCOUNT_RATE = 0.88', 'DISCOUNT_RATE = 0.89'],
  ['volume rounding', 'fee.mjs', 'Math.ceil(', 'Math.floor('],
  ['zone factor', 'tariff.mjs', 'B2: 1.25', 'B2: 1.3'],
  ['tier boundary', 'tariff.mjs', 'grams <= t.maxGrams', 'grams < t.maxGrams'],
];

function isRed() {
  try {
    execFileSync('node', ['--test', 'fee.test.mjs'], { stdio: 'ignore' });
    return false;
  } catch {
    return true;
  }
}

let caught = 0;
for (const [name, file, from, to] of MUTATION) {
  const original = readFileSync(file, 'utf8');
  writeFileSync(file, original.replace(from, to));
  const red = isRed();
  writeFileSync(file, original);
  if (red) caught += 1;
  console.log(`${name}: ${red ? 'caught' : 'escaped'}`);
}
console.log(`caught: ${caught}/${MUTATION.length}`);
```

```
threshold comparison: escaped
minimum fee: caught
discount rate: caught
volume rounding: escaped
zone factor: caught
tier boundary: caught
caught: 4/6
```

Four of the six corruptions were caught. The two that escaped do not escape by
accident. The threshold comparison escaped because the period tests try 60 and 10
shipments, neither one being the threshold itself. Volume rounding escaped because
both test boxes' volumetric weight comes out as a whole number — 3,000 and 12,000
grams — so rounding up and rounding down give the same result. Both escapes follow the
same pattern: the boundary was never tried.

Closing the gap goes through the door the previous lesson's extractions opened.
Extraction had increased the number of testable units; boundary behavior can now be
pinned down from those units themselves, not from the outer edge.

```sh
cat >> fee.test.mjs <<'SON'

import { effectiveWeight, discountRate } from './fee.mjs';

test('volumetric weight rounds up', () => {
  assert.equal(effectiveWeight({ width: 11, length: 11, height: 11, grams: 100 }), 444);
});

test('a shipment count at the threshold produces no discount', () => {
  assert.equal(discountRate(50), 1);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
node protection.mjs
```

```
# tests 8
# pass 8
# fail 0
threshold comparison: caught
minimum fee: caught
discount rate: caught
volume rounding: caught
zone factor: caught
tier boundary: caught
caught: 6/6
```

Two tests closed the two escaped corruptions. What was gained is not the test count
but a ratio: catching power rose from four in six to six in six. This ratio is not a
certificate of assurance, it is a measurement; a mutation type not on the list can
still escape. The measure's value lies in turning "are my tests enough" into a
question that can be counted.

#### Same Step, Strengthened Suite

The shifted extraction is now brought back.

```sh
cp fee-extracted.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs \
  | grep -E '^ *(ok|not ok|expected:|actual:|# (tests|pass|fail))'
```

```
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
ok 7 - volumetric weight rounds up
not ok 8 - a shipment count at the threshold produces no discount
  expected: 1
  actual: 0.88
# tests 8
# pass 7
# fail 1
```

Same change, same code, a different result: the eighth test is red, and the expected
and actual values are read straight from the run. The threshold test shows, with a
single call, the same deviation the 360-period scan had to search for. The fix is to
reverse the shifted comparison.

```js
// fee.mjs — same extraction, with the comparison direction preserved
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountApplies(count) {
  return count > DISCOUNT_THRESHOLD;
}

export function discountRate(count) {
  return discountApplies(count) ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
```

```sh
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
node diff.mjs
```

```
# tests 8
# pass 8
# fail 0
periods tried: 360
results changed: 0
first example: none
```

The run is green and the scan finds zero deviations. The two measures gain their
meaning together: the scan says this change preserved behavior across the inputs
tried, and the mutation measure says the suite catches all six of its six mutation
types. Behavior preservation is not an intention — it is a guarantee expressed in two
numbers.

#### Summary

- A refactoring step's claim to preserve behavior is read not from a green run but
  from two versions giving the same result on the same inputs.
- One shifted comparison direction left six of six tests green; a scan found
  deviations in three of 360 periods, all three landing at exactly the threshold.
- A test suite's catching power can be measured: of six mutations applied to the code,
  four were caught, two escaped, and both escapes came from an untested boundary.
- Boundary behavior was pinned down with two tests, written over the units extraction
  had produced, and catching power rose from four in six to six in six.
- In the strengthened suite, the same change turned the eighth test red; the expected
  and actual values were read directly from the run.
- The mutation measure is not a certificate of sufficiency; it is a tool that turns
  "are my tests enough" into a number.

#### Course Wrap-Up

The Clean Code course covered the internal structure of code from three angles across
sixteen lessons.

**Naming and Structure** turned readability into a measurable quantity: names that
state intent, naming that replaces comments, small functions staying at a single
level of abstraction, the diff cost of indentation and formatting consistency,
cyclomatic complexity measured by branch count, and the choice among conditionals,
lookup tables, and polymorphism.

**Interface and Data Decisions** carried the decision into the signature: never
passing a null value, splitting boolean flag parameters into two functions, choosing
the narrowest visibility, grouping code by its reason to change — that is, by actor —
and doing the same task the same way throughout the project.

**Simplicity Principles** counted the cost of decisions over time: the same
information staying in one place, incidental similarity turning into a wrong
abstraction, a design evolving in small steps, the cost of a generalization written
for a requirement that has not arrived, the extract–move–rename techniques, and
measuring behavior preservation.

What the sixteen lessons had in common was tying every claim to a number: files
touched, branches, uncovered paths, diverging boundaries, mutations caught. There is
one more thing they had in common, left unnamed until the end of this lesson: every
decision was made within **a single programming style**. Functions, constants,
modules, and object dictionaries were used without ever being discussed. How the code
was written was questioned; the model it was thought in was not.

The fee library was modeled as a set of functions and data tables. The same domain
could just as well have been modeled as objects holding behavior and state together,
or as a computation of chained transformations in which state never changes. Which of
these three models gets chosen is not a clean code question — it is a **paradigm**
question. The next course, Programming Paradigms, takes this up: the modeling
difference between object-oriented and functional approaches, the situations where
composition gets chosen over inheritance, and the same problem modeled and compared
across more than one paradigm.

