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:

  1. Find the 10 lines relevant to your bug
  2. Extract them into a small, well-named function
  3. Write tests for that new function
  4. 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:

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:

  1. Build one feature in the new architecture
  2. Route traffic for just that feature to new code
  3. Keep old code running for everything else
  4. Migrate features one by one
  5. 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.

← Back to Debugging & Code Quality

Back to Home