A 15-year-old WebForms application, backed by a single 400-table SQL Server database and a tangle of stored procedures nobody fully understands, is still processing every order the business takes. Nobody wants to touch it, but the business can’t wait months for a new feature, and the team maintaining it can’t hire developers who want to write .aspx code in 2026. The real engineering problem isn’t “how do we get to modern .NET” — it’s “how do we get there without a multi-quarter, outage-risk rewrite the business can’t afford.”
A full rewrite is sometimes the right call — see when a rewrite actually makes sense and when it doesn’t. This article assumes you’ve ruled one out.
Why big-bang rewrites fail more often than they succeed
A rewrite is scoped against documented requirements, but a decade-old system has accumulated thousands of undocumented rules — tax exceptions for specific customer types, discount logic tied to a sales rep’s tenure, validation quirks left over from an old data migration — that live only in the code’s behavior. A rewrite has to rediscover all of that from scratch, under time pressure, while the legacy system keeps changing underneath it: the two drift, the rewrite slips, and you end up running both in parallel far longer than planned — the exact situation an incremental strategy sets up on purpose, instead of by accident.
Step 1: Assessment before you touch anything
Skipping any of these three turns modernization into an archaeology dig mid-sprint:
Dependency mapping. Which parts talk to which others, and which external integrations exist through hidden channels — a scheduled task reading directly from the database, a reporting tool with a live connection string, a partner’s nightly SFTP job. Static analysis misses this coupling, so expect real time in logs and IIS traces to find what’s actually called in production, which routinely diverges from what’s documented.
Core domain vs. peripheral functionality. A regional bank’s loan-servicing system might carry negotiated-rate override logic worth extracting carefully; an insurer’s claims system might bury adjudication exceptions in old stored procedures. High-risk logic earns characterization tests; the screen for editing shipping-carrier names doesn’t. Teams that treat every module as equally important run out of budget and end up with the easy parts modernized and the risky ones — the ones that justified the project — untouched.
Undocumented business rules. These live in conditionals, stored procedure branches, and an Excel macro that “just runs.” Surface them by asking “why does this exist,” not “what does this do,” and by talking to whoever has fielded support tickets.
Step 2: Build the safety net before you refactor anything
The single most common mistake is changing code before you can detect whether you broke something. Unit tests are usually thin here because the code was never written to be testable: static methods, HttpContext.Current scattered everywhere, business logic embedded in code-behind handlers.
Characterization tests pin down what the system actually does, bugs included — a regression net while refactoring. You’re not asserting the code is right, only that it doesn’t silently change behavior while you move it.
// CharacterizationTests/PricingEngineTests.cs
// Pins CURRENT behavior of the legacy pricing engine, quirks included —
// a safety net for extraction, not a correctness check.
using Xunit;
public class PricingEngineCharacterizationTests
{
[Theory]
[InlineData("GOLD", "US-WEST", 100.00, 5, 92.50)] // 7.5% tier discount
[InlineData("GOLD", "US-WEST", 100.00, 0, 100.00)] // NOTE: needs qty > 0 — a likely
// bug, but reporting depends on it
public void CalculatePrice_MatchesObservedProductionBehavior(
string tier, string region, decimal baseAmount, int qty, decimal expected)
{
var result = new LegacyPricingEngine().CalculatePrice(tier, region, baseAmount, qty);
Assert.Equal(expected, result); // pinning the observed value, not the "correct" one
}
}
Run these against the existing legacy code first, confirm they pass, then treat any failure during refactoring as a signal to stop and investigate — never as a test to “fix.” As a rough planning number, characterization-test coverage for a module this size — a pricing engine, a claims-adjudication routine — typically runs 2-6 engineer-weeks, depending on how many undocumented branches surface.
Step 3: Modularize before you extract
You don’t need microservices — you need clear seams. Before anything moves to a new service or database, enforce bounded contexts, even as internal namespaces within the same solution. This is where an anti-corruption layer earns its keep: a thin adapter translating legacy data shapes into a cleaner model.
// AntiCorruptionLayer/LegacyOrderAdapter.cs
// New code depends on IOrderLookupService, never LegacyOrderRepository —
// so legacy internals can change or retire without breaking consumers.
public class LegacyOrderAdapter(LegacyOrderRepository legacyRepo) : IOrderLookupService
{
public async Task<OrderSummary?> GetOrderAsync(string orderId)
{
var row = await legacyRepo.FindByIdAsync(orderId);
if (row is null) return null;
return new OrderSummary
{
OrderId = row.ord_id.Trim(),
// status_cd drifted over time — 'X' means both "cancelled" and
// "expired" depending on an undocumented date check, resolved
// once here instead of leaking into every caller.
Status = ResolveStatus(row.status_cd, row.expiry_dt),
TotalAmount = row.total_amt ?? 0m,
PlacedAtUtc = DateTime.SpecifyKind(row.created_dt, DateTimeKind.Utc)
};
}
private static OrderStatus ResolveStatus(char code, DateTime? expiry) => code switch
{
'P' => OrderStatus.Pending,
'S' => OrderStatus.Shipped,
'C' => OrderStatus.Cancelled,
'X' when expiry.HasValue && expiry < DateTime.UtcNow => OrderStatus.Expired,
'X' => OrderStatus.Cancelled,
_ => OrderStatus.Unknown
};
}
The adapter can call the legacy database today and a new microservice tomorrow, and consumers never notice.
Step 4: The strangler-fig pattern in practice
The strangler-fig pattern — incrementally routing traffic away from the legacy system until nothing depends on it — is the backbone of most incremental modernizations. Teams get hurt in execution: the database, session state, and authentication are almost always shared between old and new, each with sharper failure modes than the name suggests.
flowchart TB
Client["Client / Browser"] --> Gateway["Routing Layer"]
Gateway -->|"/api/orders/*\n(migrated)"| NewSvc["New ASP.NET Core\nOrders Service"]
Gateway -->|"/api/inventory/*\n(migrated)"| NewSvc2["New ASP.NET Core\nInventory Service"]
Gateway -->|"everything else"| Legacy["Legacy WebForms\nApplication"]
NewSvc --> ACL["Anti-Corruption\nLayer"]
NewSvc2 --> ACL
ACL --> SharedDB[("Shared SQL Server DB\n(source of truth)")]
Legacy --> SharedDB
NewSvc -.->|"new tables only,\nno legacy writes"| NewSchema[("New bounded-context\nschema/tables")]
style Legacy fill:#5b2333,stroke:#8b3a4a,color:#fff
style NewSvc fill:#1f3a5f,stroke:#3a5f8f,color:#fff
style NewSvc2 fill:#1f3a5f,stroke:#3a5f8f,color:#fff
style ACL fill:#3a5f4a,stroke:#5a8f6a,color:#fff
A minimal version of the routing layer is just middleware, with migrated routes as config rather than code so a rollback is instant:
// Middleware/StranglerRoutingMiddleware.cs
// Migrated routes come from IMigratedRouteRegistry (config-backed), so
// reverting a route to legacy is a config change, not a deploy.
public class StranglerRoutingMiddleware(
RequestDelegate next,
IHttpClientFactory httpClientFactory,
IMigratedRouteRegistry migratedRoutes)
{
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
if (migratedRoutes.IsMigrated(path))
{
await ForwardToNewServiceAsync(context);
return;
}
await next(context); // falls through to legacy app
}
private async Task ForwardToNewServiceAsync(HttpContext context)
{
var client = httpClientFactory.CreateClient("NewServicesCluster");
var forwarded = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
context.Request.Path + context.Request.QueryString);
// Carry the legacy auth cookie through untouched — see Auth, below.
if (context.Request.Headers.TryGetValue("Cookie", out var cookie))
forwarded.Headers.Add("Cookie", cookie.ToString());
var response = await client.SendAsync(forwarded);
context.Response.StatusCode = (int)response.StatusCode;
await response.Content.CopyToAsync(context.Response.Body);
}
}
Two dependencies make this hard in practice — session state most of all:
- Shared database. Additive-only schema changes keep this safe early on (see Database modernization, below).
- Shared session state. The more consistently underestimated of the two, and the source of the strangest-looking production incidents — covered next.
Shared session state: the boundary nobody draws on the diagram
Legacy ASP.NET session (InProc or SQL Server session state) doesn’t transfer to a new stateless service automatically, and it’s invisible until a real user mid-transaction hits it: a shopping cart, a partially filled claims-intake form, a multi-step loan application. If routing sends step two of a workflow to the new service while step one’s session lives only in legacy memory, the new service has no idea the user exists.
Two mechanics work, and picking one is deliberate, not a default: a shared session store both sides can read — Redis-backed session state is the common choice — set up before routing traffic away, since retrofitting it after the first cutover means migrating live sessions mid-flight; or an explicit session boundary, passing context as a signed token the new service validates independently, treating any workflow crossing the boundary as a deliberate handoff — more upfront work, but it fully decouples the new service from a legacy mechanism you may eventually retire.
Either way, pick a single-user, multi-step workflow — a cart, an application form, a claims wizard — as a pre-cutover test case; that’s where a session mismatch becomes customer-facing, and the single-request pages dominating most smoke tests won’t catch it.
Step 5: Extract behind a stable API
Once a bounded context has characterization-test coverage, wrap it behind a versioned API contract — even if it just calls into legacy code through the anti-corruption layer at first. Other teams then build against the new API immediately, while internals migrate from “calls legacy code” to “owns its own logic and data” invisibly to consumers.
Authentication and authorization: handle this deliberately, not as an afterthought
Migrating from Windows Authentication or ASP.NET Forms Authentication to a modern identity provider (Entra ID, Auth0, IdentityServer/Duende) is one of the riskiest parts of this process — auth failures are binary and customer-facing. The natural instinct — swap the identity provider first — is backwards.
1. Run both identity systems in parallel, with the new provider issuing tokens carrying the same claims (user ID, roles, tenant or branch) the legacy system expects, translated through a claims-mapping layer in front of both authorization checks.
2. Migrate authorization before authentication. Get the new system enforcing the same role checks against the existing identity source first, while everyone still logs in through the legacy path. Only once those checks are proven do you swap the identity provider — one isolated change instead of two entangled ones at once.
3. Cut over by cohort, never by big bang — branch staff before online banking customers, claims adjusters before policyholders, warehouse staff before distributor logins — each with a tested, instant path back to legacy login and its own bake period, measured in weeks.
4. Audit token expiry against the legacy session timeout. A mismatch quietly logs users out mid-task at an unfamiliar cadence, producing a support-ticket spike traced to the cutover only later — and it compounds with the shared-session mechanics above, since a workflow spanning old and new needs both to survive the handoff together.
An approach HzMinds would typically recommend: treat the cohort sequence as its own plan, not a checklist item — compressing it to hit an unrelated deadline tends to produce the worst incidents in the migration.
Database modernization: evolve the schema, don’t fork it prematurely
While legacy and new services share a database, schema changes must be strictly additive — new nullable columns, tables, views, never a rename or drop the legacy code still reads. Three moves cover most cases: shadow columns, not renames (a drifted column, like status_cd above, gets a correctly-typed column alongside it, written to both, old one dropped only once legacy stops reading it); views as a compatibility shim instead of restructuring tables legacy code depends on; and dual-write with a reconciliation job, for short, well-monitored windows only — a stopgap, since any dual-write can split-brain if one write fails.
Splitting into a separate database per bounded context is a later step, almost always later than teams plan for — doing it early locks in a data-ownership boundary before you know where it belongs. Wait until the new service is the sole writer, with a plan (events, CDC, a scheduled sync — never a cross-database query) for any remaining reads. A manufacturer’s inventory platform might look ready to split off its own database once the new service owns writes — but if the legacy pricing engine still reads stock levels from those tables for availability checks, splitting early turns a schema decision into an outage.
| Strategy | When it fits | Main risk |
|---|---|---|
| Shared DB, additive schema | Early migration, contexts still coupled | Schema drift over a long transition |
| Views as compatibility shim | Cleaner shape needed, no legacy changes | Extra indirection to maintain |
| Database-per-context split | Context has a clean, single writer | Needs CDC/events for remaining cross-context reads |
| Dual-write during cutover | Short, well-monitored windows only | Split-brain if one write fails |
Observability: you can’t migrate safely what you can’t see
Most legacy .NET Framework systems have IIS logs and little else — no structured logging, no tracing, no metrics on which paths run in production. Add structured logging and request/response timing around the modules you’re about to touch: it gives you a “before” baseline, and often reveals which paths are actually hot versus dead weight — which can reshape Step 1’s priorities.
Rollback strategy: plan the exit before you plan the entry
Every migrated slice needs a rollback answer decided before the cutover, not during the incident:
- Route-level rollback — the config-driven registry above makes reverting a route a config change, executable within minutes.
- Data reconciliation — decide upfront how data the new service wrote gets reconciled into legacy’s expectations after a rollback.
- Feature-flag granularity — route by cohort or percentage, not just URL, so a bad migration affects 5% of traffic before 100%.
- A defined bake period per phase — each slice runs in production, monitored against the observability baseline, before the next starts.
For budgeting — rough, hedged ranges, not a quote for any specific system: assessment and safety-net work (Steps 1-2) typically runs 4-8 engineer-weeks before extraction starts; each subsequent bounded context commonly runs another 3-6; a cautious, cohort-based auth migration is frequently a multi-month effort on its own. These move with how much undocumented behavior surfaces, but they’re roughly the shape of investment a CTO should plan against.
When this approach is the wrong choice
- The legacy technology itself is the blocker (unsupported OS, unpatched framework, a database nearing end of life) on a hard compliance deadline — a scoped rewrite of the riskiest pieces may be forced regardless.
- The codebase is small enough that a rewrite is genuinely a few weeks of work — running two systems in parallel costs more than it saves.
- The team has no appetite for the required discipline — the approach degrades into a half-migrated system more complex than the monolith it replaced.
Key takeaways
- Map real dependencies and separate core domain from peripheral functionality; surface undocumented rules by asking why, not just what.
- Characterization tests turn refactors from a leap of faith into a verifiable operation — budget engineer-weeks, not days, for modules with real business logic.
- Shared session state is the dependency teams most underestimate: decide on a shared store or an explicit token boundary before the first cutover.
- Wrap legacy functionality behind a stable API and an anti-corruption layer.
- Sequence auth migration deliberately: authorization before the identity-provider swap, cohort by cohort, each with its own bake period.
- Keep schema changes additive; split databases per bounded context only once ownership is genuinely clean — a late step, not an early one.
- Define rollback mechanics before each phase, with a rough engineering-time budget in mind — an approach HzMinds would typically recommend scoping in phases, not as one lump estimate.
- This isn’t universal: hard deadlines, small codebases, or a team without appetite for the safety-net work argue for a different path.
Was this article helpful?
Working through a similar engineering problem? Talk to the HzMinds engineering team.
Talk to us