Case Study: Migrating a 12-Year-Old Monolith Without Downtime
How a .NET team used the Strangler Fig pattern to migrate a legacy ASP.NET WebForms monolith to a .NET 9 modular monolith — incrementally, over 14 months, with zero planned downtime.
Case Study: Migrating a 12-Year-Old Monolith Without Downtime
System: B2B ERP platform — 800 business customers, 60,000 daily active users
Stack (before): ASP.NET WebForms 4.8, SQL Server 2014, WCF services, jQuery
Stack (after): ASP.NET Core 9, PostgreSQL 16, modular monolith, HTMX + React islands
Migration duration: 14 months
Downtime during migration: 0 minutes (planned), 4 minutes (unplanned — one incident)
Team: 6 engineers
Why Migration Was Necessary
The legacy system:
- WebForms pages: 847
- WCF services: 134 endpoints
- SQL Server stored procedures: 2,400+
- Lines of code: 380,000 (C# + VB.NET mixed)
- Test coverage: 3%
- Average deploy time: 4 hours (manual steps + IIS recycle)
- Average defect turnaround: 3 weeks (too afraid to touch anything)
- .NET Framework 4.8 — end of mainstream support approaching
- Unable to run on Linux (blocked Azure cost optimisation)
- Two engineers left in 2024 who "knew how it worked"The business trigger: a competitor launched in 6 months what would have taken the team 18 months.
Why Not Rewrite From Scratch
"Big bang" rewrite risks:
1. The new system takes 18-24 months — you've been saying 12 for 6 months
2. The legacy system encodes years of undocumented business rules
3. Customers complain that "the new system doesn't do X" — it was never specced
4. Two systems in parallel means double the support burden
5. You either freeze features for 2 years or maintain two codebases
The Strangler Fig: replace the monolith one vertical slice at a time.
Every replaced slice is production-tested before the next one is touched.
The legacy system keeps running the untouched parts.The Plan
Phase 1 (months 1-2): Set up YARP proxy in front of the legacy app
Phase 2 (months 2-5): Migrate 5 highest-ROI modules (new codebase, proxy routes traffic)
Phase 3 (months 5-11): Migrate remaining 15 modules
Phase 4 (months 11-14): Decommission legacy app; direct all traffic to new appStep 1: YARP Proxy in Front of Everything
The first deployment shipped only a proxy. No features changed. All traffic passed through.
// New .NET 9 app — initial state: pure proxy
// appsettings.json
{
"ReverseProxy": {
"Routes": {
"legacy-catchall": {
"ClusterId": "legacy",
"Match": { "Path": "{**catch-all}" }
}
},
"Clusters": {
"legacy": {
"Destinations": {
"primary": { "Address": "http://legacy-erp-internal:8080/" }
}
}
}
}
}// Program.cs — pure proxy, nothing else
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();Deployment strategy:
1. Deploy the new proxy app on a new server
2. Route 1% of traffic to proxy (AWS weighted routing)
3. Verify identical responses (HTTP status, body hash comparison)
4. Route 100% through proxy
5. Legacy app now unreachable from internet — only via proxy
This took 2 days. The "migration" started with zero code written.Step 2: Intercept Routes Incrementally
When a module was ready in the new codebase, one config change redirected it:
// Before: YARP routes /invoices/* to legacy
// After: /invoices/* goes to new app; everything else still goes to legacy
{
"ReverseProxy": {
"Routes": {
"invoices-new": {
"ClusterId": "new-app",
"Match": { "Path": "/invoices/{**remainder}" },
"Order": 1
},
"legacy-catchall": {
"ClusterId": "legacy",
"Match": { "Path": "{**catch-all}" },
"Order": 100
}
},
"Clusters": {
"new-app": {
"Destinations": {
"primary": { "Address": "http://new-erp-internal:8080/" }
}
},
"legacy": {
"Destinations": {
"primary": { "Address": "http://legacy-erp-internal:8080/" }
}
}
}
}
}Every module migration was a one-line config change.
Rollback was a one-line config change.
No redeployment needed — YARP hot-reloads config.Step 3: Modular Monolith Architecture
The new app was not microservices — it was a single deployable unit with clear module boundaries.
src/
ErpPlatform.Host/ ← ASP.NET Core entry point, no business logic
ErpPlatform.SharedKernel/ ← Primitives: Entity, DomainEvent, Result
Modules/
Invoicing/
ErpPlatform.Invoicing/
Domain/
Invoice.cs
InvoiceStatus.cs
InvoiceCreatedEvent.cs
Application/
CreateInvoiceCommand.cs
GetInvoiceQuery.cs
Infrastructure/
InvoiceRepository.cs
InvoiceDbContext.cs
Api/
InvoiceEndpoints.cs
Inventory/
ErpPlatform.Inventory/
...
Customers/
ErpPlatform.Customers/
... // Each module registers itself — host knows nothing about internals
// src/Modules/Invoicing/ErpPlatform.Invoicing/InvoicingModule.cs
public static class InvoicingModule
{
public static IServiceCollection AddInvoicingModule(
this IServiceCollection services,
IConfiguration config)
{
services.AddDbContext<InvoiceDbContext>(opts =>
opts.UseNpgsql(config.GetConnectionString("Invoicing")));
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(InvoicingModule).Assembly));
return services;
}
public static IEndpointRouteBuilder MapInvoicingEndpoints(
this IEndpointRouteBuilder app)
{
app.MapGroup("/invoices").MapInvoiceEndpoints();
return app;
}
}// Program.cs knows only about modules, not their internals
builder.Services
.AddInvoicingModule(builder.Configuration)
.AddInventoryModule(builder.Configuration)
.AddCustomersModule(builder.Configuration);
app.MapInvoicingEndpoints();
app.MapInventoryEndpoints();
app.MapCustomersEndpoints();Step 4: The Dual-Write Problem
When the invoice module moved to the new system, customer data still lived in the legacy SQL Server database. Cross-module reads needed a solution.
Options considered:
A. Share the database — both old and new app write to SQL Server
Risk: tight coupling, defeats the purpose of migration
B. Event-driven sync — legacy publishes events, new app consumes
Risk: legacy WebForms can't easily publish events
C. Legacy read API — new app reads from legacy via internal HTTP
Chosen: simple, auditable, temporary
D. Full data migration upfront
Risk: 12 years of data, complex referential integrity// Temporary adapter — new Invoicing module reads Customer data from legacy
public class LegacyCustomerAdapter(HttpClient http) : ICustomerReadRepository
{
public async Task<CustomerDto?> GetByIdAsync(int id, CancellationToken ct)
{
// Calls the legacy WCF REST wrapper (thin adapter we wrote on the legacy side)
var response = await http.GetFromJsonAsync<LegacyCustomerResponse>(
$"/internal-api/customers/{id}", ct);
return response is null ? null
: new CustomerDto(response.CustomerId, response.Name, response.Email);
}
}
// Registered in InvoicingModule
services.AddHttpClient<ICustomerReadRepository, LegacyCustomerAdapter>(c =>
c.BaseAddress = new Uri(config["LegacyApp:BaseUrl"]!));This pattern was used for 8 months while Customer module was being built.
When Customer module migrated, LegacyCustomerAdapter was deleted and
replaced with a direct EF Core repository — one line change in DI registration.Step 5: Database Migration Strategy
SQL Server → PostgreSQL migration approach:
Per-module, not all-at-once:
1. New module uses PostgreSQL from day one
2. Historical data migrated using SSIS/custom ETL scripts
3. Legacy SQL Server tables kept read-only for 90 days
4. After 90 days: archive and remove
Migration tooling:
- AWS Schema Conversion Tool for schema
- Custom C# migration scripts for data (stored proc logic → C# code)
- pgloader for bulk data copy (10M invoice rows in 45 minutes)
- Dual-read verification: compare row counts + spot-check 1,000 random records// Data verification script run before cutover
public class MigrationVerifier(SqlServerDbContext legacy, InvoiceDbContext modern)
{
public async Task<VerificationReport> VerifyAsync(CancellationToken ct)
{
var legacyCount = await legacy.Invoices.CountAsync(ct);
var modernCount = await modern.Invoices.CountAsync(ct);
var countMatch = legacyCount == modernCount;
// Sample 1,000 random invoice IDs and compare totals
var sampleIds = await legacy.Invoices
.OrderBy(_ => EF.Functions.Random())
.Take(1000)
.Select(i => i.Id)
.ToListAsync(ct);
var mismatches = 0;
foreach (var id in sampleIds)
{
var legacyTotal = await legacy.Invoices
.Where(i => i.Id == id).Select(i => i.Total).FirstAsync(ct);
var modernTotal = await modern.Invoices
.Where(i => i.LegacyId == id).Select(i => i.Total).FirstAsync(ct);
if (legacyTotal != modernTotal) mismatches++;
}
return new VerificationReport(legacyCount, modernCount, countMatch, mismatches);
}
}The One Incident (4 Minutes of Downtime)
Month 8, 14:23 on a Tuesday:
A YARP route order bug caused /customers/export (legacy, not yet migrated)
to be matched by the new /customers/{id} route pattern.
The new app returned 404 (no export endpoint yet).
Legacy export was unreachable.
14:23 Customer reports export failing
14:24 On-call engineer checks YARP config
14:25 Identifies route order problem
14:26 Hot-reloads YARP config with corrected route order
14:27 Export endpoint working again
Root cause: route specificity — /customers/export was less specific than
/customers/{id} because the new route had Order: 1 (highest priority).
Fix: /customers/export added as explicit route with Order: 0 in legacy cluster
before the new /customers/{id} route had Order: 1.
Lesson: wildcard routes in YARP need explicit exceptions for any legacy paths
that overlap with new pattern-matched routes.Month 14: Decommission
Final state:
- YARP proxy removed (no longer needed — all traffic goes to new app)
- Legacy IIS server shut down
- SQL Server 2014 decommissioned (data archived to Azure Blob cold storage)
- WCF services gone
- 847 WebForms pages replaced by 20 modular API modules + React frontend
Decommission ceremony:
- Team watched the legacy server health check return 200 for the last time
- Then return Connection Refused
- Then updated the DNS recordResults
Before After
Deploy time 4 hours 8 minutes (CI/CD pipeline)
Test coverage 3% 71%
Mean time to deploy Weekly On-demand (dozens/day if needed)
Defect turnaround 3 weeks 2-3 days
Infrastructure cost $18,400/mo $4,200/mo (Linux containers)
Feature velocity 1 sprint/feat 3-4 features/sprint
Engineer confidence "Afraid" "We ship it, we own it"
Planned downtime: 0 minutes
Unplanned downtime: 4 minutes (one YARP route bug)The Rules the Team Extracted
1. Proxy first. Route second. Code last.
Set up YARP routing before writing a single line of new business logic.
Rollback is a config change, not a deployment.
2. The Strangler Fig works on vertical slices, not technical layers.
Don't migrate "all repositories" — migrate "the invoicing module."
Slice includes: API + business logic + database + tests.
3. Never share the database between old and new.
The database is the contract. Sharing it ties both systems together.
Use adapters (temporary HTTP calls) to bridge until the source migrates.
4. Migrate the data module-by-module.
Per-module ETL is testable, reversible, and scoped.
Full database migration is a single point of failure for the entire migration.
5. Celebrate each decommission.
Every deleted file of WebForms code is a team win.
Track the "lines deleted" metric alongside "lines added."Module Migration Order (Actual)
Priority factors: user impact, code risk, dependency order
Month 2-3: Invoicing (highest user complaints, cleanest bounded context)
Month 3-4: Products (catalogue reads = cacheable, low complexity)
Month 4-5: Orders (depends on Products — migrate after)
Month 5-7: Customers (all other modules depend on this — migrate mid-way)
Month 7-9: Inventory (complex stored proc logic → 6 weeks alone)
Month 9-11: Reporting (most complex: 400+ stored procs → query handlers)
Month 11-13: Admin/Config (low risk, internal users only)
Month 13-14: Auth/Users (last — everything else must be stable first)Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.