---
title: 'Keep It Simple and Refactor Often'
source: 'https://academia.sh/en/courses/clean-code/keep-it-simple-and-refactor-often'
course: 'Clean Code'
language: en
updated: '2026-08-23T07:01:12+00:00'
license: 'CC BY-SA 4.0'
---

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