Refactoring
Changing the structure of code without changing its behavior. The senior skills here are: doing it safely (tests as the safety net), knowing which refactors are worth doing, and — crucially — knowing when not to.
The definition matters
Refactoring is behavior-preserving structural change. If behavior changes, it’s not a refactor — it’s a rewrite or a bug fix, and it needs different scrutiny. Conflating the two is dangerous: “I refactored it and also fixed a bug and also changed the API” is three things in one PR, none reviewable.
Rule: a refactoring PR changes structure and nothing else. A behavior change is a separate PR. Keep them apart so each is reviewable and revertable independently.
Tests are the safety net
You cannot refactor safely without tests. The tests are what prove behavior was preserved.
1. Ensure the code is covered by tests (write characterization tests first if it isn't).
2. Make a small structural change.
3. Run the tests — still green.
4. Repeat.
If the code you want to refactor has no tests, write the tests first — “characterization tests” that pin down the current behavior (even if that behavior is weird). Now you can refactor and the tests catch any drift.
The tests must test behavior, not implementation — if a refactor (which by definition doesn’t change behavior) breaks the tests, the tests were over-coupled to the old structure. See 05_testing/.
Common refactorings
The Fowler catalog, the ones that come up most:
- Extract function/method — a chunk of a long function becomes its own named function. The single most common refactor.
- Extract variable — name an inscrutable expression.
- Inline — the reverse; remove a pointless indirection.
- Rename — the highest value-to-risk refactor; a good name removes the need for a comment.
- Move function/field — relocate to where it belongs (the class/module it actually relates to).
- Replace conditional with polymorphism — a sprawling
if type == ...becomes a dispatch on subtypes (use judiciously — see anti-patterns). - Introduce parameter object — five related parameters become one object.
- Replace magic literal with named constant.
- Decompose conditional — pull a gnarly boolean into a well-named function (
if is_eligible_for_discount(user)). - Split a class/module that has grown two responsibilities.
In Python specifically: extracting to functions, dataclasses for parameter objects, comprehensions replacing accumulation loops, context managers replacing setup/teardown duplication.
Refactor in small, safe steps
The discipline is small steps, tests green between each. Not “rewrite the module over three days then run the tests.” Each step is independently correct and revertable. If something breaks, the last green state is one small step back, not three days back.
Modern editors/LSPs do mechanical refactors (rename, extract) safely across the codebase — use them; a tool-driven rename can’t typo a reference.
When to refactor
- Before adding a feature to code that’s hard to change — “make the change easy, then make the easy change” (Kent Beck). Refactor to create the seam the feature needs, then add the feature in a separate step.
- The “rule of three” — duplicate once, fine. Duplicate a third time, now extract the abstraction — by the third instance you actually understand what varies and what’s common.
- When you touch the code anyway — opportunistic “campsite rule” cleanup: leave the file slightly better. But scoped to what you’re touching, not a side quest.
- When a name lies or a function is doing three things and you just spent ten minutes understanding it — fix it while it’s fresh.
When NOT to refactor — the senior judgment
This is the part that separates senior from mid-level. Refactoring is not free; it has cost and risk.
- Code that works and you’ll never touch again — a stable, untouched module. Refactoring it is pure risk for no return. Leave it.
- Code with no tests, on a deadline — refactoring untested code is dangerous; if you can’t write the characterization tests first, don’t refactor now.
- “It’s not how I’d write it” — different ≠ wrong. Aesthetic preference is not a reason to churn code and risk regressions.
- Speculative abstraction — refactoring to a “flexible” design for requirements that don’t exist yet. The duplication you’d remove is often cheaper than the wrong abstraction you’d create. Wait for the rule of three.
- In the same PR as a behavior change — split them.
- When the real problem is elsewhere — refactoring a function whose ugliness is a symptom of a bad module boundary just moves the mess around.
The framing for an interview: “I refactor opportunistically and with a reason — to make a needed change easier, or when I’ve just paid the cost of understanding messy code. I don’t refactor working, tested, stable code just because I’d have written it differently; that’s risk without return.”
The “big rewrite” trap
The temptation: “this is too messy, let’s rewrite it from scratch.” Almost always a mistake:
- The old code encodes years of bug fixes and edge cases you can’t see — the rewrite re-discovers them all in production.
- It’s a long stretch with no incremental value delivered.
- The team is split or paused while it happens.
The senior alternative: incremental refactoring (strangler-fig pattern) — wrap the old code, route new functionality through the new structure, migrate piece by piece, delete the old when nothing uses it. Value ships continuously; risk is bounded per step. A full rewrite is justified only rarely (the platform is genuinely dead, the old code is unmaintainable and untestable) and even then, prefer incremental if at all possible.
Refactoring and tech debt
Refactoring is one tool for paying down tech debt — see 06_tech_debt_and_complexity.md. Not all debt is refactored away; some is rewritten, some is deleted, some is correctly left alone. Refactoring addresses the “structure is bad” kind of debt specifically.
Common gotchas
- Behavior change disguised as a refactor — split them; a real refactor preserves behavior exactly.
- Refactoring without tests — no safety net; write characterization tests first or don’t refactor now.
- Tests coupled to implementation — a true refactor breaks them, which means the tests were testing structure not behavior.
- Big-bang rewrites — re-discover every old edge case in prod, no incremental value. Strangle, don’t rewrite.
- Speculative abstraction — building flexibility for requirements that don’t exist; the wrong abstraction costs more than the duplication.
- Refactoring stable, working, untouched code — risk with no return.
- Mixing refactor and feature in one PR — neither is reviewable.
Interview angle
- “What is refactoring?” — behavior-preserving structural change. If behavior changes it’s not a refactor — and a refactoring PR should change structure and only structure, kept separate from behavior changes so each is independently reviewable and revertable.
- “How do you refactor safely?” — tests are the safety net; they prove behavior was preserved. If the code isn’t covered, write characterization tests pinning the current behavior first. Then small steps, tests green between each — so a break is one step back, not three days back.
- “When do you decide not to refactor?” — working, tested, stable code you won’t touch again (risk, no return); untested code on a deadline; “it’s not how I’d write it” (different isn’t wrong); speculative abstraction before the rule of three. Refactoring has cost and risk — it needs a reason.
- “When do you refactor?” — opportunistically, with a reason: before adding a feature to code that’s hard to change (“make the change easy, then make the easy change”), at the rule of three for duplication, or campsite-rule cleanup scoped to code you’re already touching.
- “A module is a mess — rewrite it?” — almost never a full rewrite: the old code encodes invisible years of bug fixes a rewrite re-discovers in prod, and it delivers no incremental value. Prefer the strangler-fig pattern — wrap it, route new work through new structure, migrate piece by piece, delete the old. Full rewrite only when the code is genuinely unmaintainable and untestable.
- “How do you make sure a refactor didn’t break anything?” — the test suite, testing behavior not implementation. If a true refactor breaks a test, that test was over-coupled to the old structure — which is itself a finding worth fixing.