Steven's Knowledge

Migrating to Modern .NET

Moving off .NET Framework -- why it matters, the blockers (System.Web, WCF, config), and an incremental strangler-fig strategy that keeps the lights on

Migrating to Modern .NET

Walk into almost any New Zealand government department, council, bank, or established enterprise and you will find a fleet of systems running on .NET Framework 4.x. These applications are not toys -- they run payroll, consenting, claims, billing, and case management, and they have run reliably for a decade or more. The most valuable C# work in this market is rarely greenfield. It is the careful, low-risk job of modernizing these systems onto modern .NET (8 or 9) without breaking them. The engineer who can lead that work -- who can de-risk a migration that the business is terrified to touch -- is worth considerably more than one who only writes new code on a clean slate. This article is the playbook for that engineer.

Why Migrate

.NET Framework 4.8 is the end of the line. There will never be a 4.9. It still receives security patches because it ships with Windows, but it gets no new features, no performance work, and no new APIs -- ever. Everything Microsoft invests in now lands in modern .NET, the unified cross-platform runtime that succeeded both .NET Core and .NET Framework.

The upside of moving is concrete, not theoretical:

  • Cross-platform. Modern .NET runs on Linux. That means cheap container hosting, Kubernetes, and the entire cloud-native ecosystem instead of expensive Windows VMs licensed per core.
  • Performance. Each release has delivered real throughput and latency gains; for many workloads modern .NET is two to three times faster than Framework, and sometimes far more, with a fraction of the allocations.
  • Native AOT. You can compile to a self-contained native binary with near-instant startup and a tiny memory footprint -- ideal for serverless and high-density hosting.
  • Modern C#. Records, pattern matching, nullable reference types, required members, collection expressions, and the rest of the language only fully light up on modern targets.
  • A healthier ecosystem. New libraries target modern .NET first; many no longer ship Framework-compatible builds at all.

The risk of staying put is slow ossification. Hosting costs rise, the hiring pool shrinks every year as engineers move on from Framework, and sooner or later a critical dependency stops shipping a build you can use. Nothing breaks on any single day -- which is exactly why it is dangerous. The decay is invisible until the day you genuinely cannot upgrade a security-critical package, and by then the migration is an emergency instead of a project.

What Actually Blocks You

Most of a codebase ports with surprisingly little drama. The value you bring is knowing where the genuine walls are, because those are what turn an estimate from weeks into quarters.

  • System.Web / classic ASP.NET (WebForms, MVC 5, Web API 2). There is no direct port of System.Web. WebForms in particular has no successor on modern .NET at all -- you rewrite to Razor Pages, Blazor, MVC Core, or a separate SPA front end. ASP.NET MVC 5 and Web API 2 map reasonably onto ASP.NET Core: controllers, action results, and routing concepts survive. But the request pipeline, HttpContext, model binding, and the startup model are all different, so it is a port-with-rewrite rather than a recompile.
  • WCF. Server-side WCF is not in modern .NET. Your options are CoreWCF, the community port that gives you a bridge to keep existing SOAP contracts alive while you migrate, or a clean move to gRPC or REST. On the client side, the System.ServiceModel.* NuGet packages let modern .NET call existing WCF services, which is often enough to unblock a consumer.
  • AppDomains, Remoting, and Windows-only APIs. There are no AppDomains in modern .NET; if you load and unload plugin assemblies, you move to AssemblyLoadContext. .NET Remoting is gone entirely with no replacement -- code that uses it must be redesigned around a real RPC mechanism. System.Drawing.Common is supported only on Windows, so image or PDF generation that relies on it needs a cross-platform library like ImageSharp or SkiaSharp.
  • web.config / machine.config. The whole XML configuration world is replaced by the layered configuration system feeding appsettings.json, environment variables, and other providers. web.config transformations, <system.web> settings, and <appSettings> do not carry over -- you re-express them. The one place web.config survives is hosting under IIS, where a slim version still configures the ASP.NET Core Module.
  • Third-party / NuGet dependencies. Audit every package for a target of .NET Standard 2.0 or modern .NET. The dependency with no modern build is frequently the real blocker -- an unmaintained logging or reporting library that nothing else can replace cheaply. Find these on day one, because a single abandoned package can dictate the entire shape and timeline of the migration.

Tools That Help

You do not do the mechanical parts by hand. The .NET Upgrade Assistant (a CLI tool and Visual Studio extension) automates a large share of the grunt work: retargeting project files, swapping package references, and applying known code transformations. try-convert converts old-style .csproj files into the modern SDK-style project format -- usually your first concrete step. The .NET Portability Analyzer and apicompat flag the specific APIs your code calls that do not exist on the target framework, turning "will this even work" into a finite, reviewable list.

Be honest with yourself and your stakeholders about what these tools do. They perform mechanical lifting, not architectural judgement. They get you perhaps 60 to 80 percent of the way on a straightforward project, and far less on one full of System.Web and WCF. The rewrite of a WebForms page into Razor, or a WCF service into gRPC, is human work. Treat the tooling as an accelerator for the boring parts so you can spend your attention on the parts that actually require thought.

The Incremental Strategy: Strangler Fig

The single most important decision is how you migrate, and the answer for any large system is emphatically not a big-bang rewrite. The from-scratch rewrite is the project that famously kills companies: it runs for two years, delivers nothing in the meantime, the business keeps changing under it, and it is eventually cancelled with the old system still in production. Do not propose it, and push back hard when someone else does.

The pattern that works is the strangler fig -- named for the vine that grows around a tree, gradually replacing it until the original is gone but the shape remains. Applied to a .NET migration:

  1. Convert projects to SDK-style .csproj first. The SDK-style format works on both Framework and modern .NET, so this step is safe and changes no behaviour. Once converted, you can multi-target a single project at both frameworks and compile against each.
  2. Push shared logic down into class libraries targeting .NET Standard 2.0. .NET Standard 2.0 is the common denominator that both .NET Framework 4.8 and modern .NET can consume. Move your domain logic, validation, and services into these libraries so the old and new hosts share exactly the same code rather than a fork.
  3. Stand up a new ASP.NET Core app alongside the old one and put a reverse proxy in front of both. YARP (Yet Another Reverse Proxy) is the .NET-native choice and routes per-path: a handful of endpoints go to the new app while everything else still hits the legacy app, transparently to the user.
  4. Strangle route by route until the new app serves everything and the old one serves nothing, then delete the legacy app.

The multi-targeting step looks like this in the project file:

<PropertyGroup>
  <TargetFrameworks>net48;net9.0</TargetFrameworks>
</PropertyGroup>

And a YARP route that sends one path prefix to the new service while the catch-all stays on legacy:

{
  "ReverseProxy": {
    "Routes": {
      "invoices-new": { "ClusterId": "modern", "Match": { "Path": "/api/invoices/{**rest}" } },
      "legacy-all":   { "ClusterId": "legacy",  "Match": { "Path": "/{**catchall}" } }
    },
    "Clusters": {
      "modern": { "Destinations": { "d1": { "Address": "http://invoices-core:8080/" } } },
      "legacy": { "Destinations": { "d1": { "Address": "http://legacy-app:80/" } } }
    }
  }
}

Why this beats big-bang is not subtle. Every step ships to production, so value is delivered continuously instead of in a terrifying single cutover. Risk is bounded -- the blast radius of migrating one route is one route, and if it misbehaves you point YARP back at the legacy app in seconds. You can pause the effort for a quarter when priorities shift and resume later, because there is no half-finished branch that has to land or die. The business sees steady, auditable progress rather than a long silence followed by a leap of faith.

None of this is safe without tests. The non-negotiable safety net is a comprehensive suite of characterization and integration tests that pin down the current behaviour -- including its quirks -- before you change anything. In modern .NET this is exactly what WebApplicationFactory is built for: it spins up your app in memory and exercises it through real HTTP. Pair it with Testcontainers so each migrated endpoint runs against a real Postgres or SQL Server in a disposable container, not a mock. These tests are how you prove that the new route returns byte-for-byte what the old one did, which is the only thing that lets you cut over with confidence rather than hope.

A Realistic Order of Operations

  1. Inventory every dependency and pick a target -- always an LTS release for systems that need stability.
  2. Convert all projects to SDK-style .csproj while still on Framework.
  3. Extract shared code into .NET Standard 2.0 libraries that both hosts consume.
  4. Replace the blocked APIs -- WCF, System.Web, configuration -- with their modern equivalents.
  5. Stand up YARP and the new ASP.NET Core host in front of the legacy app.
  6. Migrate endpoints incrementally behind the proxy, each one backed by characterization tests.
  7. Cut over the last routes, decommission the legacy app, and drop the multi-targeting.

The NZ Angle

Government departments, councils, and the enterprises that serve them prize change that is low-risk, auditable, and reversible. A two-year rewrite with a single dramatic go-live is the opposite of what these organisations can stomach -- it is unfundable in a world of annual budgets and select-committee scrutiny. The incremental, test-backed, idempotent-deployment approach is therefore not merely the safer engineering choice. It is the approach that actually wins the work, because it speaks the language of risk and accountability that procurement and governance care about. Every fortnight there is a shippable increment, a clear rollback, and an audit trail of exactly what moved and why.

Being the person who can stand in front of a nervous steering committee and credibly de-risk a .NET Framework modernization is a genuine career differentiator -- in Wellington, where so much of the public sector still runs on Framework, and across the wider enterprise market. The technical knowledge of YARP, .NET Standard, and the Upgrade Assistant is table stakes. The real value is the judgement to sequence it so the lights never go out.

On this page