📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

How do you plan and execute a large refactoring in a production .NET system without stopping feature delivery?

Refactoring strategy: characterisation tests, strangler fig, branch by abstraction, parallel change, feature flags, small pull requests, measuring progress, and when to stop.

Senior Architecture Advanced design-judgment legacy-code refactoring strangler-fig

🎯 Key points

  • Refactoring changes structure without changing behaviour; keep it separate from feature changes
  • Start with characterisation tests around the code you will change
  • Use strangler fig, branch by abstraction or parallel change (expand/contract) for large moves
  • Ship small pull requests continuously; avoid long-lived refactoring branches
  • Feature flags and shadow traffic let old and new paths run side by side
  • Prioritise by change frequency and pain (hotspots), and define when you are done

⏱ 30-second answer

I treat a large refactoring as a series of small, behaviour-preserving steps merged to main continuously, not a big-bang branch. First I pin current behaviour with characterisation tests. Then I pick a technique: strangler fig to route traffic piece by piece to a new implementation, branch by abstraction to put an interface in front of the old code and swap implementations behind it, or parallel change (expand, migrate, contract) for APIs and database schemas. Feature flags and shadow comparisons let old and new paths run side by side. I prioritise hotspots (code that changes often and hurts), keep refactoring commits separate from feature commits, and define an end state so it does not drag on forever.

πŸ—£ Full answer (senior)

How I would answer this in an interview

"I treat a large refactoring as many small, behaviour-preserving steps merged to main continuously, never a long-lived branch or a feature freeze. First I choose where: hotspots, meaning code that changes often and causes pain (Git churn Γ— complexity, incident history), because refactoring stable code returns little. I define done: target structure, measures of success, and what's explicitly out of scope. Then I build a safety net: characterisation tests that pin current behaviour (approval/snapshot tests built from production samples) and contract tests for APIs. I pick the technique by scope. Branch by abstraction puts an interface in front of the old component and swaps implementations behind it. Strangler fig routes traffic piece by piece from a legacy app to new code, often through a YARP reverse proxy. Parallel change (expand β†’ migrate β†’ contract) handles APIs, database columns and message schemas. Old and new paths run side by side behind feature flags, with shadow comparisons logging any differences before we switch. Refactoring PRs stay separate from feature PRs, progress is visible, and the work ends when the old path and its flag are deleted."


1. Step by step

Step What I do
1. Why and where Hotspot analysis (churn Γ— complexity), incidents, lead time for changes in that area
2. Define done Target structure, success measures (defects, lead time, build time), explicit non-goals
3. Safety net Characterisation/approval tests (e.g. Verify), contract tests, observability on the area
4. Choose technique Branch by abstraction / strangler fig / parallel change / Mikado
5. Run side by side Feature flags (Microsoft.FeatureManagement), shadow comparison, gradual rollout
6. Small PRs Merged daily; refactoring separate from behaviour changes
7. Switch and delete Remove old path and flag once proven; update docs/ADRs
8. Measure Compare before/after metrics; stop when the pain is gone

2. Techniques by scope

Scope Technique How
Method/class IDE refactorings + unit tests Extract, move, rename in small commits
Internal component Branch by abstraction Interface in front of old code β†’ new implementation behind it β†’ flag β†’ switch β†’ delete old
Public API, DB column, message field Parallel change (expand/contract) Add new alongside old β†’ migrate consumers/data β†’ remove old later
Whole legacy app/subsystem Strangler fig Proxy (YARP) routes endpoints one by one to the new system
Tangled change with unclear order Mikado method Try the goal, note what breaks, revert, solve prerequisites first

3. Keeping feature delivery going

Practice Why
Reserve a steady share of capacity (e.g. [20%]) Avoids a separate "refactoring project" that gets cancelled
Trunk-based development + flags No merge hell; everything always shippable
Feature work routed through the new structure first Each feature advances the migration
Visible progress (endpoints migrated, % traffic) Keeps stakeholder support
Clear exit criteria Prevents endless "two implementations" limbo

Production example

Our [12-year-old] pricing engine lived in a [4,000-line] class: every pricing change took [weeks] and regularly caused regressions, and Git hotspot analysis showed it was both the most-changed and most-defective file. We captured [about 600] real pricing requests from production as approval tests, introduced IPriceCalculator in front of the old class (branch by abstraction), and built a rules-based calculator behind a feature flag. A shadow decorator returned the legacy result while running the new one and logging any mismatch. We fixed differences as they appeared, ramped shadow traffic to [100%], and after [three weeks] with zero mismatches switched over and deleted the old class and the flag. Feature work continued throughout: new pricing rules were built only in the new engine, which pulled the migration forward.

πŸ” What the interviewer is checking

Checks ability to change production systems safely at scale.

Green flags

  • Starts with characterisation tests.
  • Names strangler fig, branch by abstraction and parallel change correctly.
  • Uses flags and shadow comparisons.
  • Prioritises hotspots and defines done.

Red flags

  • Proposes a months-long branch or feature freeze.
  • No safety net.

πŸ“š Learn in depth

1. Sources

Technique Source
Refactoring (behaviour-preserving changes) Martin Fowler, Refactoring
Characterisation tests, seams Michael Feathers, Working Effectively with Legacy Code
Strangler fig Martin Fowler (2004)
Branch by abstraction Paul Hammant; Jez Humble
Parallel change (expand/contract) Danilo Sato / Fowler bliki
Mikado method Ola EllnestΓ₯m & Daniel Brolund
Hotspots (churn Γ— complexity) Adam Tornhill, Your Code as a Crime Scene

2. Finding hotspots

# Most frequently changed files in the last year
git log --since="12 months ago" --format=format: --name-only | grep -v '^$' | sort | uniq -c | sort -rn | head -20

Combine with size/complexity (lines, cyclomatic complexity) and defect history. High churn + high complexity + many bugs = refactor first. Tools like CodeScene automate this.

3. Characterisation tests

  • Record what the code does now, right or wrong.
  • Approval/snapshot libraries (Verify is common in .NET; Snapshooter and others exist) write the first output to a file and fail when it changes.
  • Feed them real inputs (sampled, anonymised production requests) for broad coverage quickly.
  • Bugs found are logged and fixed separately, so the refactoring itself stays behaviour-preserving.

4. Parallel change for a database column

Phase Change Deploys
Expand Add DeliveryState column; write to both old and new 1
Migrate Backfill; switch reads to the new column 2
Contract Stop writing the old column 3
Clean up Drop the old column (after a safe period) 4

Every deploy is backward compatible, so rollback is always possible. The same pattern works for API fields and message schemas.

5. Strangler fig with YARP

{
  "ReverseProxy": {
    "Routes": {
      "orders": { "ClusterId": "new",    "Match": { "Path": "/orders/{**rest}" } },
      "legacy": { "ClusterId": "legacy", "Match": { "Path": "{**catch-all}" }, "Order": 1000 }
    },
    "Clusters": {
      "new":    { "Destinations": { "d1": { "Address": "https://orders-new.internal/" } } },
      "legacy": { "Destinations": { "d1": { "Address": "https://legacy.internal/" } } }
    }
  }
}

Routes move to the new cluster one at a time; the catch-all keeps everything else on the legacy app until it can be retired. Microsoft's migration guidance for ASP.NET Framework β†’ ASP.NET Core uses this approach (with System.Web adapters for shared session/auth).

6. Shadow comparison

  • Run the new implementation alongside the old, return the old result, log differences.
  • Sample traffic if the new path is expensive; never let the shadow path's failures affect users (catch and log).
  • Don't shadow side-effecting operations (payments, emails) without making the candidate side-effect free.

7. Keeping momentum and knowing when to stop

  • Track progress visibly (endpoints migrated, % traffic on new path, lines of legacy remaining).
  • Each new feature in the area goes through the new structure.
  • Stop when the hotspot pain is gone; perfection isn't the goal.
  • Delete old paths and flags promptly: lingering flags are debt.

8. Try it yourself

  1. Run the hotspot command above on your repo and pick the top candidate.
  2. Write three characterisation tests for it from real inputs.
  3. Introduce an interface in front of it and register a shadow decorator (as in the code example) behind a flag.
  4. Plan a parallel change for one column rename as four separate deploys.
  5. Set up YARP locally to route one path to a new app and everything else to an old one.

πŸ’» Code

// Branch by abstraction with a shadow comparison.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

IPriceCalculator calculator = new ShadowPriceCalculator(
    primary: new LegacyPriceCalculator(),
    candidate: new RulesPriceCalculator(),
    logger: NullLogger<ShadowPriceCalculator>.Instance);

Console.WriteLine(calculator.Price(new PriceRequest("SKU-1", 3, "GOLD")));

public sealed record PriceRequest(string Sku, int Quantity, string Tier);

public interface IPriceCalculator { decimal Price(PriceRequest request); }

public sealed class LegacyPriceCalculator : IPriceCalculator
{
    public decimal Price(PriceRequest r) => r.Quantity * 10m * (r.Tier == "GOLD" ? 0.9m : 1m);
}

public sealed class RulesPriceCalculator : IPriceCalculator
{
    private static readonly Dictionary<string, decimal> TierDiscount = new() { ["GOLD"] = 0.10m };
    public decimal Price(PriceRequest r) =>
        r.Quantity * 10m * (1 - TierDiscount.GetValueOrDefault(r.Tier));
}

// Returns the legacy result (behaviour unchanged) and logs any difference.
public sealed class ShadowPriceCalculator(
    IPriceCalculator primary, IPriceCalculator candidate, ILogger<ShadowPriceCalculator> logger) : IPriceCalculator
{
    public decimal Price(PriceRequest request)
    {
        var expected = primary.Price(request);
        try
        {
            var actual = candidate.Price(request);
            if (actual != expected)
                logger.LogWarning("Price mismatch for {Sku}: legacy {Legacy}, new {New}", request.Sku, expected, actual);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "New calculator failed for {Sku}", request.Sku);
        }
        return expected;
    }
}

Requires Microsoft.Extensions.Logging.Abstractions. In production, register the shadow decorator behind a feature flag and sample traffic if the candidate is expensive.

⚠ Common mistakes

1. Long-lived refactoring branches

What happens: a branch diverges for weeks or months. Why it is wrong: painful merges; the work is often abandoned. Do instead: merge small steps daily using branch by abstraction and flags.

2. Refactoring without tests

What happens: "we'll test it at the end". Why it is wrong: you can't tell whether behaviour changed. Do instead: characterisation tests first.

3. Mixing behaviour changes into refactoring

What happens: a refactoring PR also fixes bugs and adds features. Why it is wrong: reviewers can't verify either; rollbacks remove both. Do instead: separate refactoring and feature PRs.

4. Refactoring code that rarely changes

What happens: effort spent on stable, ugly code. Why it is wrong: little return; risk without benefit. Do instead: target hotspots.

5. Never removing the old path

What happens: both implementations and the flag remain for months. Why it is wrong: double maintenance; confusing behaviour. Do instead: exit criteria; delete old code and flags.

6. Feature freeze for refactoring

What happens: "no features for a quarter while we clean up". Why it is wrong: loses business support; big-bang risk. Do instead: steady capacity share and features built on the new path.

7. Breaking changes in one deploy

What happens: column renamed and code updated in the same release. Why it is wrong: rollbacks or rolling deploys break. Do instead: expand/contract across several deploys.

8. Shadowing side-effecting operations

What happens: the candidate path sends emails or charges cards. Why it is wrong: duplicate side effects in production. Do instead: make the shadow path side-effect free or compare only computed outputs.

9. Fixing discovered bugs silently

What happens: characterisation tests reveal a bug and it's "fixed" during the refactor. Why it is wrong: behaviour change hidden in a refactoring; surprises consumers. Do instead: log it, decide separately, fix in its own change.

10. No definition of done

What happens: the refactoring keeps expanding in scope. Why it is wrong: never finishes; trust erodes. Do instead: target structure, success metrics and non-goals agreed upfront.

πŸ› Senior insight: trade-offs

Large refactorings fail from lost momentum and risk, not from technical difficulty. I make them boring: small steps, always shippable, measured progress and a clear finish line.

Refactoring scope Technique
Method or class IDE refactorings + unit tests
Internal component Branch by abstraction + shadow comparison
Public API or schema Parallel change (expand/contract)
Whole legacy app Strangler fig with YARP
Tangled dependencies Mikado method to find the order

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate prioritises hotspots Why: Probes technical debt management
    How to answer

    Technical debt is the future cost of a shortcut, and the interest is the extra effort or risk it adds every time we work near it. I make it visible in a debt register with each item's impact, how often we pay its interest (change frequency, incidents, delays) and a rough cost to fix. I prioritise by interest, not by how ugly the code is: debt in a hotspot that slows every feature beats a messy module nobody touches. I get time by explaining it in business terms (lead time, incident risk, cost), by reserving a steady share of capacity, and by pairing debt fixes with features that touch the same area.

  2. When: Candidate uses strangler fig for big shifts Why: Moves to evolutionary architecture
    How to answer

    Evolutionary architecture (Ford, Parsons and Kua) is architecture designed to support guided, incremental change, rather than a fixed upfront design. The key tool is fitness functions: automated checks that protect the characteristics you care about, such as dependency rules (architecture tests), latency budgets, or startup time, run in CI or production. Around that, I keep boundaries modular (often a modular monolith first), make decisions reversible where possible and defer irreversible ones to the last responsible moment, record decisions in ADRs, and rely on continuous delivery, feature flags and observability so each change is small and safe.

  3. When: Candidate refactors to remove layers Why: Checks over-engineering detection
    How to answer

    Over-engineering is complexity that today's requirements do not justify. I detect it through symptoms with numbers behind them: a one-field change touching many files, interfaces with one implementation, configuration options nobody sets, patterns like CQRS or event sourcing without the problems they solve, and microservices that always deploy together. I reverse it incrementally rather than with a rewrite: inline single-use abstractions, merge layers that only pass data through, consolidate services that share a release cycle, and delete unused options, each step behind tests. I also address why it happened, because otherwise it grows back.

πŸ›  Real-world problems: how you handle them

Pricing engine replaced in shadow mode
Situation
A [12-year-old] [4,000-line] pricing class blocked new pricing features.
Signals
Every pricing change took [weeks] and caused regressions; developers avoided touching it.
Investigation
Git hotspot analysis showed it was the most-changed and most-defective file.
Resolution
Characterisation tests from production samples, branch by abstraction, new rules engine in shadow mode, switch after zero mismatches.
Prevention
Pricing changes now require tests against the sample set; old class deleted.

How to tell it (STAR)

Our pricing engine was a [4,000-line] class nobody wanted to touch. I captured [600] real pricing cases as tests, put an interface in front of the old code, and ran a new calculator alongside it in shadow mode, logging every difference. After [three] weeks with no differences we switched over and deleted the old code, without pausing feature work.

↩ Usually asked after

More in Senior-Level Judgment

Last updated 31 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me