Migrating from ASP.NET MVC 5 to ASP.NET Core MVC: Equivalents of Global.asax, Web.config, HtmlHelper and Bundling
Moving from MVC 5 to ASP.NET Core turns Global.asax into Program.cs, Web.config into Options, HttpModules into middleware, child actions into view components and bundling into MapStaticAssets; a large app moves piece by piece behind a YARP fallback. We show real build errors for @helper and @Html.Action, the CS0104 trap with System.Web adapters and what replaced Upgrade Assistant.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402), Yarp.ReverseProxy 2.3.0 and Microsoft.AspNetCore.SystemWebAdapters 2.3.0. There's no .NET Framework on this machine, so instead of the old MVC (Model-View-Controller) 5 app I used a small ASP.NET Core app that answers the same URLs (Uniform Resource Locator).
Guess First
You take a small price-formatting helper class from a ten-year-old ASP.NET MVC 5 app. It contains the line HttpContext.Current.Request.QueryString["currency"]. You add the Microsoft.AspNetCore.SystemWebAdapters package to the new .NET 10 project; the package exists precisely so this kind of code can run on ASP.NET Core. What happens when you copy the file as is and compile it with using System.Web;? And imagine putting one of the old views, one with @helper, @Styles.Render and @Html.Action in it, into the new project. How many errors does the build give?
Why Is This Still a Topic?
In Turkey, many ASP.NET MVC 5 apps still run in banking, insurance, government and large retail: on .NET Framework 4.8, behind IIS (Internet Information Services), with Web.config files hundreds of lines long. .NET Framework 4.8 is supported as a component of Windows, so the app won't stop tomorrow. But new C# versions, Linux containers, MapStaticAssets, OpenTelemetry integration, performance improvements and everything this series covers don't come to that side.
This post answers two questions: what the ASP.NET Core equivalents of the old app's concepts are, and how to move a large app without rewriting it in one go. We covered reading old projects in the Startup.cs migration post of the Web API (Application Programming Interface) series from the .NET Core 3.1 angle; here the starting point is older: .NET Framework.
Concept Mapping Table
| ASP.NET MVC 5 (.NET Framework) | ASP.NET Core (.NET 10) |
|---|---|
Global.asax and Application_Start | Program.cs: WebApplication.CreateBuilder, service registration, pipeline |
RouteConfig.RegisterRoutes | MapControllerRoute, attribute routing |
FilterConfig and GlobalFilters | AddControllersWithViews(o => o.Filters.Add(...)) |
appSettings and connectionStrings in Web.config | appsettings.json, environment variables, IOptions<T> |
IHttpModule, IHttpHandler | Middleware, endpoints (MapGet, MapForwarder) |
System.Web.Optimization and BundleConfig | MapStaticAssets, a separate bundler if needed |
@Styles.Render, @Scripts.Render | <link> and <script> tag helpers |
Child actions (@Html.Action, [ChildActionOnly]) | View components (<vc:cart-summary>) |
@helper | Partial view, tag helper or a local function in Razor |
HttpContext.Current | Constructor injection, IHttpContextAccessor or System.Web adapters |
Session["Cart"] (stores objects) | ISession (stores byte arrays, needs AddSession and UseSession) |
| DI (Dependency Injection) with Unity, Autofac, Ninject | The built-in DI container |
Read row by row, the left column isn't scary; most rows map to a post in this series or in the Web API series. The hard part is that these concepts have spread through every corner of the code over ten years.
From Global.asax and Web.config to Program.cs and Options
In MVC 5 the app starts up in Application_Start by calling RouteConfig, BundleConfig and FilterConfig; settings are read as strings with ConfigurationManager.AppSettings["PageSize"], and parse errors show up on first use. In ASP.NET Core both jobs move to Program.cs, and settings are bound to a typed class:
public class BookstoreOptions
{
public const string SectionName = "Bookstore";
[Range(1, 100)]
public int PageSize { get; set; } = 20;
[Required, EmailAddress]
public string SupportEmail { get; set; } = "";
}
builder.Services.AddOptions<BookstoreOptions>()
.BindConfiguration(BookstoreOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
The old <appSettings> keys move into a JSON (JavaScript Object Notation) section:
{
"Bookstore": {
"PageSize": 20,
"SupportEmail": "support@bookstore.example"
}
}
Thanks to ValidateOnStart, a wrong value stops the app from starting. When I deliberately passed bad values through environment variables (Bookstore__PageSize=500, Bookstore__SupportEmail=not-an-email), the app stopped before the first request:
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'BookstoreOptions' members: 'PageSize' with the error: 'The field PageSize must be between 1 and 100.'.; DataAnnotation validation failed for 'BookstoreOptions' members: 'SupportEmail' with the error: 'The SupportEmail field is not a valid e-mail address.'.
With Web.config, the same mistake could surface only as a FormatException the first time the page reading that setting was opened. The rest of the options pattern (provider order, IOptionsSnapshot, secrets) is in the configuration post of the Web API series. The other half of Web.config, the IIS settings under <system.webServer>, is largely done in code in ASP.NET Core; the small web.config that still appears in the publish output of an ASP.NET Core app hosted on IIS exists only to configure the ASP.NET Core Module.
From HttpModule to Middleware
IHttpModule was the home of code that touched every request in MVC 5: logging, timing, adding headers, redirecting old URLs. The ASP.NET Core equivalent of a module that subscribes to the BeginRequest and EndRequest events is a middleware:
// the old RequestTimingModule (IHttpModule) as middleware
app.Use(async (context, next) =>
{
long start = Stopwatch.GetTimestamp();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = Stopwatch.GetElapsedTime(start).TotalMilliseconds.ToString("0.0");
return Task.CompletedTask;
});
await next(context);
});
Adding a header in EndRequest usually worked in MVC 5; in ASP.NET Core you can't add headers after the response has started, so we use OnStarting. The order of modules in web.config becomes the order of lines in Program.cs; the rules from the middleware post of the Web API series apply as is. The equivalent of an IHttpHandler is simply an endpoint: a handler that produced a file through .ashx becomes app.MapGet("/export/{id}", ...).
The View Side: What Doesn't Compile?
I copied an old view into the new project:
@helper PriceTag(decimal price)
{
<span class="price">@price.ToString("0.00")</span>
}
@Styles.Render("~/Content/css")
@Html.Action("CartSummary", "Cart")
@Html.Partial("_Header")
The build output (the answer to the second question):
Views/Home/Legacy.cshtml(1,2): error CS0103: The name 'helper' does not exist in the current context
Views/Home/Legacy.cshtml(1,2): error RZ1002: The helper directive is not supported.
Views/Home/Legacy.cshtml(3,26): error CS0103: The name 'price' does not exist in the current context
Views/Home/Legacy.cshtml(5,2): error CS0103: The name 'Styles' does not exist in the current context
Views/Home/Legacy.cshtml(6,2): error CS1929: 'IHtmlHelper<dynamic>' does not contain a definition for 'Action' and the best extension method overload 'UrlHelperExtensions.Action(IUrlHelper, string?, object?)' requires a receiver of type 'Microsoft.AspNetCore.Mvc.IUrlHelper'
Views/Home/Legacy.cshtml(7,2): warning MVC1000: Use of IHtmlHelper.Partial may result in application deadlocks. Consider using <partial> Tag Helper or IHtmlHelper.PartialAsync.
Four lines produced five errors and a warning. The @helper directive doesn't exist in ASP.NET Core Razor (RZ1002). For a small HTML (HyperText Markup Language) fragment, a partial view or a tag helper does the job; for something specific to one view, a local function returning HTML in @functions is enough. @Styles.Render and @Scripts.Render were part of System.Web.Optimization; they're replaced by <link href="~/css/site.css"> and MapStaticAssets, as we saw in the static assets post. @Html.Action invoked a child action, and ASP.NET Core has no child actions; the equivalent is the CartSummaryViewComponent from this series' partial and view component post. @Html.Partial still compiles, but warns because it's synchronous; use <partial name="_Header" />.
There are easily missed differences too. Request.Query["x"] instead of Request.QueryString["x"], model binding instead of Request.Form, and Url.Content("~/...") still exists, but tag helpers resolve ~/ anyway. ViewBag and ViewData still work; we discussed why we prefer ViewModels in the MVC post.
Moving Piece by Piece: Strangler Fig with YARP
Microsoft's migration documentation recommends incremental migration for large apps that must stay in production, and describes it as an implementation of the Strangler Fig pattern. The idea: you put a new ASP.NET Core app in front. The new app answers the URLs that have been moved, and everything else is forwarded as is to the old app. The user sees a single address; you move page by page, and when the last page has moved you switch the old app off. We'll cover the architectural side of the pattern in the upcoming API Gateway and YARP (Yet Another Reverse Proxy) series; here all we need is a one-line fallback:
builder.Services.AddControllersWithViews();
builder.Services.AddSystemWebAdapters();
builder.Services.AddHttpForwarder();
var app = builder.Build();
app.UseRouting();
app.UseSystemWebAdapters();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllers().WithStaticAssets();
// everything not migrated yet goes to the legacy app
app.MapForwarder("/{**catch-all}", app.Configuration["LegacyAppUrl"]!)
.WithOrder(int.MaxValue)
.ShortCircuit();
WithOrder(int.MaxValue) gives this route the lowest priority: if a controller in the new app matches, it wins; otherwise the request goes to the old app through YARP. ShortCircuit() skips the rest of the pipeline for forwarded requests. /Books has been moved; /Orders and /Account/LogOn haven't yet:
=== GET /Books
Books (ASP.NET Core) | page size 20 | 42.50 TRY
HTTP/1.1 200 OK
X-Elapsed-Ms: 24.0
=== GET /Orders
<h1>Orders (legacy MVC 5)</h1><p>::1</p>
HTTP/1.1 200 OK
X-Elapsed-Ms: 37.2
=== GET /Account/LogOn?returnUrl=%2F
legacy: /Account/LogOn
HTTP/1.1 200 OK
The old app sees the X-Forwarded-For header (::1); old code that needs the user's real IP (Internet Protocol) address must be set up to read this header. Microsoft's documentation requires that the old app not be exposed directly to the outside, only reached through the proxy, and that both apps use the same virtual directory layout.
System.Web Adapters: Moving HttpContext.Current Without Breaking It
The practical problem with strangler fig: when you move /Books, the class libraries that page uses must move to the new side too. In a ten-year-old library, HttpContext.Current is everywhere. The System.Web adapters package redefines a subset of types like System.Web.HttpContext, HttpRequest and HttpResponse on top of ASP.NET Core, so this code often compiles as is. The file from Guess First:
using System.Web;
namespace Bookstore.Web.Legacy;
// copied as is from the MVC 5 project
public static class LegacyPriceFormatter
{
public static string Format(decimal price)
{
string currency = HttpContext.Current.Request.QueryString["currency"] ?? "TRY";
return $"{price:0.00} {currency}";
}
}
The answer to the first question:
Legacy/LegacyPriceFormatter.cs(10,27): error CS0104: 'HttpContext' is an ambiguous reference between 'Microsoft.AspNetCore.Http.HttpContext' and 'System.Web.HttpContext'
The adapters package works fine; the problem is ImplicitUsings, the default since .NET 6. The Web SDK adds an invisible using Microsoft.AspNetCore.Http; to every file, and the two HttpContexts collide. Keeping the old library in a separate project with ImplicitUsings turned off is one way. If you're in the same project like us, writing the full type name is enough. With the full name, the compiler gave a warning this time:
Legacy/LegacyPriceFormatter.cs(9,27): warning CS8602: Dereference of a possibly null reference.
That's a fair warning: HttpContext.Current in the adapters is annotated as nullable, and outside a request (a background job, startup code) it really is null. It was the same on .NET Framework, but the compiler didn't say so. The final version:
namespace Bookstore.Web.Legacy;
// moved from the MVC 5 project; the only changes are the full System.Web name and ?.
public static class LegacyPriceFormatter
{
public static string Format(decimal price)
{
// ImplicitUsings brings Microsoft.AspNetCore.Http, so qualify System.Web
string currency = System.Web.HttpContext.Current?.Request.QueryString["currency"] ?? "TRY";
return $"{price:0.00} {currency}";
}
}
With builder.Services.AddSystemWebAdapters() and app.UseSystemWebAdapters() (after routing) the code works: the /Books?currency=EUR request returned 42.50 EUR. The purpose of the adapters is to shorten the migration, not to be a permanent architecture. Keep using constructor injection and IHttpContextAccessor in code you write new; keep the adapters only for old libraries you haven't touched yet.
The package also has remote app features: the new app can ask the old one about session and authentication. The new app can read the cart the old app wrote to Session["Cart"]; a user who signed in on the old app is recognized on the new pages too. For this, both sides define RemoteAppApiKey (a shared key in GUID (Globally Unique Identifier) format) and the new side defines RemoteAppUri; on the .NET Framework side AddRemoteAppServer is added to Global.asax. I can't run this part without .NET Framework, so I'm not giving code for it; Microsoft's "Remote app setup" documentation shows the code for both sides step by step.
Tools: From Upgrade Assistant to Copilot
For a long time the tool for this job was the .NET Upgrade Assistant. Microsoft's documentation now officially marks it as deprecated and points instead to the GitHub Copilot app modernization agent that ships with Visual Studio 2026 and Visual Studio 2022 17.14.16 or later. The agent analyzes the solution, produces a migration plan, converts project files to SDK-style, replaces deprecated APIs and commits each step separately. The code must be in a local Git repository and, according to press reports, it requires a paid Copilot plan.
Knowing the tools' limits matters. Converting the project file, changing namespaces and moving from packages.config to PackageReference are mechanical jobs, and tools do them well. Turning a library that uses HttpContext.Current in ten places into DI, redesigning code that stores objects in session, splitting child actions into view components: those are design decisions. A tool can suggest them, but you decide whether they're right, and you need to test them.
Production Notes
Two apps, two logs. During the migration a request can pass through both apps; without a shared correlation ID (e.g. the traceparent header from the W3C (World Wide Web Consortium) standard) and a single log destination, answering "which side was the error on" takes hours. YARP carries the trace context. A request arriving at the new app with traceparent reaches the old app with the same trace ID and a new span ID:
incoming: traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
at legacy: traceparent=00-0af7651916cd43dd8448eb211c80319c-7618bff8a209c373-01
The old app has to log this header too; otherwise the chain breaks there.
Sharing authentication cookies is a separate job. If the old app uses the OWIN (Open Web Interface for .NET) cookie middleware, both sides can read the same cookie with a shared Data Protection key ring and the same cookie name; for older apps using Forms Authentication, the most practical route is the remote authentication feature of System.Web adapters. The depth belongs to the upcoming Authentication and Authorization series.
Session behavior changes. ASP.NET Core session stores byte arrays and doesn't hold object references; objects in old code that were taken from Session["Cart"], modified and "saved by themselves" won't be saved on the new side. Explicitly serialize everything written to session on the pages you move.
Look at traffic data to choose the order. Pages that are visited the most but have the fewest dependencies (catalog, product detail) give early wins; session- and transaction-heavy flows like orders and payment come last.
Common Pitfalls
Trying to rewrite everything in one go. During a year of parallel development the old app changes too; the new code never catches up. The value of strangler fig is small steps that reach production every week.
Forgetting ImplicitUsings. Names like HttpContext, HttpRequest and HttpResponse exist in two namespaces; the CS0104 error comes from there.
Leaving @Html.Partial in place. It compiles, but the MVC1000 warning isn't there for nothing; synchronous rendering can exhaust the thread pool under load.
Not giving the fallback route a priority. Without WithOrder(int.MaxValue), the catch-all route competes with the new app's own endpoints.
Leaving the old app open to the outside even behind the proxy. Two front doors mean two separate security surfaces.
A Small Experiment
★☆☆ Write two equivalents for @helper PriceTag from the old view above: a partial view (_PriceTag.cshtml) and a tag helper (<price-tag value="...">). Which is better for testing and reuse?
★★☆ Start both apps from this post and move the /Orders page to the new app too: add an OrdersController. Without touching any YARP setting, verify with curl that /Orders now comes from the new app.
★★★ Assume the old app logs the user's IP address by reading the X-Forwarded-For header. Examine the X-Forwarded-* headers YARP sends, and test that the chain carries the right IP when the new app itself sits behind a load balancer (UseForwardedHeaders).
With this post we looked back and saw how to bring old code into the new world. In the last post of the series we look forward: what came to MVC and Razor Pages from .NET 8 to .NET 11, which behaviors changed silently, and what you need to check when upgrading.