Every legacy .NET Framework system we've inherited has the same story: it works, it's profitable, and nobody wants to touch it. That's exactly why migrations fail when they're treated as a single heroic rewrite. The teams that succeed treat modernization as a series of small, reversible steps that ship alongside normal feature work.
- Inventory dependencies before writing a single line of migration code — third-party package compatibility is usually the real blocker, not your own code.
- Multi-target your class libraries (
net48;net10.0) so you can migrate layer by layer instead of all at once. - Use the strangler-fig pattern with a reverse proxy to run old and new side by side in production.
- Build a characterization test suite before refactoring business logic you don't fully trust.
Why this migration is worth the effort
.NET Framework 4.8 is the last version of its line — there is no .NET Framework 5. It still receives security patches, but no new features, no performance investment, and none of the runtime and tooling improvements that have landed in every release since .NET Core 3.1. .NET 10 is the current long-term support release, and it's meaningfully faster, runs on Linux and in containers, and unlocks years of C# language features your team hasn't been able to use.
None of that matters if the migration takes down production or stalls the roadmap for two quarters. The plan below is the one we use with clients running everything from internal line-of-business apps to customer-facing platforms processing real revenue.
Step 1: Inventory before you touch code
Start with an honest audit of what the application actually depends on:
- NuGet and GAC dependencies — run the .NET Upgrade Assistant's analysis mode against each project to see which packages already ship .NET Standard 2.0 or .NET 10 builds.
- Windows-only APIs —
System.Drawing.Common,System.Web, WCF server hosting, and COM interop are the usual suspects that need a replacement strategy, not just a recompile. - Configuration —
Web.config/App.configtransforms need to becomeappsettings.jsonplus the options pattern. - Session and identity — anything built on
System.Web.HttpContext.Current, Forms Authentication, orMembershipproviders needs an ASP.NET Core equivalent.
This step alone usually reshapes the whole plan. A codebase with a handful of Windows-only dependencies concentrated in one module is a very different project from one where HttpContext.Current is scattered through two hundred files.
Step 2: Multi-target your class libraries
Before migrating any web or worker project, get your shared libraries building against both frameworks at once. This lets you fix cross-cutting issues — nullable reference warnings, removed APIs, behavioral differences — while the applications that consume them keep running on .NET Framework.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net48;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="System.Text.Json" Version="8.0.0" />
</ItemGroup>
</Project>
Class libraries are almost always your quickest win here — no hosting model, no Startup.cs, just a project file change and a compile pass.
Step 3: Use the strangler-fig pattern for the application shell
For anything bigger than a small internal tool, don't attempt an in-place conversion of the web project. Stand up a new ASP.NET Core 8 application next to the existing one, and route traffic between them with a reverse proxy. YARP (Yet Another Reverse Proxy) is the tool we reach for — it's a .NET library, so it fits naturally into a .NET-heavy stack.
{
"ReverseProxy": {
"Routes": {
"new-checkout": {
"ClusterId": "dotnet8",
"Match": { "Path": "/checkout/{**catch-all}" }
},
"legacy-catch-all": {
"ClusterId": "netframework48",
"Match": { "Path": "/{**catch-all}" }
}
},
"Clusters": {
"dotnet8": {
"Destinations": { "d1": { "Address": "http://localhost:5080/" } }
},
"netframework48": {
"Destinations": { "d1": { "Address": "http://localhost:8080/" } }
}
}
}
}
Each feature area you migrate becomes a new route pointing at the .NET 10 cluster. The legacy application keeps serving everything you haven't migrated yet, and you can roll a route back to the old cluster in seconds if something's wrong.
Step 4: Replace Service Locator with constructor injection incrementally
Older .NET Framework apps frequently lean on static service locators or HttpContext.Current lookups. ASP.NET Core's built-in DI container expects constructor injection. You don't need to refactor everything on day one — wrap the old locator behind an adapter that also registers with the new container, then migrate call sites feature by feature as you touch that code anyway.
Step 5: Build a characterization safety net
For business logic you don't fully understand — and in a legacy system, there's always some — write characterization tests before you refactor: capture the current inputs and outputs as they are today, not as you think they should be. These tests aren't about correctness; they're a tripwire that tells you the moment a refactor changes behavior.
Step 6: Parallel run, then cut over
For anything customer-facing or revenue-bearing, run the new .NET 10 path behind a feature flag and mirror a percentage of production traffic to it, comparing outputs without serving the response from the new path yet. Once you trust the parity, flip the flag for a small cohort, then ramp up gradually.
.NET Framework 4.8 vs .NET 10, at a glance
| .NET Framework 4.8 | .NET 10 | |
|---|---|---|
| Support | Security fixes only | Long-term support |
| Platforms | Windows only | Windows, Linux, macOS |
| Hosting | IIS | Kestrel, containers, IIS |
| Performance | Baseline | Significantly faster GC and JIT |
| Language | C# 7.3 max | C# 14 |
Migration isn't free, but it's rarely as expensive as the sunk-cost anxiety makes it feel — especially once you stop thinking of it as one project and start treating it as a standing practice that runs alongside your regular roadmap.