---
title: 'Refactoring Techniques'
source: 'https://academia.sh/en/courses/clean-code/refactoring-techniques'
course: 'Clean Code'
language: en
updated: '2026-08-23T07:01:12+00:00'
license: 'CC BY-SA 4.0'
---

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