The Golden Rules of Refactoring: How to change code without breaking everything
Refactoring is one of those words developers throw around constantly — often while creating the exact mess they're trying to clean up. To…
Part 1 — Before you touch anything
The difference between a refactoring that goes well and one that breaks production at 3am often has nothing to do with the quality of the code changes themselves. It has to do with what happened — or didn’t happen — before the first line was changed.
1. Build your safety net first: write behavioral tests
If the code you’re about to refactor isn’t covered by tests, stop. Write them first.
Not unit tests that verify implementation details. Behavioral tests — tests that describe what the system does from the outside, not how it does it internally. If you’re refactoring a payment module, a behavioral test asserts that given valid card details, a charge is created and a receipt is returned. It doesn’t care whether you used a strategy pattern or a chain of responsibility inside.
This distinction matters because refactoring will change implementation details. Tests that are too closely coupled to internals will break during refactoring even when nothing is actually wrong, creating noise that masks real regressions.
Michael Feathers calls these characterization tests — tests written not to specify desired behavior, but to document and lock down existing behavior, including the quirky parts. For legacy code, this is the foundation.
# ❌ Too tied to implementation — will break during refactoringdef test_payment_calls_stripe_client(): mock_stripe = Mock() processor = PaymentProcessor(stripe_client=mock_stripe) processor.charge(card, amount) mock_stripe.create_charge.assert_called_once()
# ✅ Behavioral - survives internal restructuringdef test_valid_payment_returns_receipt(): processor = PaymentProcessor() result = processor.charge(valid_card, amount=100) assert result.status == "success" assert result.receipt_id is not None
No safety net, no refactoring. That’s not an opinion — it’s risk management.
2. Understand the code before you change it
This sounds obvious. It rarely is.
There’s a reflex in developers — especially when facing ugly code — to immediately start rewriting. Resist it. Code that looks terrible often encodes hard-won knowledge: edge cases handled after production incidents, compliance requirements baked into obscure conditionals, performance trade-offs that weren’t documented anywhere.
Before changing anything, trace through the logic. Read the git history (git log -p is your friend). Ask the people who wrote it — if you can. The goal is to understand why it is the way it is, not just what it does.
This understanding also helps you write better behavioral tests — because you’ll know which edge cases to cover.
3. Define a clear, scoped objective
Refactoring without a goal is rewriting for the sake of rewriting. Before starting, answer this question: what problem does this refactoring solve?
- Readability? The code works but no one can understand it.
- Decoupling? Two modules are too entangled to evolve independently.
- Performance? A known bottleneck is slowing down the product.
- Testability? The code is impossible to test as-is.
Each of these is a valid objective. But conflating them turns a manageable task into an open-ended rewrite that never ships. Pick one. Write it down. Use it as a filter: if a proposed change doesn’t serve the stated objective, it doesn’t belong in this refactoring.
This scoping is especially important when communicating with non-technical stakeholders. “We’re refactoring” means nothing to a product manager. “We’re decoupling the billing module from the user account service so we can change pricing logic without risking account management” is something they can evaluate against business priorities.
4. Never start with failing tests
If your test suite already has failures before you begin, you have no baseline. You can’t tell if a new failure was caused by your changes or was already there.
Fix the failing tests first — or, if fixing them is out of scope, explicitly skip them with a documented reason. Then, before writing a single line of refactoring code, run the full suite and confirm it’s green. That green baseline is your contract: if anything turns red during the refactoring, you caused it.
Part 2 — Organizing the Work
Refactoring isn’t just a technical act. It’s a team coordination challenge. These rules prevent the kind of invisible collateral damage that erodes trust between developers and between teams.
5. Document the intent, not just the what
Code comments, commit messages, and pull request descriptions suffer from the same common failure: they describe what was changed, not why.
# Bad commit messagerefactor: rename variables and extract methods in billing service
# Good commit messagerefactor(billing): decouple charge logic from user session stateThe billing service was reading directly from the user session object,making it impossible to process charges without an active web request.This decoupling enables async processing and simplifies testing.
The diff shows what changed. Only you know why. Document the why — the problem you were solving, the constraint you were working around, the future change you’re enabling. That context is what makes a refactoring understandable and reviewable, and it’s what makes the codebase legible to the next developer months or years from now.
6. Involve others early, especially on shared code
If the code you’re touching is used by other teams or other parts of the system, a refactoring done in isolation is a merge conflict waiting to happen. Worse, it can introduce breaking changes that other developers don’t discover until they’re in the middle of unrelated work.
Before starting, communicate your plan. Create a ticket. Post in the relevant Slack channel. Open a draft PR early so others can flag concerns. This isn’t bureaucracy — it’s how you avoid doing work twice and how you make sure a “safe” refactoring doesn’t become someone else’s incident.
For cross-team changes, consider the Expand-Contract pattern: first introduce the new structure alongside the old one, migrate consumers one by one, then remove the old structure. It’s slower, but it’s safe.
7. Weigh risk against value — every refactoring has a cost
Tests reduce risk. They don’t eliminate it. A refactoring always carries the possibility of introducing subtle regressions, causing merge conflicts, or consuming more time than planned.

Risk vs Value Matrix illustration
Before investing that cost, make sure the benefit is real and proportionate:
- Immediate value: performance improvements, bug reduction, eliminating security vulnerabilities.
- Future value: faster feature development, easier onboarding, reduced cognitive overhead.
- Risk: size of the codebase touched, test coverage, number of teams affected.
A small refactoring of a well-tested, isolated module that unblocks a critical feature is easy to justify. A sweeping architectural overhaul of a poorly-tested, heavily-shared module in the middle of a release cycle is not — regardless of how technically correct it is.
Refactoring is an investment. Like any investment, it needs to be justified.
Part 3 — While Refactoring
You’ve done your preparation. You have a green test suite, a clear objective, and a communicated plan. Now you’re in the middle of it. These rules govern the execution.
8. Apply the Boy Scout Rule: improve progressively, not perfectly
The Boy Scout Rule says: leave the campsite cleaner than you found it. Applied to code, it means: every time you touch a piece of code, leave it slightly better — a clearer variable name, a shorter function, a removed dead branch.
This doesn’t mean fix everything at once. It means make consistent, incremental improvements. The codebase gets better over time without any single heroic rewrite effort.
The trap to avoid: letting the Boy Scout Rule become a justification for endless scope creep. You’re in a function to rename a method — not to restructure the entire module. Improvement should be bounded by the current task.
9. Go toward simplicity, not sophistication
Refactoring is not an opportunity to demonstrate design pattern knowledge. Every abstraction added has a carrying cost — more code to read, more indirection to trace, more surface area to test.
The right refactoring makes code simpler, not more architecturally impressive. If the result has more layers than the original, ask whether each layer earns its place. A well-named function that extracts complex logic is good. A factory that creates strategies consumed by a facade that delegates to a template method is likely not, unless all of that complexity was already there and necessary.
“Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.” Antoine de Saint-Exupéry
# Before: complex and hard to followdef process(order): if order.type == "DIGITAL": if order.user.subscription_tier == "PREMIUM": discount = order.total * 0.20 else: discount = order.total * 0.10 return order.total - discount elif order.type == "PHYSICAL": if order.user.subscription_tier == "PREMIUM": discount = order.total * 0.15 else: discount = 0 return order.total - discount + order.shipping_cost # implicit: returns None for unknown order types
# After: extracted and readable, no new abstractions neededdef calculate_discount(order): discounts = { ("DIGITAL", "PREMIUM"): 0.20, ("DIGITAL", "STANDARD"): 0.10, ("PHYSICAL", "PREMIUM"): 0.15, ("PHYSICAL", "STANDARD"): 0.0, } rate = discounts.get((order.type, order.user.subscription_tier), 0.0) return order.total * ratedef process(order): shipping = order.shipping_cost if order.type == "PHYSICAL" else 0 return order.total - calculate_discount(order) + shipping
10. Break it into steps — progressive replacement is safer than big-bang rewrites
One of the most dangerous moves in refactoring is the full replacement: removing an old implementation and dropping in a new one in a single step. If anything breaks, the diff is enormous and the regression is hard to isolate.
Instead, refactor in small, verifiable steps. Each step should leave the code in a working, releasable state. Run your tests after every step.
For large structural changes, use the Strangler Fig pattern: build the new structure around the old one, gradually route behavior to the new code, and only remove the old code when the new version has proven itself in production.

Strangler Fig Pattern illustration
# Step 1: Add new implementation alongside old one (tests still passing)def new_charge_processor(card, amount): ... # new logicdef old_charge_processor(card, amount): ... # still exists, untouched
# Step 2: Migrate callers one by one (tests still pass after each migration)# Before: old_charge_processor(card, amount)# After: new_charge_processor(card, amount)# Step 3: Remove old_charge_processor entirely (tests still passing)
This approach transforms a risky leap into a sequence of safe steps.
11. Don’t break the interface contract
If other code — especially code outside your control — depends on the interfaces you’re refactoring, those interfaces are a contract. Breaking them means breaking your consumers.
This applies to: public APIs, exported library functions, event payloads, database schemas, configuration file formats. Any surface that another system or team relies on.
The Liskov Substitution Principle captures this well: if you replace a component with a refactored version, callers should not need to know. The behavior they depend on must be preserved.
When an interface change is truly necessary, make it a deliberate, versioned, communicated migration, not a side effect of an internal refactoring.
Closing: Refactoring as a practice, not an event
The teams that maintain healthy codebases over the long term don’t do so through periodic heroic rewrites. They refactor continuously, in small doses, guided by clear objectives and protected by solid tests. They treat the codebase as a living thing that needs regular care — not a static artifact that gets overhauled every few years.
That continuous practice is what these rules are designed to support. Not to slow you down, but to make sure that when you improve the code, you actually improve it — and don’t trade one problem for three new ones.
If you want to start somewhere: pick Rule 1. Write behavioral tests before your next refactoring. That single discipline eliminates the majority of regressions. The rest of the rules will follow naturally once you have that foundation.
For a deeper dive into the mechanics and catalog of specific refactoring techniques, Martin Fowler’s Refactoring: Improving the Design of Existing Code remains the definitive reference. For working with code that has none of the safety nets described here, Michael Feathers’ Working Effectively with Legacy Code is essential reading.
The rules are simple. The discipline is the hard part!
That’s all folks!