Back to Blog
.NETLegacy ModernizationMigrationCostArchitectureTechnical Debt

Rewrite or Refactor? A Decision Framework for Legacy .NET Apps

Every legacy system eventually prompts the same meeting. Someone says the code is unmaintainable, someone else says a rewrite is career suicide, and nothing gets decided. Here's a framework that produces an actual answer — five diagnostic questions, the middle path most teams should take, and the three situations where a full rewrite genuinely is correct.

23 September 20268 min read

There is a meeting that happens in every company running software older than about eight years. A developer says the codebase is unmaintainable and needs a rewrite. Someone who has lived through a rewrite says absolutely not. A finance person asks what it costs and gets two answers that differ by a factor of five. The meeting ends, nothing is decided, and the same meeting happens again in four months.

The reason it never resolves is that "should we rewrite it?" is the wrong question. It has no answer without context, so everyone argues from instinct. Here is a set of questions that does have answers.

First, why rewrites fail so reliably

Rewrites do not fail because the new technology is bad or the developers are weak. They fail because of a specific and predictable problem: the old system encodes years of requirements that nobody wrote down.

That strange conditional in the invoicing module is not sloppy code. It is a tax rule from 2019 that applies to one client in one state. The retry loop with a hardcoded three-second delay exists because a vendor's API used to fall over. None of this is in a document. It is in the code, and only in the code.

A rewrite starts by throwing that away, then rediscovers it one production incident at a time over the following eighteen months. That is the real cost — not the development, the rediscovery.

A legacy system is not bad code. It is a requirements document that happens to be executable — and it is the only copy.— Kathan N. Patel

The five diagnostic questions

Answer these honestly. They are ordered by how much they should influence the decision.

1. Can you still deploy it?

Not "is it pleasant to deploy" — can you deploy it at all, today, without the one person who knows the ritual? If deployment requires a specific machine, an undocumented sequence, or a colleague who left, you do not have a code problem. You have an operational emergency, and it is fixable in weeks without touching application logic. Fix this first regardless of what you decide about the rest.

2. Is the pain in the whole system or in three modules?

Ask the team where the bugs actually come from. In most legacy systems the answer clusters hard — sixty to eighty percent of incidents trace to a small number of modules. If that is your shape, a rewrite is spectacular overkill. You are proposing to replace the whole house because two rooms have damp.

If the pain is genuinely uniform across the system, that is a different signal, and it usually means an architectural problem rather than a code-quality problem.

3. Is the platform itself out of support?

This is the question that converts a preference into a deadline. .NET Framework 4.8 still receives security updates as a Windows component, but the ecosystem has moved — new libraries increasingly ship .NET-only, and hiring for Framework-era work gets harder every year. If you are on something genuinely out of support, the decision has been made for you and the only question left is the route.

4. Do you have tests, or do you have a QA person?

This one is decisive and people skip it. Refactoring without a test suite is not refactoring — it is editing and hoping. If you have no automated tests, the honest first project is not a rewrite or a refactor. It is characterisation tests around the modules you intend to change: tests that assert what the system currently does, correct or not, so you can tell whether you broke it.

Teams that skip this step are the ones whose refactor turns into an unplanned rewrite six weeks in.

5. Is the business logic still correct?

The one case where a rewrite is clearly right: the system does the wrong thing. Not badly — wrongly. The business model changed, the regulations changed, the product pivoted, and the software still encodes the old world. Preserving that behaviour is not a virtue. Here, a rewrite is not a technical decision at all; you are building new software that happens to replace something.

Reading your answers

Refactor if the pain clusters in a few modules, the platform is supported, and the business logic is still correct. This is most systems, and it is the answer people resist because it is unglamorous.

Strangle — the middle path, covered below — if the platform is aging or the architecture is the problem, but the business logic is still valuable and largely undocumented. This is the right answer far more often than either extreme.

Rewrite only if the business logic itself is obsolete, or the system is small enough that a rewrite is measured in weeks, or the platform is genuinely dead with no migration path. Three narrow cases.

The strangler fig: what most teams should actually do

Named after the fig that grows around a host tree until it can stand on its own, the pattern is straightforward: put a routing layer in front of the old system, then move functionality across one slice at a time. Both systems run simultaneously. Every slice you move is live, in production, earning its keep — and if a slice goes badly you have moved one thing, not everything.

In .NET this is unusually practical, because YARP gives you the routing layer with very little code:

// Program.cs — the strangler facade.
// Everything goes to the legacy app except routes explicitly claimed
// by the new one. Moving a slice is a config change, not a deploy of
// two systems in lockstep.
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

// Claimed slices — handled by the new .NET 10 modules.
app.MapControllers();

// Everything else still falls through to the legacy application.
app.MapReverseProxy();

app.Run();
// appsettings.json — one entry per slice still living in the old app.
// Delete an entry when the new implementation takes over that route.
{
  "ReverseProxy": {
    "Routes": {
      "legacy-catch-all": {
        "ClusterId": "legacy",
        "Match": { "Path": "{**catch-all}" }
      }
    },
    "Clusters": {
      "legacy": {
        "Destinations": {
          "legacy-app": { "Address": "http://legacy-internal:8080/" }
        }
      }
    }
  }
}

The property that matters here is not technical elegance. It is that the project can be stopped at any point and you still have a working system with real value delivered. A big-bang rewrite has exactly one moment where it delivers value, and it is at the end, and it is always later than planned.

What each route costs, roughly

Indicative bands for a mid-size line-of-business application — call it 15 to 30 screens, one database, a handful of integrations. Your numbers will differ, but the ratios hold reasonably well.

Stabilise only — fix the build, automate deployment, add characterisation tests around the worst modules. Two to four weeks. Often the highest return per pound spent, and it is a prerequisite for everything else anyway.

Targeted refactor — rework the two or three modules generating most incidents, behind the tests you just wrote. Four to ten weeks. Resolves most of the felt pain in most systems.

Strangler migration — incremental replacement onto modern .NET, slice by slice. Four to twelve months elapsed, but spread across releases and stoppable at any point. Costs more in total than a rewrite on paper; costs dramatically less in practice, because the rediscovery problem never happens all at once.

Full rewrite — six to eighteen months with no value delivered until late, plus a rediscovery tail that nobody budgets. Reserve for the three cases above. For a detailed cost breakdown of the migration route specifically, I've written that up separately in the real costs of migrating from .NET Framework to modern .NET.

What to do in the next thirty days

Whichever way you are leaning, these four things are useful in every scenario and none of them commit you to a direction.

1. Get the build and deployment automated and documented, so no single person is a dependency.
2. Pull three months of incident history and find out where bugs actually originate. Opinions about which module is worst are frequently wrong.
3. Write characterisation tests around the two modules that history points at.
4. Write down the undocumented business rules the team knows about. This is the single highest-value hour a legacy team can spend, and it makes every subsequent option cheaper.

After that month you will have data instead of opinions, and the meeting resolves itself.


If you're weighing this decision on a real system and want a second opinion from someone with no stake in the answer, tell me about it. You can also get a ballpark on either route with the free project cost estimator.

Found this useful?

Share it with your network — it helps others find this too.

https://kathanpatel.vercel.app/blog/rewrite-or-refactor-legacy-dotnet-decision-framework

Planning a project?

Get a realistic budget in 60 seconds

Describe your project once and get an instant AI-generated cost range with a phase-by-phase breakdown — free, no sign-up.