"Legacy code" — the working definition from Michael Feathers — is code without tests. By that definition, most code is legacy. The looser definition, code written before the current team was responsible for it, captures the ongoing situation that most engineers spend most of their careers in. Modernizing legacy code is one of the most common engineering tasks and one of the most consistently mishandled.
This page is about the strategies that work, the rewrite-vs-refactor decision, and the patterns that make modernization survivable.
Legacy code exists for reasons. The original engineers had constraints, deadlines, and information you do not have. The "ugly" patterns sometimes encode business knowledge that has since been forgotten. The "obvious" rewrite often misses what the legacy code was protecting against.
This is the first lesson: respect the code. Then change it.
The single largest decision in legacy modernization. Common framings:
Throw out the old; build a new system that does the same thing. The case for: clean architecture, modern tools, no decade of accumulated patches.
The case against: rewrites take longer than estimated (always), rewrites lose accumulated business logic, rewrites introduce new bugs while old bugs are still being fixed in the legacy system.
The honest record: most "we'll rewrite it" projects fail. They miss deadlines, lose features the original had, take longer than the team has patience for.
Keep the system running; improve it incrementally. The case for: continuous delivery, no big-bang risk, the team learns the system.
The case against: slow, painful, the structure may be too compromised to fix without rewriting.
For most cases, refactor wins. Rewrites are seductive and rarely succeed.
Tests that pin down current behavior — including bugs — so you can refactor without changing it.
The pattern: write tests that capture what the code currently does. Do not try to test what it should do. Get coverage of the observable behavior. Now you have a safety net.
After characterization tests are in place, refactoring is dramatically safer. After refactoring, fix the bugs (if they are bugs) as separate, deliberate changes.
Build the new alongside the old; gradually move traffic. See RefactoringStrategies for detail. Works well when the system has clear boundaries.
Test the boundary between systems, not internals. Two systems can produce the same external behavior with very different internals. Boundary tests let you replace internals freely.
When you cannot easily modify a function, create a new function next to it. Call the new function from the old. The new code is testable; the old code is unchanged.
Over time, more code lives in the new functions than the old. Eventually the old wraps the new instead of the other way around.
Wrap a hard-to-modify method with a new one that adds behavior. The original is preserved; new behavior is in the wrapper.
For functions you cannot rewrite, you can sometimes refactor the inside while keeping the outside the same. Extract sub-functions, replace primitive types with proper types, add tests at the seams. The function eventually becomes a thin orchestrator over well-tested helpers.
When a real platform change is needed (Python 2 → 3, monolith → services, JavaScript → TypeScript):
Convert one module or one file at a time. Run both old and new in parallel. Each conversion is small and reversible.
For language migrations, sometimes it's possible to auto-generate a bridge from old to new. The bridge is generated; the new code lives alongside.
For data migrations, write to both systems for a period. Verify they stay in sync. Eventually cut over reads.
The new system should match old-system behavior, including quirks. Once it has parity, improvements come as separate work. Mixing parity-restoration with improvement makes both harder.
Legacy modernization is as much about people as code:
If they're still around, capture their knowledge. They know the constraints that aren't in the code: why this particular API call, why this specific error case, what the customer support implications are.
Engineers love to rewrite. New engineers especially. The company often does not benefit from the rewrite. Be explicit about the case before committing.
Every modernization decision should leave a record of why. Future engineers will need to understand the choices.
Legacy modernization is multi-year work. Set expectations accordingly; do not promise speed that requires a rewrite.
Before changing legacy code, ask why it is the way it is. The answer often reveals constraints that should be preserved.
Comments and git blame can reveal that an "ugly" piece of code was the fix for a specific bug. Removing the ugliness without understanding the bug brings the bug back.
Legacy code often has hidden dependencies — environmental variables, specific OS, specific data shape. Find them before changing.
If tests exist, read them before reading the code. They often capture the intended behavior more clearly than the code.
For modernizing a specific legacy system:
The work is long; the steps are short.