Skip to main content
Engineering deep dive · Updated September 2026

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.

Published September 16, 2026 · 13 min read

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.Web dependency are the first candidates to multi-target net48 plus 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 upgradeStrangler fig (incremental, YARP)Rewrite
How it worksConvert projects to SDK-style, retarget, fix breaks, release as one cutoverNew ASP.NET Core app sits in front; routes move one by one from the legacy app behind itBuild a new system and migrate data and users to it
Best forClass libraries, Web API and MVC apps with limited System.Web coupling, Windows services, console appsLarge MVC/Web Forms apps that must keep shipping features during migrationSystems whose domain model is wrong, or where the business process itself is being redesigned
Feature delivery during migrationOften frozen or dual-maintainedContinuesSplit between old and new
Risk profileConcentrated in one big releaseSpread across many small, reversible releasesHighest: requirements hidden in old code get lost
Temporary complexityLowProxy, shared auth/session, two deploymentsTwo full systems, data sync
Our default whenThe app is small or already cleanly layeredThe app is business-critical and largeOnly 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.

Strangler fig migration using YARP All browser and API traffic enters a new ASP.NET Core application on the current .NET LTS. Migrated routes are handled by new controllers, Razor Pages or Blazor components. Unmigrated routes fall through to YARP, which proxies them to the legacy ASP.NET Framework app on IIS. Both share authentication and session through System.Web adapters, and both access the same database during the transition. Users / clients ASP.NET CORE APP (CURRENT LTS) Migrated endpointscontrollers · Razor Pages · Blazor YARP catch-all routelowest priority, forwards the rest System.Web adaptersshared auth + session Legacy ASP.NET app.NET Framework 4.8 on IIS Shared databaseduring transition
Every release moves more routes into the new app. When the catch-all route no longer receives traffic, the legacy app is retired.
// 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:

OptionWhen it fitsTrade-offs
CoreWCFExternal consumers you can't change still call SOAP contracts; you want a like-for-like port firstCommunity-maintained with Microsoft support for the project; covers common bindings (BasicHttp, NetTcp, WSHttp subsets) but not all WS-* features; keeps SOAP in the stack
gRPCInternal service-to-service calls you control on both ends; streaming; performance-sensitive pathsContract-first .proto; needs HTTP/2 end to end; browser clients need gRPC-Web or JSON transcoding
REST / minimal APIsWeb, mobile and third-party consumers; public APIsMost 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.config app settings and connection strings move to appsettings.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 and HttpHandlers become middleware. Global.asax events become startup code and hosted services.
  • Background work hidden in the web app moves to BackgroundService or 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; use Path.Combine.
  • System.Drawing.Common is 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

Phased cutover plan for a .NET Framework migration Five phases from left to right: 0 assess and add characterisation tests; 1 prepare on Framework by converting to SDK-style projects, injecting abstractions and multi-targeting libraries; 2 stand up the ASP.NET Core front app with YARP and shared auth; 3 migrate route slices behind feature flags with canary traffic and snapshot diffs; 4 retire the legacy app, move to Linux containers and remove adapters. Each phase has an exit gate. 0 · AssessUpgrade Assistantdependency inventorycharacterisationtests 1 · PrepareSDK-style projectsinject abstractionsmulti-target libs(still on 4.8) 2 · Front doorASP.NET Core appYARP catch-allshared auth/sessionobservability 3 · Slicesroute by routefeature flagscanary + diffsrepeat 4 · Retirelegacy app offremove adaptersLinux containersEF Core, cleanup EXIT GATES BELOW EACH PHASE loop per slice plan signed offbuilds on bothproxy verifiedno diffsno legacy hits
Labels under each phase are its exit gate. Every phase can be shipped to production on its own. If a slice misbehaves, rolling back means flipping its route back to the legacy app.
  1. Assess: produce the inventory and plan, and write characterisation tests for the most critical flows.
  2. Prepare on Framework: convert to SDK-style projects, move logic out of code-behind, replace static HttpContext and config access, and multi-target libraries. All of this ships on the existing runtime.
  3. 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.
  4. 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.
  5. 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

RiskMitigation
Third-party component with no modern .NET versionFind 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 appsSystem.Web adapters remote session/auth; test sign-in and sign-out across both apps
Performance regressions from the proxy hopLoad-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 leftTreat 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
© Next Olive Technologies · nextolive.com · sales@nextolive.com