Modernising a Legacy .NET Framework App: Migration Strategy
Most .NET Framework systems still running in production are too important to rewrite and too old to keep extending. This is how our .NET engineers assess them and choose a migration strategy, then move them to the current .NET LTS step by step without stopping feature delivery.
What's in this article
- Why migrate, and why it is not a recompile
- Step 1: Assessment
- Choosing a strategy: in-place, strangler fig or rewrite
- Strangler fig with YARP in practice
- WCF: gRPC, CoreWCF or REST
- Web Forms and MVC: Razor Pages, MVC or Blazor
- EF6 to EF Core
- Removing System.Web, config and DI changes
- Windows to Linux containers
- The safety net: characterisation tests
- Phased cutover plan
- Risks and how we handle them
- FAQs
Why migrate, and why it is not a recompile
.NET Framework 4.8.x is still supported as a Windows component, but it receives no new features. Modern .NET (the current LTS and the releases after it) is where the performance work, language features, container tooling, cross-platform hosting and most new library versions are. Staying on Framework rarely stops the app from working. It gradually cuts off hiring, library upgrades, cheaper Linux hosting and cloud-native deployment.
The hard part is not the C# code. Most business logic compiles on modern .NET with few changes. What doesn't carry over is the application model around it: System.Web and the IIS pipeline, ASP.NET Web Forms, WCF server hosting, web.config-driven configuration, AppDomains, .NET Remoting, and Windows-only APIs such as the registry, WMI, System.Drawing on servers and COM interop. A migration plan is mostly a plan for replacing those parts.
Step 1: Assessment
We scope the assessment as a short, fixed engagement that ends in a written migration plan. Its inputs:
- .NET Upgrade Assistant analysis: run it in analyse mode across the solution. It reports incompatible APIs, package upgrade paths and project-type blockers, and it can export results for tracking. It replaces the older standalone API Portability Analyzer. For binaries without source, we still run API portability analysis against the compiled assemblies.
- Project graph: map every project, what depends on it and its type (class library, ASP.NET MVC, Web API, Web Forms, WCF service, Windows service, console). Libraries with no
System.Webdependency are the first candidates to multi-targetnet48plus the current LTS target framework. - Dependency inventory: every NuGet package, vendored DLL, COM component and GAC reference, with whether a modern .NET version exists, what its licence is and who replaces it if not. In our experience, a single abandoned reporting or PDF component is a common reason for schedule slips.
- Runtime behaviour: session state usage, in-process caching, scheduled tasks hidden in
Application_Start, file-system writes, Windows authentication, MSDTC distributed transactions. - Test coverage and observability: what tells us today that the system is behaving correctly? Usually the answer is "not much", and that drives the characterisation-test work described below.
# Analyse a solution without changing it (Upgrade Assistant CLI)
dotnet tool install -g upgrade-assistant
upgrade-assistant analyze .\Legacy.sln --code --binaries --format sarif
# First low-risk move: multi-target shared libraries
<!-- Directory.Build.props: ModernTfm = the current .NET LTS moniker -->
<TargetFrameworks>net48;$(ModernTfm)</TargetFrameworks>
Choosing a strategy: in-place, strangler fig or rewrite
| In-place upgrade | Strangler fig (incremental, YARP) | Rewrite | |
|---|---|---|---|
| How it works | Convert projects to SDK-style, retarget, fix breaks, release as one cutover | New ASP.NET Core app sits in front; routes move one by one from the legacy app behind it | Build a new system and migrate data and users to it |
| Best for | Class libraries, Web API and MVC apps with limited System.Web coupling, Windows services, console apps | Large MVC/Web Forms apps that must keep shipping features during migration | Systems whose domain model is wrong, or where the business process itself is being redesigned |
| Feature delivery during migration | Often frozen or dual-maintained | Continues | Split between old and new |
| Risk profile | Concentrated in one big release | Spread across many small, reversible releases | Highest: requirements hidden in old code get lost |
| Temporary complexity | Low | Proxy, shared auth/session, two deployments | Two full systems, data sync |
| Our default when | The app is small or already cleanly layered | The app is business-critical and large | Only with a clear business redesign, and even then in slices |
Opinion: "let's just rewrite it" underestimates how much business logic lives in the old code, in edge cases nobody documented. We rewrite slices through a strangler fig, where each slice can be compared against the old behaviour, rather than rewriting the whole system at once.
Strangler fig with YARP in practice
Microsoft's System.Web adapters and the YARP reverse proxy were built for exactly this pattern. A new ASP.NET Core app receives all traffic. Routes that have been migrated are served natively, and everything else is proxied to the legacy ASP.NET app. The adapters let migrated code use familiar HttpContext APIs and share session state and authentication with the legacy app, so users never notice which system served a page.
// Program.cs in the new ASP.NET Core app (excerpt)
builder.Services.AddSystemWebAdapters()
.AddJsonSessionSerializer(o => o.RegisterKey<int>("CartId"))
.AddRemoteAppClient(o => {
o.RemoteAppUrl = new(builder.Configuration["LegacyApp:Url"]!);
o.ApiKey = builder.Configuration["LegacyApp:ApiKey"]!;
})
.AddSessionClient()
.AddAuthenticationClient(isDefaultScheme: true);
builder.Services.AddHttpForwarder();
app.UseSystemWebAdapters();
app.MapControllers(); // migrated routes win
app.MapForwarder("/{**catch-all}",
app.Configuration["LegacyApp:Url"]!)
.WithOrder(int.MaxValue); // everything else goes to legacy
Migration order: start with read-only, low-risk pages to prove the pipeline, auth and session sharing. Then move high-change areas, so new features get built on the new stack. Leave rarely touched, stable screens until last, because some of them turn out not to be worth migrating at all.
WCF: gRPC, CoreWCF or REST
Modern .NET has WCF client libraries but no WCF server. The three routes:
| Option | When it fits | Trade-offs |
|---|---|---|
| CoreWCF | External consumers you can't change still call SOAP contracts; you want a like-for-like port first | Community-maintained with Microsoft support for the project; covers common bindings (BasicHttp, NetTcp, WSHttp subsets) but not all WS-* features; keeps SOAP in the stack |
| gRPC | Internal service-to-service calls you control on both ends; streaming; performance-sensitive paths | Contract-first .proto; needs HTTP/2 end to end; browser clients need gRPC-Web or JSON transcoding |
| REST / minimal APIs | Web, mobile and third-party consumers; public APIs | Most widely compatible; you design versioning and error contracts yourself |
Our usual approach is to lift SOAP endpoints that external partners depend on to CoreWCF, keeping the contract unchanged, and to move internal callers to gRPC or REST as their own code moves. Make an inventory of transactions (TransactionFlow, MSDTC) and message security early, because those are the WCF features that don't have simple equivalents.
Web Forms and MVC: Razor Pages, MVC or Blazor
ASP.NET MVC and Web API code moves over most smoothly. Controllers, routing and model binding map closely onto ASP.NET Core, although filters, HttpModules and bundling need rework. Web Forms has no port. Page lifecycle, ViewState and server controls don't exist in ASP.NET Core, so every page is a re-implementation. Choosing the target:
- Razor Pages: the closest mental model to page-centric Web Forms (a page plus its handler). A good fit for form-heavy, CRUD-style internal apps. Server-rendered and SEO-friendly.
- MVC: when the app is already heading towards a clear API plus views separation, or shares controllers with an API.
- Blazor: when the Web Forms app relies on rich stateful UI and the team wants to stay in C#. Blazor's component and event model feels familiar to Web Forms developers. Choose render modes on purpose: static SSR for content, Interactive Server for internal apps with reliable connectivity, and WebAssembly or Auto when offline or scale concerns rule out holding circuits on the server.
Don't try to recreate ViewState. Move the business logic out of code-behind into services first, which is a refactor you can do while still on Framework and one that makes every later step cheaper.
EF6 to EF Core
EF6 runs on modern .NET, which lets you separate the runtime move from the ORM move. This is often the right call. Once you do move to EF Core, watch for:
- EDMX models aren't supported. Reverse-engineer code-first models with scaffolding, then compare the generated SQL for critical queries.
- Query translation differences: EF Core throws when it can't translate a LINQ expression instead of silently evaluating it on the client. That is good, but it surfaces at runtime, so capture and replay real query paths in tests.
- Lazy loading is opt-in (proxies package). Relationship defaults, cascade delete, and owned or complex types all differ in details.
- Migrations history doesn't carry over. Baseline the existing schema with an initial EF Core migration marked as applied, and never let the ORM switch also change the schema.
- Stored-procedure-heavy data layers may map more naturally to Dapper for those paths, and EF Core and Dapper can coexist.
Removing System.Web, config and DI changes
HttpContext.Current, static access to request state, and ConfigurationManager.AppSettings scattered through business code are the coupling that makes migrations expensive. The fix is the same on both runtimes: inject abstractions.
// Before (.NET Framework, static coupling)
var tenant = HttpContext.Current.Request.Headers["X-Tenant"];
var limit = int.Parse(ConfigurationManager.AppSettings["MaxItems"]);
// After (modern .NET, injectable and testable)
public sealed class OrderService(ITenantAccessor tenant, IOptions<OrderOptions> opts)
{
public int Limit => opts.Value.MaxItems; // bound from appsettings / env vars / Key Vault
public string Tenant => tenant.CurrentTenantId;
}
builder.Services.Configure<OrderOptions>(builder.Configuration.GetSection("Orders"));
- Configuration:
web.configapp settings and connection strings move toappsettings.json, environment variables and a secret store. Config transforms become environment-specific files or deployment-time variables. - Dependency injection: the built-in container covers most needs. Autofac or other containers still work if the app depends on advanced features. Service lifetimes that were implicit (per-request via
HttpContext.Items) must be made explicit as scoped services. - Pipeline:
HttpModules andHttpHandlers become middleware.Global.asaxevents become startup code and hosted services. - Background work hidden in the web app moves to
BackgroundServiceor a separate worker, which also takes it out of IIS app-pool recycling.
Windows to Linux containers
Running on Linux containers is usually cheaper and simpler to operate. Don't make it a day-one requirement, though. Windows-specific dependencies to find:
- Case-sensitive file paths and
\separators; usePath.Combine. System.Drawing.Commonis Windows-only on modern .NET; move to ImageSharp, SkiaSharp or similar.- Windows authentication/Kerberos, registry, event log, performance counters, COM, MSMQ.
- Time zone IDs (Windows vs IANA), culture data (ICU on Linux) and fonts for PDF or report rendering.
A sensible sequence is modern .NET on Windows Server first, then Linux containers once those dependencies are gone. The .NET SDK can build container images without a Dockerfile (dotnet publish /t:PublishContainer), and chiseled or distroless base images keep the attack surface small. Where it lands afterwards, whether a managed container platform or Kubernetes, is covered by our cloud migration services, and a typical target is described in our AWS reference architecture for high-traffic SaaS.
The safety net: characterisation tests
Legacy systems rarely have tests that describe what they do today, bugs included. Before moving code we add characterisation (golden-master) tests. They pin current behaviour so that any difference after migration is visible and becomes a deliberate decision.
- HTTP-level snapshots: record real requests (sanitised) against the legacy app, then replay them against the migrated routes and diff status codes, headers and normalised bodies. Snapshot libraries such as Verify make these diffs reviewable.
- Calculation and report outputs: pricing, tax, invoice and export files compared byte-for-byte or field-by-field across a large sample of historical inputs.
- Database side effects: run scenarios in a disposable database (Testcontainers) and compare resulting rows.
- Shadow traffic for read-only endpoints: mirror production requests to the new implementation and log differences without serving its responses.
These tests run on every pull request in the pipeline described in our CI/CD and automated testing pipeline.
Phased cutover plan
- Assess: produce the inventory and plan, and write characterisation tests for the most critical flows.
- Prepare on Framework: convert to SDK-style projects, move logic out of code-behind, replace static
HttpContextand config access, and multi-target libraries. All of this ships on the existing runtime. - Front door: deploy the ASP.NET Core app proxying 100% of traffic through YARP. Nothing is migrated yet. This proves networking, auth, session, logging and performance overhead.
- Slices: move routes one by one behind feature flags, send canary traffic, compare against snapshots and watch error rates. New features are built only in the new app.
- Retire: once the catch-all route gets no traffic, switch off the legacy app, remove adapters, then move to EF Core and Linux containers if those were deferred.
Risks and how we handle them
| Risk | Mitigation |
|---|---|
| Third-party component with no modern .NET version | Find it in assessment; isolate behind an interface; replace, or run it in a small Framework sidecar service |
| Behaviour drift (rounding, culture, date handling, JSON serialisation) | Characterisation tests over real historical inputs; explicit culture and serializer settings |
| Session and auth mismatch between old and new apps | System.Web adapters remote session/auth; test sign-in and sign-out across both apps |
| Performance regressions from the proxy hop | Load-test phase 2 before migrating anything; keep the proxy co-located |
| Migration never finishes ("two systems forever") | Time-boxed slices, a visible route burndown, and a rule that new features go only to the new app |
| Knowledge loss when original developers have left | Treat snapshot diffs as documentation; product owners review each behavioural difference |
For the broader portfolio view, including when to rehost, refactor or replace, see our article on legacy software modernisation strategies and our overview of .NET app and software development. Our legacy software modernisation and .NET development teams run these migrations end to end, from assessment to the final retirement of the legacy app.
Frequently asked questions
Can we upgrade a .NET Framework app to modern .NET in place?
Often yes for class libraries, Web API and MVC applications with limited System.Web coupling, Windows services and console apps. Web Forms pages and WCF server hosting have no direct equivalent, so large apps using them usually benefit from an incremental strangler fig approach instead.
What does YARP do in a .NET migration?
YARP is a reverse proxy library for ASP.NET Core. In a strangler fig migration the new ASP.NET Core app receives all traffic, serves routes that have been migrated, and uses YARP to forward everything else to the legacy .NET Framework app. With the System.Web adapters, both apps can share authentication and session state.
Should WCF services move to gRPC or CoreWCF?
Use CoreWCF when external consumers depend on existing SOAP contracts you cannot change. Use gRPC for internal service-to-service calls where you control both ends, and REST for web, mobile and public consumers. Many migrations use CoreWCF first and then move internal callers over time.
Do we have to move from EF6 to EF Core at the same time?
No. EF6 runs on modern .NET, so you can move the runtime first and the ORM later. Separating the two reduces risk. When you do move to EF Core, baseline the existing schema, compare generated SQL for critical queries and do not change the schema as part of the ORM switch.
Is moving to Linux containers required?
Not at first. Running modern .NET on Windows Server is a valid intermediate step. Linux containers usually lower hosting and operational cost, but only after Windows-specific dependencies such as System.Drawing, Windows authentication, COM, MSMQ and Windows time zone IDs have been removed.
Sitting on a .NET Framework system that has to keep running?
Send us a short description of the solution: project types, WCF and Web Forms usage, database and hosting. We will suggest an assessment scope and the migration path we think carries the least risk.
Talk to an engineer