Ideas Engineered for Tomorrow
We Engineer Services & Solutions for Your Business Needs
Home About
Products
Services
Hire
Industries
Consulting
Partners
Articles Careers Contact
Software Development

Refactoring Legacy Code Without Breaking Everything

The codebase everyone's afraid to touch. Here's how to modernize it safely — with real strategies from teams that shipped while refactoring.

October 12, 2025 13 min read

A client asked us to "quickly clean up" a 7-year-old PHP monolith. 180,000 lines of code. No tests. Global state everywhere. Database queries embedded in HTML templates. We quoted 6 months for a full rewrite. They said "we don't have 6 months." So instead, we did something smarter: we wrapped the old code in tests, extracted one module at a time, and shipped new features throughout. Twelve months later, 60% of the codebase was modernized. The other 40% still worked fine — and nobody needed to touch it.

Why the Big Rewrite Almost Always Fails

The temptation is always the same: "This code is terrible. Let's rewrite it from scratch." Joel Spolsky called this the single worst strategic mistake a company can make. Here's why:

  • The old code contains years of bug fixes — each "weird" if-statement usually exists because a customer hit an edge case. A rewrite starts without that knowledge
  • Feature development stops during the rewrite. Competitors don't stop. Customers don't stop requesting features
  • Rewrites take 2-3x longer than estimated. We've seen this consistently — the estimation multiplier for "never done anything like it" is 3x
  • You'll make the same architectural mistakes unless the team has fundamentally improved. New code becomes legacy code faster than you think

Assessing the Damage: Where to Start

Not all legacy code needs refactoring. Some of it works fine and nobody needs to change it. Focus on the code that's actively painful.

Signal What It Means Action
High change frequency + high bug rate This module changes often and breaks every time Refactor first — highest ROI
High change frequency + low bug rate Changes often but doesn't break. "Ugly but works" Add tests, minor cleanup. Don't over-invest
Low change frequency + high complexity Complex code nobody touches Leave it alone. It's stable. Wrap it with tests if you ever need to change it
New feature blocked by legacy code "We can't add X because the auth module is too tangled" Refactor the blocking module as part of the feature work

Hotspot Analysis

Use Git history to find the code that actually needs attention:

# Find files with most commits (most-changed code)
git log --format=format: --name-only --since="2024-01-01" | \
  sort | uniq -c | sort -rn | head -20

# Find files with most bug-fix commits
git log --format=format: --name-only --grep="fix\|bug\|patch" | \
  sort | uniq -c | sort -rn | head -20

The files at the top of both lists are your refactoring targets. They change often AND they break often.

Building a Safety Net: Characterization Tests

You can't refactor code you can't test. But legacy code usually doesn't have tests. The solution: characterization tests — tests that capture the code's current behavior, bugs and all.

How to Write Characterization Tests

  1. Call the function/API endpoint with known inputs
  2. Record the output — don't judge whether it's "correct." Just record what it does
  3. Write a test that asserts the recorded output
  4. Repeat for different inputs: happy path, edge cases, error conditions
// Characterization test: we don't know if this behavior is
// "correct," but this is what the code currently does.
// If refactoring changes this output, we need to investigate.

test('calculateDiscount returns 15% for orders over 10000', () => {
    const result = calculateDiscount({ total: 12000, userType: 'regular' });
    // Current behavior: 15% discount
    // Note: this seems high — verify with product team
    expect(result).toBe(1800);
});

test('calculateDiscount handles negative totals (bug?)', () => {
    const result = calculateDiscount({ total: -500, userType: 'regular' });
    // Current behavior: returns negative discount (!?)
    // Known bug — don't "fix" during refactoring, fix separately
    expect(result).toBe(-75);
});

The key insight: characterization tests preserve behavior during refactoring. Fix bugs separately, in separate PRs, after the refactoring is done. Mixing refactoring and bug fixes is how you introduce regressions.

Refactoring Strategies

Strategy 1: Extract and Test

Find a tangled function. Extract a piece of logic into a new, well-tested function. The old function calls the new one. Repeat until the old function is just orchestration.

Strategy 2: Branch by Abstraction

  1. Create an abstraction (interface) over the legacy code
  2. Make the legacy code implement the interface
  3. Write a new implementation of the same interface
  4. Gradually switch consumers to the new implementation (behind a feature flag)
  5. Remove the legacy implementation when all consumers are migrated

Strategy 3: Parallel Run

Run both old and new code simultaneously. Compare outputs. When they match consistently, switch to the new code. GitHub uses this approach (called "Scientist") for critical path changes. It's the safest way to refactor code where correctness is critical (payment processing, permissions).

The Strangler Fig Pattern

Named after the tropical fig that grows around a host tree, eventually replacing it. Apply this to your legacy system:

  1. Put a proxy/router in front of the legacy system
  2. New features go to the new system
  3. Gradually migrate existing features from legacy to new, one endpoint at a time
  4. The proxy routes traffic — some requests hit legacy, some hit new. Users don't notice
  5. Eventually, all traffic goes to the new system. Decommission the legacy

This is the approach we used for the 180K-line PHP monolith. We put Nginx in front. New API endpoints went to a new Node.js service. Old endpoints stayed in PHP. Over 12 months, we migrated the high-churn endpoints first. The rarely-used PHP endpoints? They still run in production. And that's fine.

Measuring Refactoring Progress

Metric How to Measure Why It Matters
Test coverage of refactored code Coverage reports per module Refactored code without tests is just different legacy code
Bug rate in refactored modules Track bugs by module in your issue tracker If bugs increase after refactoring, something went wrong
Change lead time Time from "start coding" to "deployed to production" per module Refactored code should be faster to change. If it's not, the refactoring isn't helping
Developer sentiment Ask: "Which modules are painful to work in?" quarterly The people writing code know where the pain is. Trust their feedback
Percentage migrated Requests served by new system vs legacy Visible progress. Useful for stakeholder communication

Rules We Follow When Refactoring

  • Never refactor without tests. If you can't write a test first, write a characterization test first
  • Never mix refactoring with feature work in the same PR. Refactoring PRs should have zero behavior change. Feature PRs can introduce new behavior
  • Refactor the code you're changing. "Boy Scout Rule" — leave code better than you found it. But only the code you're actually touching
  • Set a time budget. "We'll spend 20% of each sprint on refactoring" is sustainable. "We'll stop everything and refactor for 3 months" is not
  • Ship refactoring to production continuously. Don't accumulate 6 weeks of refactoring in a branch. Small, frequent merges catch issues early

Frequently Asked Questions

How do you convince management to invest in refactoring?

Frame it as velocity investment, not technical debt cleanup. "This module takes 2 weeks to add a feature. After refactoring, it'll take 3 days." Use change lead time data to quantify the cost of NOT refactoring. Management doesn't care about code quality — they care about shipping speed and bug rates.

Should we refactor or rewrite our legacy system?

Refactor in almost all cases. Rewrite only if: (1) the technology is truly end-of-life (e.g., Flash, Python 2), (2) the system is small enough to rewrite in under 3 months, or (3) the architecture is fundamentally incompatible with your needs (e.g., desktop app needs to become a web app). Even then, use the strangler fig pattern rather than a big-bang rewrite.

How do you refactor code with no documentation?

Start with characterization tests to document current behavior. Use `git blame` to find who wrote the code and why (commit messages are informal documentation). Use AI tools like Claude Code to explain unfamiliar functions. Write the documentation as you go — your understanding becomes the next developer's docs.

Pillai Infotech Engineering Team

We've modernized legacy systems ranging from 50K to 500K lines of code — in PHP, Java, and .NET. Our approach: incremental migration that ships continuously, never a 6-month rewrite blackout.

Stuck with Legacy Code?

We help teams modernize legacy systems without stopping feature development. Incremental migration, continuous delivery, measurable progress.

Get a Free Assessment Our Services