.NET Modernization
ASP.NET Web Forms Migration: Moving to Modern .NET Without a Rewrite
Web Forms has no equivalent in modern .NET, so this is an architecture migration, not a retarget. Here's the technical shape of doing it incrementally.
An ASP.NET Web Forms migration is not a framework upgrade. Moving from .NET 6 to .NET 8 is a
retarget-and-test job; moving off Web Forms is not, because the entire programming model it's built on — the
page lifecycle, ViewState, server controls, the postback event pipeline — has no counterpart in
ASP.NET Core. There is no System.Web, no <asp:GridView>, no
Page_Load. That's the fact that makes people brace for a big-bang rewrite. This post is about the
technical alternative: how the migration actually works when you do it incrementally.
Why it isn't a simple retarget
Before any strategy, it helps to be precise about what is missing, because each gap dictates a piece of the migration:
- Stateful page/event model → stateless requests. Web Forms simulates a stateful, desktop-like UI over HTTP using ViewState and postbacks. ASP.NET Core is stateless request/response. Any logic that assumes a live control tree between requests has to be rethought.
- ViewState → nothing (on purpose). The hidden
__VIEWSTATEfield round-trips serialized control state on every postback. There is no equivalent, and you don't want one — you make state explicit instead. System.Web.HttpContext→Microsoft.AspNetCore.Http.HttpContext. Same name, different type. Any code touchingHttpContext.Current,Request,Response,Session, orServerwill not compile against Core unchanged. This is usually the single most pervasive source of coupling.- Server controls + postbacks → tag helpers/components + model binding.
runat="server"controls, the control tree, and__doPostBackare replaced by Razor markup, tag helpers, and HTTP verbs — or by Blazor components with real event handlers. - Master pages /
.ascx→ layouts / components. A direct conceptual map, but a mechanical rewrite. Global.asax, HttpModules, HttpHandlers → the middleware pipeline. Cross-cutting request logic is re-expressed as middleware.Web.config→ configuration + options. Settings, connection strings, and pipeline config move toappsettings.json, environment variables, and startup code.
Rebuild all of that at once and flip a switch, and you've signed up for the classic 12–18 month big-bang rewrite — parallel maintenance, forgotten edge cases, one terrifying cutover. The incremental route sidesteps that. It has three technical pillars: share the logic, run both apps at once, and rebuild the UI slice by slice.
Pillar 1 — Extract the logic to .NET Standard 2.0
The enabler for everything else is .NET Standard 2.0: a class library targeting it can be referenced, unchanged, by both the old .NET Framework app (4.6.1+) and the new .NET Core / .NET app. That shared library is the bridge you migrate across. So the first move — before touching a single page — is to lift business logic out of the code-behind into services in a .NET Standard library.
// BEFORE — logic trapped in the .aspx code-behind, tied to controls + ViewState protected void SubmitButton_Click(object sender, EventArgs e) { var total = 0m; foreach (GridViewRow row in CartGrid.Rows) { /* pull qty + price out of ViewState */ } // pricing, tax, discount rules all inline; needs a page instance to run at all SaveOrder(total); ResultLabel.Text = "Order placed"; } // AFTER — rules move into a plain service in a .NET Standard 2.0 library // (referenced UNCHANGED by both the Framework app and the new .NET Core app) public class OrderService { public OrderResult PlaceOrder(Cart cart) { var total = _pricing.Calculate(cart); // UI-free, unit-testable, host-agnostic return _repo.Save(cart, total); } }
The constraint that bites: .NET Standard has no System.Web. Any logic that reads
HttpContext.Current, the current user, session, or request data can't move verbatim. You abstract
those touchpoints behind small interfaces, implemented once per host — so the shared code stays host-agnostic.
// The catch: .NET Standard has no System.Web, so code that reaches into // HttpContext.Current can't move as-is. Abstract it behind an interface. public interface ICurrentUser { string UserId { get; } bool IsInRole(string role); } // Framework app implements it against System.Web... class WebFormsCurrentUser : ICurrentUser { public string UserId => HttpContext.Current.User.Identity.Name; public bool IsInRole(string r) => HttpContext.Current.User.IsInRole(r); } // ...the Core app implements the same interface against IHttpContextAccessor. // The shared service depends only on ICurrentUser — it never sees either host.
Do this well and, by the time you rebuild a page in ASP.NET Core, there's no logic left to port — the new UI just calls the same tested service. You're re-skinning, not reimplementing.
Pillar 2 — Run both apps at once (the strangler fig)
You don't migrate the app; you migrate routes. Put a reverse proxy in front of both apps — YARP (Yet Another Reverse Proxy, Microsoft's .NET proxy) is the natural fit. Paths you've rebuilt route to the new Core app; a catch-all sends everything else to the legacy Web Forms app. To the browser it's one site at one origin, and each migrated slice ships independently with its own small, reversible cutover.
// YARP sits in front of both apps. Migrated paths route to the new .NET // Core app; a catch-all sends everything else to the legacy Web Forms app. "ReverseProxy": { "Routes": { "orders-migrated": { "Match": { "Path": "/orders/{**rest}" }, "ClusterId": "core" }, "legacy-fallback": { "Match": { "Path": "/{**rest}" }, "ClusterId": "webforms" } }, "Clusters": { "core": { "Destinations": { "d": { "Address": "https://app-core:5001/" } } }, "webforms": { "Destinations": { "d": { "Address": "https://app-legacy:8080/" } } } } }
The hard part of coexistence isn't routing — it's shared state and identity while the two
apps run side by side. This is exactly what Microsoft's System.Web Adapters
(Microsoft.AspNetCore.SystemWebAdapters) exist for, and they're the piece that makes incremental
ASP.NET migration genuinely practical:
- Shared session. The adapters let the Core app read and write the same session state as the Framework app during the transition, so a half-migrated flow doesn't lose the user's context mid-journey.
- Remote app authentication. The new app doesn't need its own login on day one. It can defer to the legacy app to resolve the authenticated user (over a secured internal call), so a signed-in user moving from a legacy page to a migrated one stays signed in. You migrate auth deliberately, later — not as a prerequisite.
HttpContextshims. The adapters provide aSystem.Web-shapedHttpContextsurface on Core, so code that leans on the old API can move with far fewer edits while you refactor it properly over time.
(If you're only sharing a cookie — e.g. Forms Auth — the lighter-weight option is a shared ASP.NET Core Data Protection key ring so both apps can read the same auth cookie. The adapters are the fuller answer when session and identity are entangled.)
Pillar 3 — Rebuild the UI: what replaces ViewState and postbacks
This is where the real rewriting happens, and where the mindset shift matters. Web Forms used ViewState and postbacks to fake statefulness over a stateless protocol. Modern .NET stops pretending:
- Razor Pages / MVC — the mainstream target. State is explicit: form fields bind to a model, you decide what persists (TempData, a distributed session, the database, or the client). Most Web Forms pages are, underneath, a form posting to a handler — which is precisely what a Razor Page is.
- Blazor — the closest paradigm to Web Forms: component-based, stateful, event-driven UI in C#. A
Button.Clickhandler and a stateful component tree feel familiar to Web Forms developers, which flattens the learning curve — at the cost of a heavier runtime model to understand (Server vs. WebAssembly). For teams whose instinct is "give me back my event handlers," it's often the pragmatic choice.
The control-level mapping is mostly mechanical once the model is chosen:
- Master page →
_Layout.cshtml/ Blazor layout component - User control (
.ascx) → partial view, view component, or Razor/Blazor component <asp:*>server controls → tag helpers + model binding, or Blazor components__doPostBack/ScriptManager→ normal form posts,fetch, or Blazor events- ViewState /
Session→ explicit model binding and a chosen state store
A realistic migration order
Sequence matters as much as technique. A migration that tends to go smoothly looks like:
- 1. Prove the pipeline first. Stand up YARP, the empty Core app, shared layout, and the adapters (session + remote auth) — then migrate one trivial, low-risk leaf page end to end. You want the coexistence plumbing validated before anything important rides on it.
- 2. Extract logic ahead of the UI. Move each area's business rules into the shared .NET Standard library first (with tests), so the page rebuild is pure presentation.
- 3. Migrate by seam, not by size. Take self-contained sections — a module, a workflow — rather than scattering half-migrated pages. Each becomes a route the proxy flips.
- 4. Leave auth and the highest-traffic, ViewState-heaviest pages until the pattern is proven on simpler ones. Remote auth buys you the right to defer the login rebuild.
- 5. Retire the legacy app when it's empty. The Web Forms app shrinks route by route until nothing points to it and it can be switched off.
The gotchas that actually cost time
The framework mechanics are the easy part. The schedule usually goes sideways on these:
HttpContext.Currentreached from everywhere — including deep in "business" code and static helpers. Finding and abstracting every touchpoint is often the biggest single task.- Third-party server controls — Telerik, DevExpress, and similar Web Forms control suites have no drop-in Core equivalent. Those screens are genuine rebuilds, and they're often the most complex ones.
- ViewState-heavy pages — grids with inline edit, wizard flows, and dynamic control trees encode a lot of implicit state. Making that state explicit is where the real design work lives.
- Session serialization differences — Framework and Core serialize session differently; sharing it (via the adapters) means being deliberate about what's stored and how it serializes.
- Client script tied to the postback model — code depending on
__doPostBack,UpdatePanel, or generated control IDs needs reworking alongside the markup. - Globalization and culture quirks, plus anything relying on
Web.configtransforms or IIS modules, need their Core equivalents thought through.
A worked example
This is the exact approach we took modernizing a 15-year-old, business-critical ASP.NET Web Forms API that still powered a consumer mobile app with a large installed base: extract the logic, keep the external contract byte-for-byte identical, rebuild one endpoint at a time behind a proxy, and prove compatibility before any real traffic moved — zero downtime, zero broken clients. The full technical write-up, including the latent bugs a faithful rewrite surfaces and how we proved parity, is in the Modernizing a Legacy API series, and the broader strategy sits in our legacy .NET modernization work.
The throughline: Web Forms migration feels like a rewrite because the UI model genuinely changes — but with a shared .NET Standard core, a YARP + System.Web Adapters coexistence layer, and a page-at-a-time cadence, it becomes a controlled migration you ship continuously, not a bet you make all at once.
Let's Talk About Your Project
A quick 30‑minute call is all it takes to find out if we're a good fit for each other. Book a time and we'll take it from there.