Moving a live ASP.NET Web Forms application to modern .NET

The route-by-route method in full: what has to be true before the first page moves, how one identity and one session are held across two stacks, how re-implemented business logic is proved correct against the original, and what goes wrong. Written to be executed, including by someone who never hires us.

12 min read · Updated

01Why the rewrite is the expensive option

A rewrite sounds like the decisive choice and behaves like the opposite. It asks a business to run two systems, stop shipping features for as long as it takes, and stake the result on a single cutover date that nobody can move once it is announced. Every risk in the project is concentrated on that one day, and none of the value arrives before it.

The alternative is not new. Martin Fowler named it the strangler fig: you put something in front of the old application, move one path at a time to a new implementation, and let the old system shrink until nothing is left of it. It is slower to describe and considerably cheaper to survive, because the work returns value continuously and every step is individually reversible.

The reason teams reach for the rewrite anyway is that the incremental path has an awkward first month. Nothing visible moves while you build the machinery that makes moving things safe. That month is the whole trick, so it is worth being precise about what gets built in it.

02Three things must be true before any page moves

A route cannot move until a user crossing between the old stack and the new one cannot tell. That means three properties have to hold first, and all three are infrastructure rather than features.

One entry point
Both applications sit behind a single routing layer serving one domain, one certificate and one cookie scope. YARP is the usual choice on .NET because it runs in-process in the new application, so the new stack owns routing and forwards anything it does not yet handle to the old one.
One identity
A user signed in on either stack is signed in on both. Microsoft's System.Web Adapters provide remote app authentication for exactly this: the ASP.NET Core application defers identity to the Framework application rather than each maintaining its own notion of who is logged in.
One session
Session state written by one stack is readable by the other. The same adapters expose remote session support. Legacy Web Forms code leans on Session far more than anyone remembers until they try to move a page that uses it.

Attempting a route migration before all three hold produces the classic failure: the page works in isolation, and users get logged out when they click into it.

03The authentication problem, specifically

Sharing a login between .NET Framework and .NET is the single most underestimated task in this kind of migration, because the two platforms protect cookies differently. Framework Forms Authentication encrypts its ticket with MachineKey. ASP.NET Core uses the Data Protection stack. Neither reads the other by default.

There are two viable answers, and the choice matters more than it looks.

Remote app authentication
The new application asks the old one who the user is. Nothing about the existing login has to change, which makes it the lower-risk option and the right default when the legacy authentication is complicated, custom, or poorly understood.
Shared cookie format
Both stacks read the same cookie, using the interop packages that let Core produce and consume Framework-compatible tickets. Fewer moving parts at runtime, but it means changing how the legacy application handles login before you have moved a single page.

Prefer deferring to the old application first. Unifying the cookie format is a migration in its own right, and doing it while everything else is still unproven means a failure has two possible causes instead of one.

04Choosing the first route

The first migrated page is not chosen for business value. It is chosen to prove the machinery, so it should be the route where being wrong costs least while still exercising the full path.

  • It reads rather than writes, so a mistake shows up as a wrong page rather than wrong data
  • It requires an authenticated user, so it proves identity crosses the boundary
  • It touches Session, so it proves session crosses too
  • It is used often enough that a problem surfaces in hours, not next quarter
  • It is not the busiest page on the platform, because the first one is where you discover what you got wrong

A read-only authenticated detail page is almost always the right first move. A login page or a checkout is almost always the wrong one.

05The data layer during the crossing

Both stacks talk to the same database for the entire migration. This is a feature, not a compromise: it is what makes a route reversible, because no data has moved and nothing has to be migrated back.

Resist the urge to introduce a new ORM and a new schema at the same time as the new stack. A migrated route that also changes how data is read has two variables, and when the numbers come out wrong you will not know which one caused it. Move the route first on the existing data access, then modernise the access separately, as its own change with its own rollback.

Stored procedures are usually where the real business logic lives in an application of this age. That is inconvenient but it is also useful: a procedure is a stable contract you can call identically from both stacks while the page around it moves.

06Re-implementing business logic you cannot read

Some of this work is not translation but reconstruction: rules that live in a mainframe, in a procedure nobody has opened in a decade, or in the head of someone who left. You are handed a specification and asked to build against it, and the specification is always slightly wrong, because it describes what the rule was meant to do rather than what it does.

The only reliable way to close that gap is to run both implementations against the same real inputs and compare the outputs until they agree.

Parallel run, in shape
for each production input in a representative window:
    expected = legacy_implementation(input)
    actual   = new_implementation(input)
    if actual != expected:
        record(input, expected, actual)   // do not fail, collect

// ship only when the difference set is empty, or every remaining
// difference is a legacy defect the business has agreed to correct

Run it in production against live traffic, with the new implementation's result discarded. Rules that have been running for twenty years have edge cases nobody remembers, and a sample of real inputs finds them where a test suite written from the specification cannot: the specification is the thing that is incomplete. This technique has a guide of its own, covering where to run the comparison, how to normalise it, and what to do when the old system turns out to be wrong.

07Releasing one route, and taking it back

A migrated route ships behind a switch at the routing layer, not behind a deployment. Whether a path is served by the new stack or the old one is configuration, which means reversing it is a configuration change and not a rollback of a release.

This is the property that makes the whole method safe, and it is worth protecting. If reverting a route requires a redeploy, the team will hesitate at exactly the moment hesitation is expensive.

  • Route switching lives in configuration and can be changed without shipping code
  • The old implementation stays deployed and functional until the new one has carried real traffic
  • Both implementations log identically, so comparing behaviour does not mean comparing two different log formats
  • One route at a time reaches production, so when something breaks the cause set has one member

08Retiring, which is the step people skip

A route is not migrated when the new page works. It is migrated when the old page is deleted.

Code that is superseded but still deployed is not progress, it is a second system to maintain, and it quietly recreates the cost the migration was meant to remove. Worse, it stays wired to the same database, so an unreachable page can still be reached by anyone who kept the URL.

Delete the old implementation once the new one has carried production traffic long enough to cover its cycle, which for anything touching billing means at least one month end. Then remove the routing rule, so the configuration reflects reality rather than a history of what used to be true.

09What actually goes wrong

In roughly the order it tends to bite.

Session was load-bearing
Web Forms applications store far more in Session than anyone expects, often set on one page and read three pages later. Migrating the reader before the writer produces a fault that appears only on a specific navigation path.
ViewState hid the state machine
A page's behaviour can depend on ViewState accumulated across postbacks. That state has no equivalent in a stateless model, and finding it usually means reading the page's event handlers rather than its markup.
The specification was aspirational
Re-implemented rules match the document and not production. This is why the parallel run exists, and why its output is a difference set rather than a pass or a fail.
Two logging formats
If the stacks log differently, comparing behaviour becomes archaeology. Unify structured logging early, before it is needed, because you will need it while something is on fire.
The scope crept sideways
A migrated page is a tempting place to fix a design nobody liked. Do not. Move it as it is, then change it as a separate piece of work, or you lose the ability to say whether the migration or the redesign caused the regression.

10What this costs

The first month buys no visible features. The boundary, the shared identity, the shared session and the release switch all have to exist before the first page moves, and none of that is demonstrable to anyone outside the team.

After that the work is repetitive in the best sense: each route is a small, individually reversible change, the estimate improves as more routes go through, and the platform gets continuously better rather than getting worse for a year and then better all at once.

The trade is a slower start for the removal of cutover risk. On any system that a business actually depends on, that is the correct trade.

Also in guides

This is the method we use, published in full.

If you would rather not run it yourself, the two-week assessment produces the sequence for your specific system, and the plan is yours whoever executes it.