Lesson 13 / 16
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.
Contents
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.
- The base fee is read from the weight tier.
- The base fee is multiplied by the shipping zone’s factor.
- The result cannot fall below the minimum fee.
- 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.
// 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.
// 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.
// 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); });
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.
// fee.mjs — step 1: weight tier only import { tier } from './tariff.mjs'; export function shipmentFee(s) { return tier(s.grams).cents; }
: > 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.
// 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]); }
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.
// 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]); }
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.
// 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); }
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.
// 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); }
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.
// 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); }
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.