Lesson 12 / 16
Do Not 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.
Contents
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.
// 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.
// 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.
// 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.
// 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; }
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.
// 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); }
// fee.mjs — reads from the tariff import { tier } from './tariff.mjs'; export function weightFee(grams) { return tier(grams).cents; }
// summary.mjs — reads from the same tariff import { tier } from './tariff.mjs'; export function tierName(grams) { return tier(grams).name; }
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.
// 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.
// 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.
// 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.
// 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'); });
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.
// 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.
// 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'); });
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.
// 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`; }
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.