Every codebase eventually has legacy code — usually code you wrote two years ago that you no longer recognize. The 'rewrite from scratch' impulse is almost always wrong. The 'leave it alone' impulse is also wrong. Incremental refactoring with safety nets is the work.

Advertisement

Don't rewrite from scratch

Joel Spolsky's classic argument is still right: the old code embodies thousands of bug fixes you don't remember. The new code will rediscover them. Rewrites take 2-5x longer than estimated and ship with familiar bugs newly reintroduced. Avoid unless the old code is structurally unmovable.

Characterization tests first

Before changing anything, write tests that capture the CURRENT behavior — even the buggy parts. Characterization tests are about preserving behavior, not asserting correctness. They're the safety net for the next steps.

Advertisement

Strangler fig pattern

New functionality goes in new code that intercepts calls to the old. Over time, the old code shrinks until it can be deleted. Martin Fowler's pattern; works for any modernization where 'big bang' would be risky. Routes between old/new based on flag or path.

Extract before refactor

Extract the messy code into a clean interface before refactoring its internals. Now you can change implementation freely. This is harder than it looks because the interface is fictional — you're imagining the right boundary.

Stop adding to the legacy

New features go in the new code. The legacy code is on a diet. This requires discipline — short-term it's faster to add to the legacy. Long-term it's the only way out. Document this rule explicitly; new joiners won't infer it.

No rewrites. Characterization tests. Strangler fig. Extract before refactor. New code doesn't go in legacy. Discipline > heroics.