Skip to content

Refactoring Legacy Codebases

Key Points

  • The senior definition of "legacy": code without tests. Not "old," not "ugly" — untested. Michael Feathers' framing in Working Effectively with Legacy Code is still the right starting point in 2026.
  • You can't refactor what you can't test. The first move on any legacy refactor is finding or creating a seam — a place where you can substitute behavior — so you can write a characterization test that pins current behavior before you change anything.
  • Refactoring vs rewriting vs replacing are three different decisions. Refactor when the code is salvageable; replace (via strangler-fig) when it isn't; rewrite almost never.
  • Refactor in the path of change, not as a standalone activity. The Boy Scout rule ("leave it cleaner than you found it") + 15–20% sustained engineering budget beats heroic refactor sprints.
  • Make the change easy, then make the easy change (Kent Beck). The preparatory refactor is often bigger than the feature itself — that's fine; the feature ships safely and the next feature on the same area ships faster.
  • Tests pin behavior, not intent. Characterization tests aren't testing "what the code should do" — they're testing "what it does," even the bugs. That's the contract you're refactoring under.
  • Senior leverage on legacy is mostly judgment, not technique. Knowing what to leave alone is half the skill.

Concepts (deep dive)

The Feathers definition: legacy = untested

Untested code is "legacy" regardless of when it was written. A pristine 2025 service with no tests is legacy; a well-tested 2008 service is not. The reason this definition is load-bearing: untested code can't be safely changed. Any modification carries an unknowable risk of regression, which forces either (a) heroic manual verification, or (b) "don't touch it." Most legacy codebases live in state (b); senior engineers move them to state (a) one method at a time, eventually adding the test coverage that retires the heroism.

Characterization tests: pin behavior before you touch it

A characterization test doesn't assert what the code should do. It asserts what the code currently does, including bugs:

[Fact]
public void GivenInputXY_PriceCalculator_Returns_ExactCurrentResult()
{
    // The legacy behavior we are pinning, even if it's wrong:
    var actual = LegacyPriceCalculator.Calculate(
        new Order(qty: 0, unitPrice: 5m));

    actual.Should().Be(0m);  // ← whatever the system returns today
}

If the test passes today, you have a safety net. If you find a bug while writing it, you write the test asserting the current buggy behavior — and you flag the bug separately. Don't mix bug-fix with refactor; you'll never untangle which change caused which regression.

The discipline: the characterization test must pass against the unchanged legacy before any refactor begins. It's the contract.

Seams (Feathers): the places you can substitute behavior

A seam is a place in the code where you can change behavior without editing the production code there. Why this matters: legacy code is full of new HttpClient(), DateTime.Now, static dependencies, hard-coded database connections — none of which can be controlled in a test as written. A seam is created when you introduce a substitution point.

Common seams Feathers documents (adapted to modern .NET):

Seam type What it is Example
Object seam DI substitution via an interface Pull new EmailClient() into a constructor-injected IEmailClient
Method seam Wrap a static / global call in a virtual method protected virtual DateTime Now() => DateTime.UtcNow; (override in test subclass)
Preprocessing seam Use internal + InternalsVisibleTo to expose for tests Test-only access without changing public API
Link seam Replace the implementation at build/deploy time Conditional DI registration; substitute clients

The mechanical refactor on a typical untested .NET class:

  1. Extract dependencies — every new X() or static call inside the class is a candidate for constructor injection.
  2. Introduce interface for each extracted dependency (only when there's a real second case in the test).
  3. Add constructor overload that takes the new dependencies (call the existing constructor with defaults from inside, so existing callers don't break).
  4. Write the characterization test using the new constructor with fakes.
  5. Run the test against unchanged behavior — it passes (or you fix your fake).
  6. Now you can refactor with a safety net.

The Mikado Method: walk the dependency graph backward

When the refactor you want to make is deeply tangled, the Mikado Method gives a structured way to find the first change that's actually possible:

Goal: Replace LegacyTaxCalculator with NewTaxCalculator
  ├── Prerequisite: All callers must accept ITaxCalculator
  │     ├── Prerequisite: TaxCalculator must implement ITaxCalculator
  │     │     ├── Prerequisite: Extract ITaxCalculator interface
  │     │     │     └── Possible now ← start here
  │     │     └── (other prerequisites if extracting reveals tight coupling)
  │     └── Prerequisite: Caller X must use DI
  └── Prerequisite: Test coverage on tax flows
        └── Prerequisite: Extract HTTP from controller into service
              └── Possible now ← or here

You can't do the goal directly, so you list its prerequisites. Each prerequisite is either possible now or has its own prerequisites. The leaves of the tree are where the work actually starts. Often half the tree turns out to be unnecessary once you've made the bottom-level changes — the path opens up.

Refactor in the path of change (the Boy Scout rule + a budget)

The two failure modes of legacy refactoring are equally bad:

  • Never — the code rots forever, the team complains about it for years, every feature takes longer than the last.
  • All at once — a six-month "refactor project" that pauses feature delivery, gets cancelled at month four, and leaves the code in a worse state (half-old, half-new).

The senior pattern is refactor in the path of change: when you're already in a file for a feature/bug, leave it cleaner than you found it. Pair this with a sustained engineering budget — typically 15–20% of capacity — explicitly tracked. The combination buys:

  • Continuous improvement on the code people actually touch.
  • No "we never refactor" complaint, because every PR includes some.
  • No "we paused features for a quarter," because the budget is sustained, not bursty.

What "cleaner than you found it" looks like:

  • Renaming a confusingly-named variable.
  • Extracting a method that's currently inlined and unclear.
  • Adding a missing null check that the existing code happens to dodge.
  • Adding a characterization test on the path you just modified.
  • Splitting a 60-line method into two 30-line ones.

What it does not look like:

  • "While I was in here, I rewrote the module" — that's a separate PR with its own review and risk story.

When to refactor vs replace vs leave alone

Situation Move
Code changes often, has a clear domain Refactor in place — add tests, extract methods, clean as you go
Code changes rarely, works fine Leave alone — the cost-benefit is negative
Code is bad and core to active development Refactor aggressively — the cost of staying is paid every sprint
Code is bad and part of a system being replaced anyway Don't refactorstrangler-fig it instead
Code is unsalvageable and feature-critical Replace via strangler, not rewrite
Code is unsalvageable and not feature-critical Delete it; absorb the feature elsewhere or accept loss

The most common mistake: aggressive refactoring of code that doesn't change. You pay the refactor cost (and the regression risk) for code that wasn't hurting anyone. The Pareto answer: 80% of pain comes from 20% of the codebase. Find that 20% (git churn metrics, incident history, recent bug clusters) and put your engineering budget there.

The "no-rewrite" stance

Joel Spolsky's 2000 essay still applies in 2026: rewrites almost never work. The pattern that fails:

  1. "Let's rewrite from scratch — we know what to build now."
  2. New system has to reach feature parity before anyone can switch.
  3. Legacy keeps adding features during the rewrite (because the business doesn't pause).
  4. The parity gap grows over time.
  5. Either the rewrite ships incomplete, ships late, or gets cancelled.

The senior answer when "rewrite" comes up is almost always strangler-fig: replace incrementally, slice by slice, with the legacy still running. See Migration Patterns.

The narrow case where rewrite is correct: the legacy is small, well-understood, and the new system can reach parity in less than one quarter (so the legacy doesn't move meaningfully during the rewrite). That's a tiny slice of "we need to rewrite" claims.

Tooling worth using

Tool What it does When
Roslyn analyzers / dotnet format Style + bug class enforcement on every save Always
ReSharper / Rider's safe-rename and "extract method" Mechanical refactors with rollback Always
git log --stat -- file.cs Find the 20% of files that change most Before deciding what to refactor
git blame Understand intent of code you're about to change Before non-trivial edits
Mutmusic / Stryker.NET Mutation testing — find tests that don't actually pin behavior After adding characterization tests; before declaring "covered"
ArchUnitNET Enforce structural rules (no cross-module DB access, no Domain → Infrastructure) After extracting modules
SonarQube / NDepend Bulk metrics — cyclomatic complexity, coupling Inputs, not roadmaps (see Tech-Debt Management)
BenchmarkDotNet Verify perf doesn't regress on hot paths Before/after on the 5% of code where perf matters

Refactoring patterns worth knowing by name

  • Extract Method — the most common move. Pull a coherent chunk out of a long method into a named method.
  • Extract Class — when a class has two unrelated responsibilities (cohesion is low), split them.
  • Introduce Parameter Object — when many methods take the same parameter list, wrap them.
  • Replace Conditional with Polymorphism — when a switch over type appears in three places, the types want to be polymorphic.
  • Inline — the reverse of Extract; an over-extracted helper that's used once and clouds the call site goes back inline.
  • Move Method / Move Field — relocate where a thing lives without changing what it does.
  • Replace Temp with Query — a local variable computing a value becomes a method (often a getter).

Each is small and reversible. The book to keep open: Martin Fowler's Refactoring (2nd edition), which uses JavaScript but applies directly to C#.

How this fits with strangler-fig

Refactoring is the in-place complement to strangler-fig:

  • Strangler-fig replaces a system from the outside, one feature at a time.
  • Refactoring improves a system from the inside, one method at a time.

They're not exclusive — a migration project often uses both. The new system being grown via strangler-fig is itself improved by refactoring as it grows. The legacy being replaced is sometimes worth a few targeted refactors during the migration, especially in the Anti-Corruption Layer translating between old and new.

Communicating refactor work to non-engineers

Tech-debt narratives fail because they're framed as "code quality" (which doesn't have a business hook). The same work, reframed:

  • ❌ "The code is messy in the payments module."
  • ✅ "We spend ~10 engineer-hours per sprint working around the payments module's tight coupling, and the last three incidents touched it. A ~40-hour refactor reduces both."

See Tech-Debt Management for the full framing — that file is the partner to this one. This file is about the technique; that file is about the prioritization and conversation.


How it works under the hood

This topic doesn't have a runtime / CLR mechanics layer — it's about practice and judgment.


Code: correct vs wrong

❌ Wrong: refactor first, test later

// Original (legacy):
public class OrderProcessor
{
    public void Process(Order o)
    {
        var c = new HttpClient();
        // 200 lines of mixed I/O, parsing, business rules, formatting...
        c.PostAsync(/* ... */).Wait();
    }
}

// Refactor (no tests written first):
public class OrderProcessor(IHttpClientFactory httpFactory, IOrderRepository repo, IClock clock)
{
    public async Task ProcessAsync(Order o, CancellationToken ct)
    {
        // Cleaner code — but does it match the legacy's behavior?
        // We don't know. We're hoping.
    }
}

No characterization test means no safety net. Subtle behavior changes (rounding, timezone, error swallowing) get shipped silently. The refactor "looks better" and "still passes the few unit tests we had," but a customer-visible regression appears in week three.

✅ Correct: pin behavior, then refactor

// Step 1 — introduce a seam (object seam: pull HttpClient through DI):
public class OrderProcessor
{
    private readonly Func<HttpClient> _httpFactory;

    public OrderProcessor() : this(() => new HttpClient()) { }  // ← preserves existing call sites
    internal OrderProcessor(Func<HttpClient> httpFactory)
    {
        _httpFactory = httpFactory;
    }

    public void Process(Order o)
    {
        var c = _httpFactory();
        // ... unchanged
    }
}

// Step 2 — characterization test against captured legacy behavior:
[Fact]
public async Task Process_GivenSampleOrder_ProducesExpectedHttpPayload()
{
    var captured = new List<HttpRequestMessage>();
    var handler  = new CapturingHandler(captured);
    var http     = new HttpClient(handler);

    var sut = new OrderProcessor(() => http);
    sut.Process(SampleOrders.Standard);

    captured.Should().HaveCount(1);
    var body = await captured[0].Content!.ReadAsStringAsync();
    body.Should().Be(KnownLegacyPayload);  // ← captured from production today
}

// Step 3 — run; it passes (or you fix the fake until it does).

// Step 4 — now refactor freely; the test catches behavior drift.

The test pins the legacy's output byte-for-byte. Any refactor that breaks the output breaks the test.

❌ Wrong: "while I'm in here, fix everything"

// PR: "Fix bug in Order.Total calculation"
// Changed files: 47.
// What actually changed: the bug fix in Order.cs (3 lines) + a personal-taste rewrite of OrderService, OrderController, OrderRepository, and OrderTests.

Reviewers can't tell which change caused which behavior. CI passes (the tests that exist), but production regressions are inevitable, and bisecting is awful.

✅ Correct: bug fix and refactor as separate PRs

PR 1: "Fix Order.Total calculation off-by-one (issue #1234)"
  Changed files: 2 (Order.cs + a regression test). 15 lines total.

PR 2: "Refactor: extract OrderTotalCalculator from OrderService"
  Changed files: 5. No behavior change; characterization tests still pass.

Reviewable, revertable, traceable.

❌ Wrong: rewrite under the "we know better now" banner

Q1: "We need to rewrite OrderService — it's spaghetti."
Q3: New OrderService is at 60% parity. Legacy has shipped 12 new features. Parity gap is wider.
Q4: Rewrite cancelled; team morale damaged; legacy in worse shape than at Q1.

✅ Correct: strangler-fig + targeted refactor

Sprint 1: Put YARP in front of OrderService. 100% traffic still to legacy.
Sprint 2: Migrate `GET /orders/{id}` to new service. Read-only, low risk.
Sprint 3-N: Migrate one slice per sprint. Refactor the in-place parts that need bug fixes.
Eventually: Legacy holds nothing material; turn it off.

Continuous value; bounded risk; the legacy keeps working until it doesn't need to.


Design patterns for this topic

Pattern 1 — "Legacy = untested" (Feathers)

  • Intent: define legacy by what enables safe change, not by code aesthetics or age.

Pattern 2 — "Seam, characterization test, then refactor"

  • Intent: never modify untested code without a safety net.

Pattern 3 — "Refactor in the path of change"

  • Intent: improve the 20% of the codebase that actually changes; ignore the rest.

Pattern 4 — "Preparatory refactor (Beck)"

  • Intent: make the change easy, then make the easy change — the preparatory PR is sometimes bigger than the feature PR.

Pattern 5 — "Mikado Method"

  • Intent: when a goal is tangled, walk its dependency graph backward to find the leaves you can actually start with.

Pattern 6 — "Strangler-fig over rewrite"

  • Intent: replace incrementally with production traffic, never with a months-long parallel-build that hopes to reach parity.

Pattern 7 — "Sustained engineering budget over debt sprints"

  • Intent: 15-20% continuous improvement beats heroic refactor weeks.

Pros & cons / trade-offs

Approach Pros Cons
Refactor in place (with tests) Low risk; continuous progress; preserves all current behavior Slow on large surfaces; needs discipline
Big-bang rewrite "Clean slate" appeal High failure rate; parity gap; no incremental value
Strangler-fig Continuous value; bounded risk per slice Slow; needs leadership patience; never "feels done"
Sustained engineering budget Continuous; no morale-damaging "we paused features" Easy to deprioritize sprint by sprint
Debt sprints Concentrated effort Pauses features; team fatigue; one-shot — what about next quarter?
"Leave it alone" Zero risk Compound interest on debt; code dies of neglect

When to use / when to avoid

  • Use seam + characterization test + refactor whenever you must change untested code.
  • Use preparatory refactor when the feature change would be ugly in the current shape but easy after a small reshape.
  • Use strangler-fig when the legacy is too large or risky to refactor in place but must be replaced.
  • Use Mikado when "I can't see how to start" — it surfaces the leaves you can start with.
  • Avoid rewrites unless the legacy is small, well-understood, and replaceable in under a quarter.
  • Avoid "while I'm in here" — bug fixes and refactors travel in separate PRs.
  • Avoid refactoring code that doesn't change — the cost-benefit is negative.
  • Avoid debt sprints as the primary mechanism — they burn out teams and ignore the steady-state problem.

Interview Q&A

Q1. What's the senior definition of "legacy code"? Untested code (Feathers). Not "old," not "ugly" — untested. The defining property is "you can't safely change it," and that's what tests give you back.

Q2. What's a characterization test? A test that pins current behavior, including bugs. It's the safety net you put under code before refactoring. If the legacy returns the wrong answer in some edge case, the characterization test asserts that wrong answer — the bug fix is a separate, later change.

Q3. What's a seam? A place in the code where you can substitute behavior without modifying the production code there — an injected dependency, a virtual method, an InternalsVisibleTo exposure. Feathers' book is the canonical reference.

Q4. Why is "rewrite from scratch" usually wrong? The legacy keeps shipping features during the rewrite, so the parity gap grows; the new system can't reach parity until it ships, so there's no incremental value; and the team doesn't fully understand the legacy until they've tried to recreate it. Most rewrites either ship incomplete or are cancelled.

Q5. When is rewrite actually the right call? When the legacy is small, well-understood, and parity is reachable in less than a quarter (so the legacy doesn't move meaningfully during the rewrite). That's a narrow slice of "we need to rewrite" claims.

Q6. What's the Mikado Method? A technique for tangled refactors: try the goal change; if it doesn't work, identify what would have to be true first (prerequisites); recurse until you reach a leaf change that is possible now; start there. Often, completing leaves reveals that half the tree was unnecessary.

Q7. Refactor in the path of change vs scheduled refactor sprints? The path-of-change pattern (every PR leaves the touched code a little better) plus a sustained 15-20% engineering budget consistently outperforms "we'll have a refactor sprint." Sprints pause features, exhaust people, and don't address steady-state debt.

Q8. How do you decide whether to refactor or strangle? Refactor when the code is salvageable, well-understood, and core to active development. Strangle (replace incrementally) when the code is unsalvageable but feature-critical. Leave alone when the code is bad but doesn't change.

Q9. What's wrong with "while I'm in here, I rewrote the module"? Reviewable PRs change one thing at a time. Mixing a bug fix with a 5-file refactor makes the diff unreviewable, hides which change caused which regression, and is awful to bisect when a problem appears two weeks later.

Q10. How do you find which code to refactor first? Combine git log --stat (which files change most), incident history (which files appear in postmortems), and grep for "TODO" / "HACK" clusters. The 20% of the codebase that changes the most and breaks the most is where engineering budget pays off.

Q11. How do you sell a non-engineer on refactor work? Translate it into cost ("we lose ~10 engineer-hours per sprint to this") and risk ("the last three incidents touched this code"). "It's messy" doesn't compete with features that have revenue projections; "we recover 10 hours per sprint" does. See Tech-Debt Management.

Q12. What's the relationship between refactoring and DDD? DDD's tactical patterns (aggregates, value objects, repositories) often emerge from refactoring rather than being designed up front. A pile of CRUD methods, refactored carefully, eventually grows aggregates as the domain logic clusters. "Refactor toward DDD" is a credible path; "design with DDD first" works only when the domain is well-understood.


Gotchas / common mistakes

  • ⚠️ Refactoring without a test first — every change is a gamble; subtle regressions ship silently.
  • ⚠️ Asserting "what it should do" in a characterization test — defeats the purpose; the test must capture what the code does today, bugs included.
  • ⚠️ Bug fix + refactor in one PR — unreviewable, unbisectable, regression risk doubles.
  • ⚠️ "While I'm in here" creep — touching files outside the PR's stated scope.
  • ⚠️ Rewriting a system in production — feature parity is a moving target; you'll lose the race.
  • ⚠️ Refactoring stable, rarely-changed code — pure cost; no benefit. Find the 20% that hurts.
  • ⚠️ Speculative abstractions during refactor — introducing interfaces for hypothetical second implementations. Wait for the second case.
  • ⚠️ No engineering budget; just "we'll find time" — you won't. Make it explicit; track it.
  • ⚠️ Treating SonarQube's 4,000 issues as a roadmap — it isn't one; prioritize by churn and pain, not by lint score.
  • ⚠️ Refactoring code already targeted for strangler-fig replacement — wasted effort.

Further reading