open atlas
↑ Back to track
Code patterns & craft CP · 10 · 03

Core moves under green

The catalog of small refactorings done under green tests, one at a time — extract, inline, rename, move, change declaration, encapsulate variable. The rhythm is tiny step, run tests, commit, repeat; never bundle a refactor with a behavior change.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You have a 200-line function you need to reshape: rename a misleading variable, pull two blocks into named helpers, and move a function to where it belongs. The instinct is to do it all in one heroic edit, run the tests at the end, and pray. Then a test goes red and you have no idea which of the eleven changes broke it — so you spend an hour bisecting your own diff.

The senior move is the opposite. Refactoring is not a creative act; it is a catalog of named, behavior-preserving transformations, each small enough to verify in seconds. You apply one, run the tests, commit, and repeat. The test suite becomes a ratchet: it can only click forward. This lesson is the catalog and the rhythm that makes it safe.

Goal

After this lesson you can name and apply the core moves — extract/inline function, extract/inline variable, rename, move, change function declaration, encapsulate variable — each as a behavior-preserving step; you can run the tiny-step / run-tests / commit loop so every refactor is independently revertible; and you can explain why bundling a refactor with a behavior change in one commit destroys your ability to bisect a failure.

1

Refactoring is behavior-preserving by definition — if observable behavior changed, it was not a refactor. The word has a precise meaning: a change to the internal structure of code that does not alter its external behavior. Same inputs, same outputs, same side effects, same errors. That is not a nice-to-have property you try to keep; it is the definition. The instant a “refactor” also fixes a bug or adds a feature, it has stopped being a refactor and become two changes wearing one name.

This precision is what lets the test suite be your safety net. If behavior is unchanged, a green suite before the edit must stay green after it. A red test therefore means exactly one thing: you broke the invariant — your step was not behavior-preserving. The narrower the step, the more precisely red points at the mistake.

2

The core catalog is a handful of small, named, reversible moves. You do not invent refactorings; you recognize the situation and apply the matching move. The everyday set:

// Extract Function: a named block becomes a function
// before
const tax = order.lines.reduce((s, l) => s + l.qty * l.price, 0) * 0.2;
// after
const tax = subtotal(order.lines) * 0.2;
function subtotal(lines: Line[]) {
  return lines.reduce((s, l) => s + l.qty * l.price, 0);
}

// Extract Variable: name a sub-expression
// before
if (order.total > 100 && order.lines.length > 3) { /* ... */ }
// after
const isBulkOrder = order.total > 100 && order.lines.length > 3;
if (isBulkOrder) { /* ... */ }

Inline Function and Inline Variable are the exact inverses — used when a name no longer earns its keep. Rename changes a name everywhere it binds. Move relocates a function/field to the module it actually belongs to. Each is reversible, which is what makes the whole process low-risk: any step you regret, you undo with its inverse, no detective work required.

3

Change Function Declaration and Encapsulate Variable are the two moves that touch a boundary — do them in micro-steps. Renaming a function, adding/removing a parameter, or reordering arguments (Change Function Declaration) ripples to every caller. The amateur version is one giant find-and-replace. The disciplined version, when callers are many, is the migration variant:

// 1. add the new shape alongside the old, old delegates to new
function chargeOld(cents: number) { return charge({ cents }); }
function charge(opts: { cents: number }) { /* real body */ }
// 2. migrate callers to `charge` one at a time, tests green after each
// 3. delete `chargeOld` when no caller remains

Encapsulate Variable does the same for data: wrap a bare exported field in a getter/setter so future access is funneled through one point, then migrate readers.

// before: bare mutable export — every reader is a caller you can't see
export let config = { region: "eu" };
// after: access funneled through functions you can later guard or memoize
let _config = { region: "eu" };
export const getConfig = () => _config;
export const setConfig = (c: Config) => { _config = c; };

Each numbered sub-step keeps the suite green, so you can stop and ship at any line.

4

The rhythm is a loop, not a sprint: tiny step → run tests → commit → repeat. This is the whole discipline. The commit is not bureaucracy; it is the ratchet tooth. After each green commit the previous good state is captured, so the cost of any mistake is bounded to one step — git reset --hard or revert that single commit and you are back to known-good. Skip the commits and one bad step contaminates an hour of work you now can’t cleanly separate.

loop:
  make ONE catalog move        # extract, rename, inline, move…
  run the test suite           # must be green (it was green before)
  green? -> git commit         # "refactor: extract subtotal()"  ← ratchet clicks
  red?   -> revert THIS step   # the step was not behavior-preserving; undo, retry smaller

Two failure modes this kills: the big-bang refactor (hours of edits, then a red suite with no idea which edit did it), and the irreversible refactor (you’ve changed so much you can’t get back to working code). Small reversible steps make both impossible — the suite can only ratchet forward.

Worked example

Reshape a function in three commits, never leaving green. Start with a tangled discount calculator:

function priceFor(order: Order): number {
  let p = order.subtotal;
  if (order.subtotal > 100 && order.coupon) p = p - p * 0.1; // bulk + coupon
  return p + p * 0.2; // tax
}

Step 1 — Extract Variable (commit refactor: name the bulk-coupon condition):

function priceFor(order: Order): number {
  let p = order.subtotal;
  const qualifiesForDiscount = order.subtotal > 100 && order.coupon;
  if (qualifiesForDiscount) p = p - p * 0.1;
  return p + p * 0.2;
}

Run tests — green. Commit. Step 2 — Extract Function for the discount (commit refactor: extract applyDiscount):

function priceFor(order: Order): number {
  const discounted = applyDiscount(order);
  return discounted + discounted * 0.2;
}
function applyDiscount(order: Order): number {
  const qualifies = order.subtotal > 100 && order.coupon;
  return qualifies ? order.subtotal * 0.9 : order.subtotal;
}

Green. Commit. Step 3 — Extract Function for tax (commit refactor: extract withTax):

function priceFor(order: Order): number {
  return withTax(applyDiscount(order));
}
const withTax = (amount: number) => amount * 1.2;

Green. Commit. The function went from one tangled body to three named, testable pieces. Crucially, the 20% tax and 10% discount never changed — behavior is identical at every step. If, mid-way, product also said “tax is now 21%”, that is a separate commit on top, never folded into Step 2 or 3 — because if a test then broke, you must be able to tell whether the extraction or the rate change did it.

Why this works

Why insist on a commit between steps, not just “run the tests”? Because the green suite proves the current state is good, but only the commit captures that state cheaply. Without the commit, “go back to the last working version” means manually un-editing — exactly the bisecting you’re trying to avoid. With it, recovery is one git revert <sha>. Each commit is also a reviewable atom: a reviewer reading refactor: extract applyDiscount knows by the message that behavior is unchanged and can skim, whereas a 400-line diff titled “cleanup + bugfix” forces them to re-verify the entire thing. Small commits make both rollback and review cheap; that is the payoff for the typing overhead.

Common mistake

The cardinal sin is the mixed commit: a refactor and a behavior change in the same commit. You extract a function and fix an off-by-one and rename a field, all in one shot. It works, you push it. Two weeks later a regression appears and git bisect lands on that commit — now the commit contains three logically independent changes and bisect can’t tell you which one is the culprit; you’re back to reading the whole diff by hand. The behavior-preserving guarantee is also gone: you can no longer say “this commit can’t have caused a behavior bug,” because it absolutely could. Rule: a commit is either a refactor (structure changes, behavior fixed) or a behavior change (structure fixed, behavior changes) — never both. When you catch yourself wanting both, stash the behavior change, finish the refactor under green, commit, then make the behavior change as its own commit.

Check yourself
Quiz

While extracting a function, you notice a small bug in the same block and fix it in the same commit. The tests still pass. Why is a senior reviewer right to push back on this?

Recap

Refactoring is behavior-preserving by definition — change observable behavior and it stopped being a refactor. You don’t improvise; you apply named moves from a small catalog: extract/inline function, extract/inline variable, rename, move, change function declaration, encapsulate variable — each reversible by its inverse. The discipline is the rhythm: one tiny move, run the suite, commit, repeat — the green suite plus the commit form a ratchet that can only click forward, bounding the cost of any mistake to a single step you can revert. The failure mode to fear is not a wrong move but the mixed commit — a refactor fused with a behavior change — which annihilates git bisect and the behavior-preserving guarantee at once. Keep refactor and behavior changes in separate commits, always, and the safety net actually catches you.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.