The Refactoring Loop
Working code, a safety net, one small transformation, verify, repeat. The discipline is entirely in the size of the step and in never being more than one step from working.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I restructure code that is in production without ever being in a state I cannot ship or abandon?
A 900-line order handler needs to grow a second fulfilment path. Everyone agrees it must be broken up first. The team ships every Tuesday and cannot stop shipping for two weeks.
Understand the whole handler, design the target structure, then make the change in one careful pass. Doing it in small pieces means touching the same code repeatedly, which is wasteful.
A single pass leaves a long window where nothing works. The two-hours-a-day constraint turns that window into a week, and during that week every other change conflicts with it.
- A single pass leaves a long window where nothing works. The two-hours-a-day constraint turns that window into a week, and during that week every other change conflicts with it.
- Verification is deferred to the end, so when the suite goes red you have twenty transformations to search through. Small steps make the answer trivially "the one I just made".
- The target design is a guess made before the work. Most restructures discover something in step four that changes the plan, and a one-pass approach has already committed by then.
- It cannot be abandoned. The moment the priority changes — and on a two-hour-a-day budget it will — the choice is finish or revert a week of work.
- And it is not actually cheaper. Repeated small transformations feel wasteful and are mostly mechanical; the one-pass version spends its time in debugging, which is the expensive kind (Review Size).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Main must stay deployable. There is no branch long enough to hold the whole restructure.
- The handler has partial test coverage: the happy path and two error cases out of maybe fifteen.
- Other people are editing nearby files every day, so a long-lived branch will conflict badly.
- The engineer doing this has about two hours a day for it, in fragments.
- After every step, the system builds, the suite is green, and the code is deployable.
- At no point is the old structure removed before the new one works.
- Any step can be the last one. Stopping mid-restructure must leave the codebase no worse than it started.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The safety net owns detecting behaviour change. Whether it is the existing suite, characterization tests, or a diff of outputs from both paths, something must be able to say "this moved" (Characterization Tests).
- Each transformation owns exactly one structural move, small enough to describe in five words.
- The verification step owns being cheap. If running the tests takes eight minutes, the loop breaks, because nobody runs them every step.
- The engineer owns stopping at a clean point rather than at a convenient one.
- The step boundary is where the code works. Not where a logical unit is finished — where it builds, passes and could ship.
- The net's boundary is behaviour, at the outermost interface you can reach cheaply. Testing the handler through its HTTP entry point protects everything inside it while constraining nothing you are about to move (What a Unit Is).
- The loop's boundary is the named goal. It ends when the change you were preparing for is cheap, not when the code is as good as it could be.
The loop
Five steps, and the whole discipline is in how small step three is. Everything else exists to make step three recoverable.
The failure column matters more than the step descriptions, because every one of these steps has a comfortable-feeling degradation that removes the property the loop was providing.
- 11. Start from working code
Build passes, suite is green, and you know what the suite actually covers.
fails by Starting from a red or flaky suite, which means every later failure has two possible causes and neither can be ruled out (Flaky Tests in DevOps).
- 22. Build the safety net
Enough detection to notice a behaviour change in the code you are about to move — existing tests, characterization tests, or an output diff against real inputs.
fails by Assuming coverage exists. The suite is green on code paths nobody wrote a test for, and green means nothing there (Characterization Tests).
- 33. One small transformation
Extract, inline, move, rename — one move, describable in five words, preferably performed by the tooling.
fails by "While I am here." The step grows, and the loop silently becomes a one-pass rewrite with no verification points inside it.
- 44. Verify immediately
Build and run the relevant tests. Seconds, not minutes — the value is in the gap between change and feedback.
fails by Batching verification across several steps, which restores exactly the debugging problem the loop exists to remove.
- 55. Commit, then repeat
Every green step is a commit. Stopping is now free and bisecting is now useful.
fails by Working uncommitted for an hour, so a bad step costs the whole hour and abandonment costs everything.
The loop terminates on a named goal — "until adding a second fulfilment path costs a class and a line of wiring" — and not on a feeling. Without the goal, step five always has another turn available, and the preparation becomes the project.
What one step looks like
A transformation is smaller than most people expect. Below is one turn of the loop on the order handler: not "break up the handler", just "give this block a name and move it behind a call".
Nothing about the behaviour changed, the tests were not touched, and the code is deployable. That is the whole property, and it is what lets the next nine steps be attempted cheaply.
1// before — inside handleOrder(), line 310 of 9002if (order.items.some(i => i.kind === 'digital')) {3 if (!order.customer.email) throw new Error('no email')4 await mailer.send(order.customer.email, licenceFor(order))5 await audit.record('licence.sent', order.id)6}7 8// after — same file, same behaviour, one call9if (hasDigitalItems(order)) {10 await deliverLicences(order, mailer, audit)11}12 13// tests: unchanged. build: green. commit.Two extractions and nothing else — no error type introduced, no dependency injected, no null check tightened, even though all three are tempting and all three will be separate steps later. deliverLicences still takes its collaborators as arguments rather than being a proper module, because that is step seven. The discipline is visible in what was *not* done.
Choosing a step size
Step size is the only real decision in the loop, and it is a function of two things: how well you understand the code, and how much detection you have. Both are properties of the situation rather than of the engineer.
The table is a way of making that judgement explicit rather than habitual — experienced engineers move up and down these rows several times inside a single afternoon.
| Situation | Safe step size | What to verify after each step | The characteristic mistake |
|---|---|---|---|
| Familiar code, strong suite, typed language | A whole extraction or a signature change | Build plus the module's tests | Taking a step so large that a failure has several possible causes |
| Familiar code, thin suite | One mechanical move at a time | Build, tests, and a manual check of the path you touched | Trusting the green suite for a path it never covered |
| Unfamiliar code, decent suite | One move, tooling-performed where possible | Full suite, every step | Reading for an hour instead of making a small safe move and observing what breaks |
| Unfamiliar code, no suite | Provably safe moves only, net first | Characterization output diffed against a recorded baseline | Starting at all before building the net (Refactoring Without Tests) |
| Dynamic language, reflection or string-keyed lookup | Smaller than feels necessary | Full suite plus a grep for the old name in strings, config and data | Trusting a rename that the compiler did not check (Rename) |
| Code others are editing right now | Whatever fits in a same-day commit | Build, tests, and a merge with main before pushing | A three-day branch that conflicts with everything (Long-Lived Branches in DevOps) |
How to build it
Most important first.
- Start from working code and a green suite. If it is not green now, the first task is to make it green or to quarantine the failure — you cannot use a red suite as a net (Flaky Tests in DevOps).
- Build the net before touching anything. Where coverage is thin, characterize the current behaviour, including behaviour you believe is wrong (Characterization Tests).
- Make one transformation. Extract a function, introduce a parameter, move a method, inline a variable — one, and preferably one your tooling can do mechanically (Extract Function).
- Verify immediately. Build and run. The value of the loop comes from the gap between transformation and feedback being seconds rather than hours.
- Commit. Every green step is a commit, which is what makes abandonment free and bisecting useful.
- Repeat until the change you were preparing for is cheap, then make the change — as a separate commit that says it changes behaviour (What Refactoring Actually Is).
- When a step turns out to be too big, revert rather than push through. A reverted step costs ten minutes; a half-finished one costs the invariant.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under one-pass: the next change costs the restructure plus the feature, entangled, plus whatever debugging the entanglement produces. If it has to be abandoned, the cost is the whole thing.
- Under the loop: the next change costs the same total work spread over deployable increments, plus a small overhead per step for running and committing. What it buys is that the cost of *stopping* is zero at all times.
- The named goal is what bounds the cost. "Until adding a second fulfilment path is a new class and a line of wiring" is a stopping condition; "until it is clean" is not.
- What does not get cheaper: this is more total keystrokes and more commits than a single careful pass, and on a small, well-tested, well-understood piece of code the overhead is real and returns little (When Design Does Not Pay).
- Small steps are slower in keystrokes and produce a history some reviewers find noisy. On a team that squashes everything on merge, part of the benefit is thrown away at the last moment.
- Building the net first can cost more than the refactor, and occasionally the honest answer is that the refactor is not worth the net.
- Committing at every green step means committing intermediate structures that are worse than both the start and the end. Anyone reading the history mid-restructure sees code in an awkward middle state.
What can go wrong
- Steps grow. The first five are small, the sixth is "while I am here", and the loop has quietly become a one-pass rewrite.
- The net is checked once at the start and never again, so a step that broke something is discovered eight steps later.
- Green but wrong: the suite passes because the case was never covered. This is the failure the net itself has, and characterization tests written from real production inputs are the mitigation (The Legacy Change Loop).
- The loop never terminates. Without a named goal, "one more improvement" is always available, and the restructure becomes the work rather than the preparation for it (Changeability Is the Goal).
- The mitigation fails: characterization tests pin the behaviour so exactly that a legitimate later change has to rewrite forty of them, and someone deletes the lot.
- The loop depends on a fast build and a fast test run. Everything about this discipline degrades in proportion to feedback latency (CI Is a Feedback System in DevOps).
- It depends on version control being used at step granularity, which some teams resist because it produces "noisy" history — the noise is the audit trail.
- It depends on tests that assert behaviour rather than structure. A suite full of interaction assertions turns every step red for reasons that are not about correctness (Mocking).
- "Small steps mean slow progress." They mean short recovery. Total elapsed time is usually lower because none of it is spent debugging a large simultaneous change.
- "I need full coverage before refactoring." You need enough detection for the behaviour you are about to move. Full coverage is a different and much larger project, and waiting for it is how nothing gets restructured (Refactoring Without Tests).
- "The loop is for legacy code." It is for any code you cannot hold entirely in your head, which after a few months includes code you wrote yourself.
- "Revert means failure." Revert is the cheapest available outcome and it is what makes large steps affordable to attempt. A team that never reverts is taking steps that are too small or pushing through ones that are too big.
Testing it, and how it ages
- Before starting: run the suite and know what it covers. An unexamined suite is a net with unknown holes, and the holes are exactly where the risky code is.
- During: run after every transformation. If that is too slow, run the subset that covers the code you are touching and the full suite before each commit.
- For the parts with no coverage, generate characterization tests from real inputs — recorded requests, a sample of production rows — because invented inputs test the behaviour you imagined (Characterization Tests).
- After: the suite should be unchanged. A refactoring loop that ends with edited assertions changed behaviour somewhere (What Refactoring Actually Is).
- The loop scales down well and up badly. For a module it is exactly right; for a restructure that crosses service boundaries you need the same discipline with different mechanics — parallel paths, flags, and a contraction phase (The Strangler Pattern).
- As coverage improves, steps can get larger safely, and experienced engineers do take larger steps. The skill is knowing when to shrink them again, which is when the code stops being familiar.
- What ends the loop: the named goal is reached, or the discovery that no behaviour-preserving path reaches the target — which is the honest trigger for the rewrite conversation (Refactor or Rewrite).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe loop is about feedback latency and recoverability, so it holds in any language; what changes is how much of each transformation the tooling performs for you and therefore how large a safe step is.
- SCALE-SPECIFICWithin one module the loop is exactly as described. Across services the same discipline needs different mechanics — you cannot leave a distributed system in an intermediate state with a local commit, so the equivalent of a step becomes a deployable parallel path behind a flag, and the loop slows down by orders of magnitude (Incremental Migration).
- CONTESTEDThe strongest opposing view is that very small steps are a beginner's scaffold: an engineer who understands the code deeply makes a large, coherent transformation in one pass, more correctly and far faster, and the step-by-step discipline imposes real overhead on people who do not need it. That is true, and it has a specific boundary — it holds where the engineer genuinely understands the code, which is precisely the case where refactoring is least necessary. The discipline earns its keep in unfamiliar code, which is most code.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the loop is only as good as the net, and how much confidence a given suite actually provides is that domain's subject.