In-Depth Guide · 8 Sections · Migration

Legacy Modernization In Depth

From .NET Framework monoliths to .NET 8/9 — assessment, strangler fig, MVC→Minimal API, Web Forms paths, EF6→EF Core, and a safe production cutover. Plain English, copy-paste patterns.

Strangler fig Framework → .NET 8/9 EF6 → EF Core Cutover

Scenario map: Job Reference — Legacy. Target stack guides: Minimal API, Azure Platform, Docker & CI/CD.

01

Assessment — Know What You Have

Before touching code, inventory the monolith: frameworks, NuGet packages, IIS features, database schema ownership, and integration points. The goal is a migration backlog ranked by risk and business value, not a big-bang rewrite plan.

1.1

Inventory checklist

// Run on the legacy solution — capture in a spreadsheet

// 1. Target framework(s): net472? net48? mixed?
// 2. Project types: Web Forms, MVC, Web API 2, WCF, Windows Service
// 3. Blockers: System.Web, HttpContext.Current, Remoting, AppDomains
// 4. NuGet: packages with no .NET 8 support (check nuget.org)
// 5. Config: web.config transforms, machineKey, custom modules
// 6. Auth: Forms auth, OWIN, AD, custom cookies
// 7. Data: EF6 EDMX? raw ADO? stored procs owned by app team?
// 8. Integrations: SOAP, MSMQ, file shares, COM, third-party DLLs
// 9. Deploy: IIS version, GAC assemblies, x86 vs AnyCPU
// 10. Tests: unit coverage? integration against prod-like DB?
1.2

Automated analysis with .NET Upgrade Assistant

# Install CLI
dotnet tool install -g upgrade-assistant

# Analyze (read-only report)
upgrade-assistant analyze MyLegacy.sln

# Key outputs:
#   - Incompatible APIs (System.Web.HttpContext, etc.)
#   - Package replacements (e.g. Microsoft.AspNet.* → Microsoft.AspNetCore.*)
#   - Estimated effort per project

# Prioritize: leaf libraries first (no System.Web), then services, then UI last
02

Strangler Fig Pattern

Don't replace the monolith in one release. Route traffic incrementally to a new facade (reverse proxy or API gateway) that sends some URLs to the legacy app and others to the modern stack. Over months, the legacy system is "strangled" until you can decommission it.

2.1

YARP routes — legacy vs modern

// Program.cs — new .NET 8 host in front of both apps
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();

// appsettings.json
{
  "ReverseProxy": {
    "Routes": {
      "modern-api": {
        "ClusterId": "modern",
        "Match": { "Path": "/api/v2/{**catch-all}" }
      },
      "legacy-fallback": {
        "ClusterId": "legacy",
        "Match": { "Path": "{**catch-all}" }
      }
    },
    "Clusters": {
      "modern": { "Destinations": { "d1": { "Address": "https://modern.internal/" } } },
      "legacy": { "Destinations": { "d1": { "Address": "https://legacy.internal/" } } }
    }
  }
}
2.2

Slice by bounded context

// Good first slices (low coupling, high value):
//   ✓ Read-only reporting API
//   ✓ New feature on greenfield endpoint (/api/v2/orders)
//   ✓ Background job moved to Hangfire / Azure Functions
//   ✓ Public marketing pages → static site or Blazor

// Defer until late:
//   ✗ Shared session state across Web Forms + new API
//   ✗ Giant God DbContext used by 40 controllers
//   ✗ Custom HTTP modules that touch every request
03

.NET Framework → .NET 8/9

Class libraries migrate first. Change TargetFramework to net8.0, fix compile errors, replace incompatible packages. ASP.NET apps usually become new SDK-style projects rather than in-place upgrades.

3.1

SDK-style csproj migration

<!-- Old -->
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>

<!-- New class library -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

// Common replacements:
// System.Configuration → Microsoft.Extensions.Configuration
// log4net → Serilog + Microsoft.Extensions.Logging
// Unity / Autofac → built-in DI (still works on .NET 8)
3.2

System.Web escape hatches

// ❌ Legacy — does not exist on .NET 8
var user = HttpContext.Current.User;
var path = HostingEnvironment.MapPath("~/App_Data/file.txt");

// ✅ Modern equivalents
// IHttpContextAccessor for current user in DI services
// IWebHostEnvironment.ContentRootPath / WebRootPath for files
// IMemoryCache or IDistributedCache instead of HttpRuntime.Cache

// If you MUST share code temporarily:
#if NETFRAMEWORK
    // old path
#else
    // new path
#endif
04

MVC → Minimal API

ASP.NET MVC controllers map cleanly to Minimal API route groups. Move one controller at a time; keep the same URL paths so the strangler proxy needs no client changes.

4.1

Controller action → endpoint

// Legacy MVC
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<OrderDto> Get(int id) =>
        await _repo.GetAsync(id);
}

// .NET 8 Minimal API — same contract
var orders = app.MapGroup("/api/orders").WithTags("Orders");
orders.MapGet("/{id:int}", async (int id, IOrderRepo repo) =>
    await repo.GetAsync(id))
    .Produces<OrderDto>();
4.2

Filters → middleware & endpoints

// MVC [Authorize(Roles = "Admin")] on controller
// → Minimal API
orders.MapDelete("/{id:int}", DeleteOrder)
    .RequireAuthorization("AdminOnly");

// MVC action filter for validation
// → builder.Services.AddProblemDetails() + IValidatableObject
// → or FluentValidation + endpoint filter

builder.Services.AddAuthorization(o =>
    o.AddPolicy("AdminOnly", p => p.RequireRole("Admin")));
05

Web Forms — Realistic Options

Web Forms has no direct port to .NET Core. You choose: rewrite UI (Blazor, Razor Pages, SPA), wrap behind API, or keep on .NET Framework longer behind the strangler.

5.1

Decision matrix

// Option A — Blazor Server (internal LOB, rich UI, C# only)
//   Pros: reuse C# skills, incremental page migration
//   Cons: SignalR scale-out, not for public SEO pages

// Option B — Razor Pages + HTMX (simpler forms, server render)
//   Pros: familiar page model, lighter than SPA
//   Cons: still a rewrite of .aspx markup

// Option C — SPA (React/Vue) + new Minimal API
//   Pros: decouple UI team, modern UX
//   Cons: two codebases, auth complexity (BFF pattern)

// Option D — Keep Web Forms on IIS + strangler new features
//   Pros: lowest short-term risk
//   Cons: dual runtime ops cost until full rewrite
5.2

Extract API from code-behind first

// Before rewriting 200 .aspx pages:
// 1. Move business logic from code-behind → class libraries (netstandard2.0)
// 2. Expose JSON endpoints from new Minimal API
// 3. Web Forms pages call fetch("/api/v2/...") for new behavior
// 4. Replace .aspx one screen at a time with Blazor/Razor

// Page_Load anti-pattern to kill early:
protected void Page_Load(object sender, EventArgs e)
{
    _orders = _db.Orders.Where(...).ToList(); // move to IOrderService
}
06

EF6 → EF Core

EDMX and Database-First don't port. Reverse-engineer the schema into EF Core, then port queries incrementally. Expect to fix lazy loading, ObjectContext, and raw SQL differences.

6.1

Scaffold DbContext from existing DB

# Package Manager / CLI — same database, new model
dotnet ef dbcontext scaffold "Server=.;Database=LegacyDb;Trusted_Connection=True;" \
  Microsoft.EntityFrameworkCore.SqlServer \
  --output-dir Entities \
  --context AppDbContext \
  --no-onconfiguring

// Compare with EF6 model — watch for:
//   - Table/column renames (singular vs plural)
//   - Decimal precision, DateTime vs DateTimeOffset
//   - Cascade delete behavior changes
6.2

Query porting cheatsheet

// EF6                          → EF Core
.Include("Orders.Items")        → .Include(o => o.Items)
DbContext.Database.SqlQuery<T>  → context.Set<T>().FromSqlRaw(...)
DbEntityEntry.State = Modified  → context.Entry(e).State = EntityState.Modified

// Lazy loading — explicit opt-in in EF Core
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseLazyLoadingProxies().UseSqlServer(conn));

// Run both contexts briefly during strangler (same DB, read-only legacy)
07

Deployment Cutover

The last mile: run old and new in parallel, shift traffic with slots or proxy weights, validate with synthetic checks, and keep a rollback path for 48 hours.

7.1

Blue-green with App Service slots

# Deploy modern app to staging slot
az webapp deployment slot swap \
  --resource-group rg-prod \
  --name myapp \
  --slot staging \
  --action preview   # warm up, don't swap yet

# Smoke tests against staging URL
curl -f https://myapp-staging.azurewebsites.net/health

# Swap when green
az webapp deployment slot swap --slot staging --resource-group rg-prod --name myapp
7.2

Data migration & dual-write window

// Schema changes: expand → migrate → contract
// 1. Add nullable columns / new tables (backward compatible)
// 2. Dual-write: legacy + modern both write new shape
// 3. Backfill job copies historical rows
// 4. Switch reads to modern; stop legacy writes
// 5. Drop old columns after verification period

// Feature flag kill switch
if (await _flags.IsEnabledAsync("UseModernOrderApi"))
    return await _modernClient.GetOrderAsync(id);
return await _legacyClient.GetOrderAsync(id);
08

Production — Operate the New Stack

8.1

Observability from day one

// Log correlation across strangler boundary
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddAzureMonitorTraceExporter());

// Compare error rates legacy vs modern during ramp
// Alert if modern p95 latency > legacy + 20%
8.2

Decommission checklist

  • Zero traffic to legacy URLs for 2+ weeks (proxy logs)
  • No batch jobs or integrations still calling legacy endpoints
  • Database objects only used by legacy identified and dropped
  • Runbooks updated — on-call knows only modern deploy path
  • Archive legacy repo tag + final IIS snapshot before shutdown
Mastery Move — where to go next

1. Clean Architecture & CQRS — structure the modern codebase so it doesn't become the next monolith.

2. Azure Platform — host the strangler facade and modern API on App Service with slots.

3. Testing .NET — regression suite that proves parity before each route migration.