Legacy code is the code you fear — code that has no tests, unclear intent, and a long shadow of “no one is quite sure what depends on what”. Rewriting from scratch is the rookie instinct and the senior’s last resort. Refactoring in place is the actual craft: change the system a piece at a time, every step reversible, never stranded in a half-built state.
What Makes Legacy Hard
Three properties compound:
- No tests — there is no safety net, so any change is a guess.
- No single owner — the people who wrote it left; the current owners know less than you do.
- Hidden coupling — renaming a function breaks a script in another repo nobody remembered.
A greenfield rewrite☐all three problems, and reintroduces every bug the legacy code accumulated. In-place refactoring keeps the production bug history while improving the structure.
The Strangler Fig
Plant a new system alongside the old. Route a slice of traffic to it. When that slice works, route more. When the new system handles everything, retire the old.
callers → [ router ]
↓ ↓
old system new system
↑ ↑
entire behavioural surface today new slice today, everything tomorrow
Key properties:
- Reversible at every step: roll traffic back if the new slice breaks.
- Parallel: the new system can be built while the old still earns revenue.
- No big-bang deadline: the only date that matters is “old retired”, and you only commit when the new provably handles the entire surface.
The pattern works at multiple scales: a microservice replacing a module, a database migrating per-tenant to a new schema, a UI framework transition behind a feature flag.
Seams
A seam is a place in existing code where you can alter behaviour without editing the surrounding code — usually by substituting a dependency. Examples:
| Seam kind | Example | When to expose |
|---|---|---|
| Object seam | Inject a fake at construction | The class is instantiatable in tests |
| Link seam | Swap a module import for a stub at test time | You control the build / module map |
| Pre-processor seam | #ifdef TEST eliminates unavailable APIs | The language supports it; rare in modern stacks |
Most legacy code has no seams. The first refactor is introducing one: extract the dependency behind an interface, then the actual change happens against the interface. Extract Interface is the enabling move that unlocks everything else.
Branch-by-Abstraction
You need to refactor LegacyPaymentService to NewPaymentService without freezing development. The pattern:
- Create an abstraction (
PaymentGateway) that the old service implements. - Update every caller to depend on the abstraction, not the concrete service.
- Implement
NewPaymentGatewayagainst the same abstraction — run side-by-side. - Flip routing, feature-flagged per tenant or per region.
- Retire
LegacyPaymentGateway.
The team keeps shipping product changes during the refactor — they keep LegacyPaymentGateway updated while the new one builds. Big-bang refactors fail precisely because they pause product development; branch-by-abstraction never pauses it.
The Mikado Method
For invasive refactors where every change seems to spawn two prerequisites, the Mikado method works like a recursive undo tree:
- State the desired outcome (“extract
parseConfiginto its own file”). - Try the change. If the build / test fails with a dependency, you have a new sub-goal (“need to expose
Configtype”). Revert. - Recurse on each sub-goal until one succeeds cleanly.
- Apply in reverse-tree order, recommitting each layer.
The method turns “I broke everything” into “I have a list of achievable changes”. The repository is always building before each commit; you never strand the team in a broken state.
Continuous vs Big-Bang
| Property | Big-bang | Continuous |
|---|---|---|
| Visibility | Invisible until “done” | Each step ships to prod |
| Risk | Concentrated at one deadline | Spread across many small bets |
| Reversibility | Often impossible once started | Each step is revertable |
| Cultural cost | The author can be hero or scapegoat | Refactor is a normal Tuesday |
Always choose continuous, unless the product requires a single-cut migration (rarely; “we relaunch at Super Bowl”). Even then, prepare strangler underneath — the big-bang moment becomes a flag flip, not a knife-edge deploy.
Knowing When to Stop
The hardest discipline. A useful checklist:
- The purpose of the refactor (faster onboarding? enable feature X? remove a bug class?) is achievable now. Declaring done is the goal, not polishing.
- The cost per change has gone back up: the next move is hard because the code already supports what it needs to.
- The diff has become a one-line cosmetic nit rather than a structural move. Stop.
Engineers over-refactor when they treat the codebase as a craft object rather than a tool. Most refactors should stop just before “perfect”, because the marginal refactor rarely ships to the customer but always costs the team’s review-time budget.
Practice Trajectory
- Identify one “scary” function nobody wants to touch in a codebase you use. Write three characterization tests for it; describe what changes in your willingness to edit it.
- Apply Extract Interface to one legacy dependency. Note what becomes testable that wasn’t before.
- Sketch a strangler-fig migration plan for any service you know: name the router layer, the slices, the rollback rule.
- Pick one improvement stalled on “needs refactoring first” and apply the Mikado method: draw the dependency tree, find a leaf you can ship today.
- Run a “what would I delete?” pass over the system after your refactor. Most legacy refactors net subtract code. If yours net adds, ask why.
When It’s the Right Tool
| Situation | Takeaway |
|---|---|
| High-risk module with no tests | Characterization tests first; refactor second |
| Migration that needs to ship product | Branch-by-abstraction; never freeze development |
| Replacement that has clear slice points | Strangler fig; route small slices first |
| Refactor blocked by tangled dependencies | Mikado method; never strand the team in a broken state |
| “Refactor done, but I want to keep polishing” | Stop. Ship. Refactor again when the next real need appears |