Refactoring Legacy Code Without Breaking Everything
I opened utils.js on my first day at a new job. It was 4,800 lines long. Variables named x, data2, temp. Comments saying "// DO NOT TOUCH". Zero tests.
I needed to add a feature. Touched one function. Broke the payment system. Rolled back. Touched a different line. Broke user registration. My manager asked if I was okay.
That's legacy code. Not old code—code without tests. If you can't verify it works, changing it is gambling.
Step One: Write Tests for What Exists
Before changing anything, capture the current behavior with a "characterization test." You're not testing what the code should do—you're documenting what it actually does.
// Legacy function
function calculateDiscount(user, price) {
if (user.type == 1) return price * 0.9;
if (user.type == 2) {
if (price > 100) return price * 0.8;
return price;
}
return price;
}
// Characterization test - captures current behavior
test('Type 2 user below 100 gets no discount', () => {
const user = { type: 2 };
expect(calculateDiscount(user, 50)).toBe(50);
});
That logic might be wrong (why no discount for cheap items?), but other code might depend on it. Pin the behavior first. Now if your refactoring changes this output, the test turns red.
The Boy Scout Rule
Don't try to fix everything at once. That leads to the "big rewrite"—which usually fails.
Instead: leave the code cleaner than you found it. If you're fixing a bug in a 1,000-line function:
- Find the 10 lines relevant to your bug
- Extract them into a small, well-named function
- Write tests for that new function
- Fix the bug
Over time, the monster function gets eaten from the inside, replaced by small, tested pieces.
The Sprout Method
Sometimes legacy code is so tangled you can't even test it. It depends on global variables, database connections, and DOM elements all at once.
When you need to add new functionality, don't write it inside the mess. Create a separate, clean function and call it from the old code:
class OrderProcessor {
process() {
// ... 500 lines of spaghetti ...
// NEW: Call a clean function instead of adding more spaghetti
const tax = this.calculateTax(order);
// ... more spaghetti ...
}
// Sprouted method - clean, testable, isolated
calculateTax(order) {
// New logic here
}
}
You're not fixing the old mess, but you're not making it worse. New code is clean and testable.
Code Smells to Watch For
When refactoring, look for these patterns:
- Magic numbers:
if (status == 4)→if (status == STATUS_COMPLETE) - Long parameter lists: 6-7 arguments → single config object
- Duplicated code: Same logic in three places → extract to utility
- God classes:
ManagerorHelperthat does everything → split by responsibility
The Strangler Fig Pattern
For massive changes (monolith to microservices, jQuery to React), don't do a cutover. Build the new system alongside the old one:
- Build one feature in the new architecture
- Route traffic for just that feature to new code
- Keep old code running for everything else
- Migrate features one by one
- Eventually turn off the old system
No big bang. No "migration day" where everything breaks at once.
The Mindset
Legacy code makes money. It runs in production. Users depend on it. Respect that.
Small changes. Run tests after each change. Commit often. Never mix refactoring with new features in the same commit.
It's slow, but it's safe. And safe is fast in the long run.