- Published on
The Complete Guide to Refactoring: Changing Structure While Preserving Behaviour
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Start from the definition — what refactoring is not
- 2. Do not start without a safety net — characterization tests
- 3. Introducing seams — touching code you cannot touch
- 4. The discipline of small steps — stay green at all times
- 5. Changing interfaces with parallel change
- 6. Cutting a large change into reversible pieces
- 7. Should refactoring be mixed with feature work?
- 8. Automated refactoring tools and large-scale change
- 9. Deciding when to stop
- Quiz: Check your understanding
- Wrapping up
- References
- Further reading
Introduction
This blog already has The Economics of Refactoring: When Does It Pay Off?. That post starts from change frequency and modification cost and computes when refactoring becomes profitable. It is an article about the investment decision.
This post is the other half. Once you have decided to do it, how do you do it safely? How do you acquire the evidence that behaviour was preserved, how do you make the first change to code you cannot touch, how do you stay deployable while changing an interface, and along which axes do you cut a large change so that each piece is a reversible unit? Read in order, the two posts form one decision and one execution.
1. Start from the definition — what refactoring is not
1-1. The noun and the verb
Martin Fowler defines refactoring in two parts of speech. As a noun, a refactoring is "a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior." As a verb, to refactor is "to restructure software by applying a series of refactorings without changing its observable behavior."
Both definitions share exactly one condition: preservation of observable behaviour. That condition is what separates refactoring from restructuring and rewriting.
1-2. The team has to draw the boundary of "observable behaviour"
The definition is clear; the boundary is not. Are the following observable behaviour?
- Response time: users observe it. A refactoring that made things twice as slow cannot really claim to have preserved behaviour.
- Log format: barely visible to humans, but a contract to any alerting rule that parses logs.
- Error message wording and the actual order of an unsorted list: not in the specification, but consumers may be branching on the string or depending on the order.
So in practice the first step is writing down, in one line, what this refactoring will preserve. "Public HTTP response bodies and status codes are preserved; log format is not" saves you an argument with the reviewer.
1-3. Things that are not refactoring
- Bug fixes: they change behaviour by definition. Mixed into a refactoring commit, the distinction vanishes from the diff.
- Performance optimisation: it deliberately changes one observable characteristic. Even when it comes with structural improvement, treat it as separate work.
- Rewrites: building it again from scratch with no guarantee of behaviour preservation.
- Large dependency upgrades: they absorb the library's own behaviour changes, so they are not pure refactorings.
The distinction matters not as a matter of taste but because the review method and the rollback risk differ. A reviewer reading a refactoring PR is asking "did the structure improve?". A behaviour change hidden inside it gets approved without anyone reviewing that part.
Reduced to one working rule: a commit is either a structural change or a behavioural change, never both. The old advice about not wearing two hats at once is this same idea.
2. Do not start without a safety net — characterization tests
2-1. Behaviour preservation is an observation, not an assertion
"I didn't change behaviour" has to be a verifiable sentence, which requires a way to compare behaviour before and after. The name used for a long time in the legacy-code literature is the characterization test. The term is widely known from Michael Feathers' work on legacy code, and the idea is simple.
A characterization test pins down not what the code should do but what it does right now. That means it pins the bugs down too. This is intent, not accident: if you fix a bug mid-refactoring, a failing test can no longer tell you whether it was a refactoring mistake or the bug fix.
# Example — the procedure for building characterization tests
# 1) Collect inputs: production logs, sample requests, boundary values.
# 2) Run them through the current implementation and record the outputs.
# Do not hand-write the expected values.
# 3) Freeze the recorded outputs as golden files.
# 4) Freeze results that look wrong too, marking them only in a comment.
def test_pricing_characterization(golden):
for case in load_cases("fixtures/pricing_inputs.jsonl"):
actual = calculate_price(**case)
# This value is not the 'correct' value; it is the 'current' value.
golden.assert_match(case["id"], actual)
# Known anomaly: a discount rate above 100% produces a negative price.
# Preserve this behaviour during refactoring. Fix it in a separate commit.
2-2. A coverage number is not a safety net
90% overall line coverage does not mean the file you are about to touch is tested. What you need is not a global metric but tests that actually execute the code you are changing. Measure coverage for the target files only before you start, and fill in characterization tests starting from the branches that never execute.
The other trap is that a test passing and a test verifying something are different facts. Break the code deliberately once and confirm the test fails.
2-3. Which layer holds the safety net — this is contested
Whether the refactoring safety net belongs in unit tests or in coarser boundary tests has no agreed answer. The problem is that unit tests coupled to internal structure die together with the refactoring. Split a class and the tests written against that class must be rewritten too — leaving you working without a net.
Kent C. Dodds, explaining the testing trophy, puts it as: the more your tests resemble the way your software is used, the more confidence they can give you. From that angle, placing the refactoring net at the integration layer is natural.
Underneath the argument, though, is a terminology problem. In his practical test pyramid article Fowler writes that if you ask three different people what "unit" means you will probably receive four different, slightly nuanced answers, and Dodds likewise acknowledges that roughly twenty-four definitions of unit test exist, quoting Justin Searls to the effect that debating what percentage of which type of tests to write is itself a distraction.
- A unit-test net: fast. Keeping steps small requires a feedback loop measured in seconds. But if it is coupled to structure it collapses along with the thing you are refactoring.
- A boundary/integration net: survives internal restructuring. But it is slow. Fowler writes that end-to-end tests are "notoriously flaky and often fail for unexpected and unforeseeable reasons", and that they "require a lot of maintenance and run pretty slowly".
The practical compromise usually looks like this: put the net at the outer boundary of the structure you are going to change. If you plan to rearrange three classes, put the tests at the module boundary that wraps those three. That rule is not something the sources above tell you; it is what this article proposes.
2-4. Is behaviour preservation achievable without tests?
Also contested. The "yes" camp argues that with a static type system and only verified IDE refactorings the process is mechanically safe. The "no" camp argues that dynamic references and side effects survive in every language, and that believing in "safe transformations" is the most dangerous part. Three axes: the strength of the language's static checking, whether the transformation is purely mechanical or includes judgement, and the density of side effects in the code.
3. Introducing seams — touching code you cannot touch
3-1. Chicken and egg
The most common deadlock in legacy code goes like this. To write a test you must break a dependency; to break the dependency you must change the code; to change the code you need a test. The concept that breaks the cycle is the seam: a place where you can swap behaviour without editing the code at that point. This term, too, is widely known from Michael Feathers' legacy-code work.
There is only one way out: make only the lowest-risk change first. A change that introduces a seam should do almost nothing on its own.
// Example — introducing a seam in three steps, ordered by risk
// Step 0: untouchable. The clock and the network are nailed inside the function.
async function expireSessions() {
const now = new Date()
const rows = await db.query('SELECT * FROM sessions')
return rows.filter((r) => r.expires_at < now)
}
// Step 1: add parameters. With defaults, every call site keeps working unchanged.
async function expireSessions({ now = new Date(), query = db.query } = {}) {
const rows = await query('SELECT * FROM sessions')
return rows.filter((r) => r.expires_at < now)
}
// Step 2: now it is testable. The real refactoring starts here.
// expireSessions({ now: new Date('2026-01-01'), query: fakeQuery })
3-2. Three sources of nondeterminism are usually the first seam
When making legacy code testable, the first things you hit are usually the clock, randomness and the network. Make those three injectable and everything afterwards gets dramatically easier. The file system and environment variables come next.
3-3. Kinds of seams and their cost
- Parameter seam: add an argument with a default. Cheapest and safest.
- Constructor seam: move the dependency into the constructor. With many call sites you need parallel change.
- Subclass seam: extract a method and override it in a test subclass. A fast stopgap.
- Module seam: substitute the implementation at load time. Powerful, but isolation between tests breaks easily.
- Process seam: intercept outside the process with a stub server. Most realistic, and slowest.
3-4. What you must not call a seam
Putting a branch that asks whether we are in a test into production code is not a seam. A condition like if (isTest) makes the tested path different from the path that actually runs, which destroys the whole point of the net. A seam makes the same code run with different collaborators, not different code run.
4. The discipline of small steps — stay green at all times
4-1. What the discipline actually is
The discipline of small steps is not "commit often". It is staying deployable no matter when you stop. Satisfy that condition and the cost of abandoning a refactoring approaches zero — and when the abandon cost is zero, the threshold for starting drops too.
[The refactoring loop — one lap should not exceed a few minutes]
1. Confirm green run the tests and check you are green right now
2. One change one rename, one extraction, one move. Never two at once
3. Test if it goes red, revert immediately. Do not try to fix it
4. Commit one line on what changed and why
5. Repeat
[Stop rules]
- If you have been red for more than 10 minutes, revert and restart with smaller steps
- If reverting costs more than redoing, the step was already too big
4-2. How to size a step
There is one practical test of whether a step is right-sized: could you throw it away and redo it? If throwing it away feels wasteful, the step is already too big. The classic beginner failure is spending 30 minutes red while saying "almost there" — which is precisely the point of maximum revert cost.
4-3. Feedback loop speed determines the discipline
If tests take 15 minutes, nobody runs them per step. Changes then clump together, and when something fails you cannot narrow down the cause. In other words, slow tests demolish the entire refactoring procedure. Before starting, secure a way to run only the target scope quickly, and run the full suite after commits.
4-4. The commit history is a safety net too
Never put mechanical changes and judgement changes in the same commit. When 3,000 lines of renaming sit next to 8 lines of logic change, the reviewer will not find the 8 lines. Only by separating commits can you say "just read this one".
5. Changing interfaces with parallel change
5-1. Expand, migrate, contract
The standard way to change an interface while staying green is Martin Fowler's Parallel Change. Fowler describes the expand phase as augmenting the interface to support both the old and the new versions, and the migrate phase as updating all clients using the old version to the new version, which can be done incrementally; once all usages have been migrated you perform the contract phase and remove the old version. The pattern is attributed to Joshua Kerievsky.
// Example — changing a function signature via parallel change
// expand: add the new shape while keeping the old one intact.
export function createOrder(userId, items, options) {
return createOrderV2({ userId, items, ...options })
}
export function createOrderV2(input) {
/* new implementation */
}
// migrate: move call sites one at a time. Each move is its own commit.
// Leave a signal on the old function so usage can be detected.
export function createOrder(userId, items, options) {
logger.warn('createOrder is deprecated', { caller: new Error().stack })
return createOrderV2({ userId, items, ...options })
}
// contract: remove it once the logs confirm the call count has reached zero.
5-2. The deprecation signal has to live in the code
A deprecation announced only in documentation is read by nobody. The signals that actually work are the ones that detect calls or make the build noisy.
- Runtime logs: count calls on the old path. The only way to set the contract date from data.
- Type-level markers and compiler warnings: stop newly written code from using the old path.
- Lint rules: ban new usage, keep existing sites in an exception list, and track the list shrinking.
5-3. Knowing your consumers is the deciding factor
If you know every call site and can fix them all in one commit, parallel change is overkill. You need it when consumers live in another repository, deploy on a different schedule, or are exposed externally. There is one deciding question: "can I see all the code that would break if I changed this right now?" If you cannot, use parallel change.
5-4. Skipping the contract phase is how debt is created
Do expand and migrate but skip contract and two paths live in the codebase forever. Accumulated, that is a large fraction of what people call technical debt. Creating the contract ticket at the moment migration starts is the only prevention that reliably works. The management side is covered in The Complete Guide to Technical Debt.
6. Cutting a large change into reversible pieces
6-1. Four axes to cut along
Axis A. By layer repository layer only → service layer only → controllers only
Axis B. By call site move one call site at a time to the new interface
Axis C. By data direction read path first → write path later (or the reverse)
Axis D. By runtime branch expose the new implementation to part of traffic via a flag
Conditions each piece must satisfy
- It can be deployed independently
- It can be reverted independently
- It is not harmful on its own (it need not add value, but must not subtract any)
6-2. How long branches kill refactorings
Keep a refactoring branch alive for three weeks and two things happen at once: everyone else keeps writing code on top of the old structure, and merge conflicts grow faster than linearly with time. In the end the merge itself becomes a large change with no safety net.
So the bigger the refactoring, the more it must proceed as pieces on the main branch rather than on a branch. If you need system-scale incremental replacement, the structure in The Complete Guide to the Strangler Fig Pattern applies directly.
6-3. Ordering the pieces
- The most informative piece first. If a design assumption is wrong, you want to know early.
- The hardest piece to reverse last. Data format changes usually belong here.
- Pieces that block other people, quickly. Wide renames are the classic example.
- Put a real deployment between pieces. Pieces stacked without deploying equal one big change.
The criteria for judging the reversibility of a deployment unit are the same checklist as in The Complete Guide to Deployment Strategies. In particular, any piece that touches a data format is conditionally reversible on its own, so expand, migrate and contract must be separate deployments.
7. Should refactoring be mixed with feature work?
7-1. This is contested
Whether to track refactoring as its own ticket or fold it into feature work is an old argument.
- Separate ticket camp: reviews get easier, rollback units stay separated, and investment is traceable.
- Fold-in camp: separate tickets always lose the priority contest, and doing it while you already hold the context is far cheaper.
- Boy scout rule camp: leaving each place you touch a little better makes debt shrink naturally.
- Planned refactoring camp: incremental tidying never resolves structural problems, and large changes need agreement.
Three axes: the code ownership model, review turnaround speed, and the distribution of change frequency. Where ownership is clear and review is fast, mixing works well. Where review is slow, mixing makes PRs bigger and reviews slower still — a feedback loop in the wrong direction.
7-2. Review speed determines how much refactoring happens
Google's engineering practices documentation states the causation explicitly: slow reviews discourage code cleanups, refactorings, and further improvements to existing CLs. The same document fixes one business day as the maximum time it should take to respond to a code review request.
On the review standard, the same source says reviewers should favour approving a change once it is in a state where it definitely improves the overall code health of the system being worked on, even if the change is not perfect. It also recommends prefixing non-mandatory polish with "Nit: " so the author may ignore it. Since a taste argument stops a refactoring dead, that convention matters here more than anywhere.
7-3. The practical compromise
- Separate the commits; the PR may still be combined. "This commit is pure refactoring, the next one is the feature" lets the reviewer choose how to read.
- Refactor first, then add the feature. Ship the feature first and the tidy-up commit usually never arrives.
- Set a size ceiling. The bigger a refactoring PR gets, the more reviewers approve without really reading. That tendency is not something the sources above measured; it is this article's rule of thumb, so the team must pick its own ceiling.
- Split mechanical changes into their own PR. Section 8's codemods belong here.
More on the conversation side is in How to Talk in Code Review.
8. Automated refactoring tools and large-scale change
8-1. When IDE refactoring is safe
An IDE's rename or extract-method understands the syntax tree, which makes it far safer than string replacement. But it fails silently in front of references that are not statically traceable.
- Reflection and dynamic dispatch: names built from strings are invisible to the tool.
- Serialised identifiers: class names and event type names stored in a database or queue are data, not code.
- Config files, templates, and consumers in other repositories: all outside the tool's search scope.
That is why a full-repository string search after running an automated refactoring is cheap insurance.
8-2. The codemod procedure
[Running a codemod]
1. Write the transform against the syntax tree (regex replacement also rewrites
comments and string literals)
2. Apply it to a sample of about 20 files and read the result yourself
3. Fix the rule. Repeat 2 and 3 until the results are boring
4. Apply to everything and run the full test suite
5. When requesting review, state what should actually be reviewed
[What to review — not the entire result diff]
- The transform script itself
- The exception list the tool could not handle
- The transformed output of 10 randomly sampled files
- Test results and coverage change
8-3. Splitting a large change can make it more dangerous
Smaller is usually safer, but mechanical transformations are sometimes an exception. Splitting a rename across several PRs lengthens the period in which the code does not compile or two names coexist. The options are to merge the whole thing at once, or to apply section 5's parallel change so the intermediate state is legal. Forcing a split on a change that cannot be split buys you an unstable intermediate state, not safety.
8-4. Compare behaviour, not the result
The final verification of a large change is not reading the diff. Comparing outputs before and after for the same inputs is far stronger. Golden-file comparison, and running a copy of production traffic through both implementations to compare results, are the usual approaches. The latter has the same structure as the shadow deployment technique, so it has the same precondition: side effects must be isolated.
9. Deciding when to stop
9-1. Write the exit condition first
Refactoring is inherently endless, so before starting write the exit condition in one sentence. A good exit condition is expressed as the cost of the next change, not as a structural metric.
- Weak goal: "reduce cyclomatic complexity below 15"
- Strong goal: "adding one more payment method touches at most three files"
A strong goal is verifiable, because actually adding the next feature reveals immediately whether you met it.
9-2. Signals that you should stop
- Files unrelated to the original purpose start appearing in the diff
- Resolving merge conflicts takes longer than the refactoring itself
- You hear twice or more that you are blocking someone else's work
- "Just this last bit" has been said three times
- The net stays red and you cannot tell whether the refactoring is the cause
- You can no longer answer which future change this refactoring was meant to make cheap
9-3. Stopping is also a result
Stopping halfway is not a failure in itself. The failure is walking away without recording the state you stopped in. When you stop, close out the finished part in a deployable state, put the remainder on the debt list, and leave a paragraph on what you learned.
9-4. Measuring the effect
Whether a refactoring actually helped is more honestly checked through change cost than through code metrics. DORA defines change lead time as the amount of time it takes for a change to go from committed to version control to deployed in production, and change fail rate as the ratio of deployments that require immediate intervention following a deployment. If neither improves for work in the refactored area, the structure got prettier but the purpose was not achieved. If the metrics got worse, keep reverting on the table.
Quiz: Check your understanding
Quiz 1: A colleague opens a PR saying "I also fixed a bug I found while refactoring". What do you ask for?
Answer: Ask them to split the bug fix into a separate commit or a separate PR.
Explanation: The definition of refactoring is preservation of observable behaviour, and a bug fix changes behaviour by definition. Mixed into one commit, a failing test can no longer tell you whether it is a refactoring mistake or the intended behaviour change, and a rollback cannot take back only the half you want. It is also why characterization tests pin down even the currently wrong behaviour.
Quiz 2: The class you are refactoring has 40 unit tests, and you plan to split it into three. What safety net do you prepare?
Answer: Build characterization tests first at the outer boundary of those classes — the module level that uses them.
Explanation: Unit tests coupled to the class's internal structure must be rewritten the moment you split the class, which leaves you without a net exactly while you are refactoring. Tests placed at the boundary wrapping the structure you will change survive any internal rearrangement.
Quiz 3: You renamed a class with an automated tool and all tests pass. What still needs checking?
Answer: Every place that references the symbol as a string: reflection, serialised type names in storage, config files and templates, and consumers in other repositories.
Explanation: Syntax-tree tools only see statically traceable references. Names already persisted in a database or message queue are data rather than code and sit outside the tool's field of view — in which case the rename is immediately an irreversible change. A full-repository string search costs almost nothing.
Quiz 4: A three-week refactoring branch met 300 conflicts at merge time. What changes next time?
Answer: Instead of keeping a long branch, proceed as independently deployable pieces on the main branch, using parallel change to legalise intermediate states where needed.
Explanation: On a long branch, everyone else keeps adding code on top of the old structure while conflicts grow faster than linearly with time. Cutting by layer, by call site and by data direction, with a real deployment between pieces, makes each piece a reversible unit.
Quiz 5: The refactoring is finished, but the only evidence it helped is code metrics. What else do you look at?
Answer: The cost of real changes in that area — change lead time and change fail rate.
Explanation: Cyclomatic complexity and coupling are proxy metrics. The purpose of refactoring is to make the next change cheap, so verification has to happen on the next change. If the metrics did not improve, reverting is a legitimate option.
Wrapping up
The hard part of refactoring is not knowing the catalogue of patterns. It is acquiring the evidence to say behaviour was preserved, and staying deployable no matter when you stop. With those two in place the rest is mechanical repetition; without them, even excellent design instincts become gambling.
Compressed into one line: write down what you will preserve, build the safety net, introduce seams, change in small steps, move interfaces via parallel change, cut large changes into reversible pieces, and stop when you reach your exit condition. And leave a record of where you stopped.
References
- Definition of Refactoring — Martin Fowler — cited for the noun and verb definitions of refactoring, and for behaviour preservation being the condition that separates refactoring from restructuring. Checked 2026-08-15.
- Parallel Change — Martin Fowler — cited for the definitions of the expand, migrate and contract phases and the attribution to Joshua Kerievsky. Checked 2026-08-15.
- The Practical Test Pyramid — Martin Fowler — cited for the lack of agreement on what "unit" means, and for end-to-end tests being flaky, maintenance-heavy and slow. Checked 2026-08-15.
- The Testing Trophy and Testing Classifications — Kent C. Dodds — cited for the claim that tests resembling real usage give more confidence, and for the acknowledgement that many definitions of unit test exist and the ratio debate is a distraction. Checked 2026-08-15.
- Code Review Developer Guide, The Standard of Code Review — Google — cited for approving a change that definitely improves overall code health even when imperfect, and for the "Nit: " convention. Checked 2026-08-15.
- Code Review Developer Guide, Speed of Code Reviews — Google — cited for slow reviews discouraging cleanups and refactorings, and for one business day as the maximum response time. Checked 2026-08-15.
- DORA metrics: the four keys — DORA — cited for the definitions of change lead time and change fail rate. Checked 2026-08-15.
- The terms characterization test and seam are widely known from Michael Feathers' legacy-code work; this article uses the concepts without quoting the original. The safety-net placement rule in section 2, the loop and stop rules in section 4, the cutting axes in section 6, the codemod review targets in section 8 and the stop signals in section 9 are procedures assembled in this article rather than taken from the sources above.
Further reading
- Related post on this blog: The Economics of Refactoring: When Does It Pay Off?
- Related post on this blog: The Complete Guide to Software Testing Strategy
- Related post on this blog: Designing Verification: Treating Tests as Grounds for Trust
- Related post on this blog: The Complete Guide to the Strangler Fig Pattern
- Related post on this blog: How to Talk in Code Review
- Related tool: Collab RPG
The Complete Guide series