A client asked us to "quickly clean up" a 7-year-old PHP monolith. 180,000 lines of code. No tests. Global state everywhere. Database queries embedded in HTML templates. We quoted 6 months for a full rewrite. They said "we don't have 6 months." So instead, we did something smarter: we wrapped the old code in tests, extracted one module at a time, and shipped new features throughout. Twelve months later, 60% of the codebase was modernized. The other 40% still worked fine — and nobody needed to touch it.
What We'll Cover
Why the Big Rewrite Almost Always Fails
The temptation is always the same: "This code is terrible. Let's rewrite it from scratch." Joel Spolsky called this the single worst strategic mistake a company can make. Here's why:
- The old code contains years of bug fixes — each "weird" if-statement usually exists because a customer hit an edge case. A rewrite starts without that knowledge
- Feature development stops during the rewrite. Competitors don't stop. Customers don't stop requesting features
- Rewrites take 2-3x longer than estimated. We've seen this consistently — the estimation multiplier for "never done anything like it" is 3x
- You'll make the same architectural mistakes unless the team has fundamentally improved. New code becomes legacy code faster than you think
Assessing the Damage: Where to Start
Not all legacy code needs refactoring. Some of it works fine and nobody needs to change it. Focus on the code that's actively painful.
| Signal | What It Means | Action |
|---|---|---|
| High change frequency + high bug rate | This module changes often and breaks every time | Refactor first — highest ROI |
| High change frequency + low bug rate | Changes often but doesn't break. "Ugly but works" | Add tests, minor cleanup. Don't over-invest |
| Low change frequency + high complexity | Complex code nobody touches | Leave it alone. It's stable. Wrap it with tests if you ever need to change it |
| New feature blocked by legacy code | "We can't add X because the auth module is too tangled" | Refactor the blocking module as part of the feature work |
Hotspot Analysis
Use Git history to find the code that actually needs attention:
# Find files with most commits (most-changed code)
git log --format=format: --name-only --since="2024-01-01" | \
sort | uniq -c | sort -rn | head -20
# Find files with most bug-fix commits
git log --format=format: --name-only --grep="fix\|bug\|patch" | \
sort | uniq -c | sort -rn | head -20
The files at the top of both lists are your refactoring targets. They change often AND they break often.
Building a Safety Net: Characterization Tests
You can't refactor code you can't test. But legacy code usually doesn't have tests. The solution: characterization tests — tests that capture the code's current behavior, bugs and all.
How to Write Characterization Tests
- Call the function/API endpoint with known inputs
- Record the output — don't judge whether it's "correct." Just record what it does
- Write a test that asserts the recorded output
- Repeat for different inputs: happy path, edge cases, error conditions
// Characterization test: we don't know if this behavior is
// "correct," but this is what the code currently does.
// If refactoring changes this output, we need to investigate.
test('calculateDiscount returns 15% for orders over 10000', () => {
const result = calculateDiscount({ total: 12000, userType: 'regular' });
// Current behavior: 15% discount
// Note: this seems high — verify with product team
expect(result).toBe(1800);
});
test('calculateDiscount handles negative totals (bug?)', () => {
const result = calculateDiscount({ total: -500, userType: 'regular' });
// Current behavior: returns negative discount (!?)
// Known bug — don't "fix" during refactoring, fix separately
expect(result).toBe(-75);
});
The key insight: characterization tests preserve behavior during refactoring. Fix bugs separately, in separate PRs, after the refactoring is done. Mixing refactoring and bug fixes is how you introduce regressions.
Refactoring Strategies
Strategy 1: Extract and Test
Find a tangled function. Extract a piece of logic into a new, well-tested function. The old function calls the new one. Repeat until the old function is just orchestration.
Strategy 2: Branch by Abstraction
- Create an abstraction (interface) over the legacy code
- Make the legacy code implement the interface
- Write a new implementation of the same interface
- Gradually switch consumers to the new implementation (behind a feature flag)
- Remove the legacy implementation when all consumers are migrated
Strategy 3: Parallel Run
Run both old and new code simultaneously. Compare outputs. When they match consistently, switch to the new code. GitHub uses this approach (called "Scientist") for critical path changes. It's the safest way to refactor code where correctness is critical (payment processing, permissions).
The Strangler Fig Pattern
Named after the tropical fig that grows around a host tree, eventually replacing it. Apply this to your legacy system:
- Put a proxy/router in front of the legacy system
- New features go to the new system
- Gradually migrate existing features from legacy to new, one endpoint at a time
- The proxy routes traffic — some requests hit legacy, some hit new. Users don't notice
- Eventually, all traffic goes to the new system. Decommission the legacy
This is the approach we used for the 180K-line PHP monolith. We put Nginx in front. New API endpoints went to a new Node.js service. Old endpoints stayed in PHP. Over 12 months, we migrated the high-churn endpoints first. The rarely-used PHP endpoints? They still run in production. And that's fine.
Measuring Refactoring Progress
| Metric | How to Measure | Why It Matters |
|---|---|---|
| Test coverage of refactored code | Coverage reports per module | Refactored code without tests is just different legacy code |
| Bug rate in refactored modules | Track bugs by module in your issue tracker | If bugs increase after refactoring, something went wrong |
| Change lead time | Time from "start coding" to "deployed to production" per module | Refactored code should be faster to change. If it's not, the refactoring isn't helping |
| Developer sentiment | Ask: "Which modules are painful to work in?" quarterly | The people writing code know where the pain is. Trust their feedback |
| Percentage migrated | Requests served by new system vs legacy | Visible progress. Useful for stakeholder communication |
Rules We Follow When Refactoring
- Never refactor without tests. If you can't write a test first, write a characterization test first
- Never mix refactoring with feature work in the same PR. Refactoring PRs should have zero behavior change. Feature PRs can introduce new behavior
- Refactor the code you're changing. "Boy Scout Rule" — leave code better than you found it. But only the code you're actually touching
- Set a time budget. "We'll spend 20% of each sprint on refactoring" is sustainable. "We'll stop everything and refactor for 3 months" is not
- Ship refactoring to production continuously. Don't accumulate 6 weeks of refactoring in a branch. Small, frequent merges catch issues early
Frequently Asked Questions
How do you convince management to invest in refactoring?
Frame it as velocity investment, not technical debt cleanup. "This module takes 2 weeks to add a feature. After refactoring, it'll take 3 days." Use change lead time data to quantify the cost of NOT refactoring. Management doesn't care about code quality — they care about shipping speed and bug rates.
Should we refactor or rewrite our legacy system?
Refactor in almost all cases. Rewrite only if: (1) the technology is truly end-of-life (e.g., Flash, Python 2), (2) the system is small enough to rewrite in under 3 months, or (3) the architecture is fundamentally incompatible with your needs (e.g., desktop app needs to become a web app). Even then, use the strangler fig pattern rather than a big-bang rewrite.
How do you refactor code with no documentation?
Start with characterization tests to document current behavior. Use `git blame` to find who wrote the code and why (commit messages are informal documentation). Use AI tools like Claude Code to explain unfamiliar functions. Write the documentation as you go — your understanding becomes the next developer's docs.