Ready to migrate to .NET 8? Check SSW's .NET 8 Migration consulting page.
In the world of software development, staying up-to-date with technology is not just a trend; it's a necessity. As the digital landscape evolves, so do the tools and frameworks that developers rely on to build applications. One significant transition that has been taking place in recent years is the migration from the legacy .NET Framework to the latest .NET offerings. Read more about the .NET version lifecycle. But why is this shift so crucial, and what benefits does it bring to the table?
Migrating from .NET Framework (4.x) to the latest .NET (5+) brings huge advantages to your app's performance, hosting fees, and maintainability. But it's important that you understand what the road to .NET 5+ looks like for your app before you start breaking things! So how do you ensure your migration is being done the right way?
The differences between a web app built with ASP.NET Framework and one built with ASP.NET Core are immense. The entire request pipeline underwent significant changes, and can often be impossible to migrate in-place. So how can you tackle these challenges the right way?
With outdated NuGet packages, C# stylings and architectures, keeping our .NET Framework applications up to date can be a pain. Especially when we want to make the leap from .NET Framework to .NET for that juicy performance and compatibility bump.
Luckily Microsoft provides excellent tooling for supporting your great leap into .NET. The best part? It's free and open source!
When upgrading a web application from .NET Framework to .NET Standard or .NET, you will likely have to address System.Web. On upgrade, the reference to System.Web is either removed or will cause compile-time errors.
When itβs removed, the most common compile time error will be HttpContext.Current.
There are several options available depending on the scenario and stage of migration.
When moving to .NET, youβll find HttpContext.Current no longer exists. You can use IHttpContextAccessor instead in constructor and access it via httpContextAccessor.HttpContext.
public class SomeService{public void DoSomething(){var httpContext = HttpContext.Current;// Rest of the code...}}
β Figure: Bad example - An example .NET Framework code snippet demonstrating use of HttpContext.Current in a method.
public class SomeService{private readonly IHttpContextAccessor _httpContextAccessor;public SomeService(IHttpContextAccessor httpContextAccessor){_httpContextAccessor = httpContextAccessor;}public void DoSomething(){var httpContext = _httpContextAccessor.HttpContext;// Rest of the code...}}
β Figure: Good example - Code snippet showing the replacement of HttpContext.Current with IHttpContextAccessor in a .NET service class.
You can also abstract what you need from HttpContext, which can be useful it you want to run the code in a non-web environment like a console app or if you want to keep dependency on .NET Framework.
If you multi-target .NET and .NET Framework, we need to add back System.Web reference for .NET Framework. We do that by updating the csproj file.
<!-- .NET Framework reference for HttpContext --><ItemGroup Condition="'$(TargetFramework)' == 'net472'"><Reference Include="System.Web" /></ItemGroup><!-- .NET reference for IHttpContextAccessor, already included if the project is WebApp --><ItemGroup Condition="'$(TargetFramework)' == 'net8.0'"><FrameworkReference Include="Microsoft.AspNetCore.App" /></ItemGroup>
Figure: Conditional inclusion of the "System.Web" reference in a .NET Framework 4.7.2 project.
Next step is to define an interface. For this case, weβll only expose currently authenticated user. We are calling it IApplicationContext as it contains context for current request, whether itβs coming from an HttpContext or somewhere else if itβs a background job or console application.
public interface IRequestContext{string GetCurrentUsername();}
Figure: IRequestContext interface for retrieving the current user's username.
Below you can see multiple implementations of IRequestContext as an example. You may need to implement in the web application project of the respective platform.
#if NETFRAMEWORK// For .NET Frameworkpublic sealed class LegacyHttpRequestContext : IRequestContext{public string GetCurrentUsername()=> HttpContext.Current?.User?.Identity?.Name;}#else // Or #if NET// For .NET Core and .NETpublic sealed class HttpRequestContext : IRequestContext{private readonly IHttpContextAccessor _httpContextAccessor;public HttpRequestContext(IHttpContextAccessor httpContextAccessor){_httpContextAccessor = httpContextAccessor;}public string GetCurrentUsername()=> _httpContextAccessor.HttpContext.User.Identity?.Name;}#endif// For background jobs, console applications, MAUI, etc.public sealed class BackgroundJobRequestContext : IRequestContext{private readonly string _username;public BackgroundJobRequestContext(string username){_username = username;}public string GetCurrentUsername() => _username;}
Figure: Different implementations of the IRequestContext interface for various environments (.NET Framework, .NET Core, and non-web contexts).
NOTE: If the above code needs to be in a multi-target project, you can use the #ifΒ NET472_OR_GREATER pragma to target specifically .NET Framework code.
For projects heavily dependent on HttpContext and where abstracting HttpContext is impractical, you can mimic the behavior of IHttpContextAccessor in .NET Framework. While IHttpContextAccessor is available to .NET applications, itβs not for .NET Framework applications.
namespace Microsoft.AspNetCore.Http{// Make sure, we only use it in .NET Framework, .NET already has it's own implementation.#if NET472_OR_GREATER// Interface identical to .NET IHttpContextAccessor.public interface IHttpContextAccessor{HttpContext HttpContext { get; }}// Simple implementation of IHttpContextAccessor for .NET Frameworkpublic sealed class LegacyHttpContextAccessor : IHttpContextAccessor{public HttpContext HttpContext => HttpContext.Current;}#endif}
Figure: A .NET Framework specific implementation of the IHttpContextAccessor interface, only available when the target framework is .NET 4.7.2 or greater.
NOTE: Make sure your .csproj is correctly configured. Don't use
<!-- .NET Framework reference for HttpContext --><ItemGroup Condition="'$(TargetFramework)' == 'net472'"><Reference Include="System.Web" /></ItemGroup><!-- .NET reference for IHttpContextAccessor, already included if the project is WebApp --><ItemGroup Condition="'$(TargetFramework)' == 'net8.0'"><FrameworkReference Include="Microsoft.AspNetCore.App" /></ItemGroup>
As a final resort, you can use System.Web adapters. These allow you to access HttpContext.Current without needing to port the code. Be aware that while this might seem like an easy solution, not all projects have been able to adopt this without issues. Therefore, it should only be used as a last resort if all other options fail.
Please refer to the official Microsoft documentation on System.Web adapters | Microsoft Learn for more details.
NOTE: The above strategies are not mutually exclusive and can be combined depending on your specific needs and constraints. The goal is to make your code more adaptable and ready for the migration to .NET or .NET Standard.
Some older projects .NET Framework project will have EDMX instead of modern DbContext first introduced in Entity Framework 4.1, which first introduced DbContext and Code-First approach back in 2012, replacing the ObjectContext that EDMX used for Database-First approach.
In this rule, weβll use ObjectContext and Entities interchangeably. ObjectContext is the base class that is used by the generated class, which will generally end with Entities (e.g. DataEntities).
The rule is focusing on .NET 8+ as the support for .NET Framework projects and Nuget was added back, which makes a staged migration a lot more feasible. Most, if not all, are still applicable for .NET 7 as well.
There are a few strategies regarding the migration from a full rewrite with to a more in-place migration. Depending on the scale and complexity of the project. This rule will describe an approach that balances the code we need to rewrite and modernisation.
The focus is to minimise the amount of time no deployments are made due to migration.
The strategy in this rules will include:
ObjectContext/Entities class with a custom IDbContext interface (e.g. ITenantDbContext)DbContext.OnConfiguringObjectSet<T> with DbSet<T>.AsEnumerable(), use raw SQL or change how the query worksSystem.Data.Entity namespace in all files using EF Core 3.1 (otherwise, you'll get odd Linq exceptions)Microsoft.EntityFrameworkCore namespace.AddDbContext() or .AddDbContextPool()Steps 6 and 7 are required when upgrading from .NET Framework to .NET 8 and the solution is too complex to do the migration in one go. For simple projects, if EDMX is the only major blocking issue, they should go straight to .NET 8 and EF Core 8.
NOTE: With some smart abstraction strategies, it is possible to do steps 3 - 5 while still having a working application. It is only recommended for experienced developers in architecture and how EF operates to avoid bugs related to running 2 EF tracking systems. This will impact EF internal caching and saving changes.
In this rule, we'll only cover abstracting access to ObjectContext with a custom IDbContext and how to scaffold the DB. The rest of the steps require in-depth code review and may differ greatly between projects.
Before starting, itβs important to note that ObjectContext and EDMX are no longer supported and we need to do a full rewrite of the data layer. You can wrap ObjectContext with an interface that looks like modern DbContext, as most commonly used methods are identical.
The wrapper below not only allows us to use ObjectContext in a cleaner way (see Rules to Better Clean Architecture) but also allows us to better manage the differences between ObjectContext and DbContext without needing to refactor the business logic.
using System.Data.Entity.Core.Objects;public interface ITenantDbContext{ObjectSet<Client> Clients { get; }int SaveChanges();Task<int> SaveChangesAsync(CancellationToken ct = default);}/// <summary>/// Implement DbContext as internal, so that external libraries cannot access it directly./// Expose functionality via interfaces instead./// </summary>internal class TenantDbContext : ITenantDbContext{private readonly DataEntities _entities;public TenantDbContext(DataEntities entities){_entities = entities;}public ObjectSet<Client> Clients => _entities.Clients;public int SaveChanges() => _entities.SaveChanges();public Task<int> SaveChangesAsync(CancellationToken ct = default) => _entities.SaveChangesAsync(ct);}
β Figure: Abstracting ObjectEntities behind an interface and using an interface to reduce the amount of issues while migrating.
NOTE: The changes made in this section are still compatible with .NET Framework, allowing us to deliver value to the clients while the above changes are made.
Now that we abstracted access to the data, it's time to scaffold the DB. The easiest way to do this is by using EF Core Power Tools.
Figure: Select reverse engineer tool
Figure: Data Connection
Figure: Database Objects
Figure: Settings for project
Persistence folderFigure: Settings for project
DbContext class will be auto-generated by EF Core Power ToolsFigure: Settings for project
EF Core 3.1 EDMX - Walk-through: Using an Entity Framework 6 EDMX file with .NET Core | ErikEJ's blog
While the above blog is supposedly working in EF Core 3.1, there is no information on whether that is true for .NET 8. It would still require a lot of migrations.
Limitations:
The Global.asax is an optional file that dictates how an ASP.NET application handles application, session and request events. The code for handling those events is written in Global.asax.cs, and when migrating to ASP.NET Core this code will need to be restructured.
The methods given below are automatically linked to event handlers on the HttpApplication class at runtime.
The Application_Start() or Application_OnStart() method is called once upon the first request being received by the server, and is typically used to initialize static values. The logic for this starting method should be included at the beginning of Program.cs in the ASP.NET Core project.
The Application_Init() method is called after all event handler modules have been added. Its logic can be migrated by registering the logic with the WebApplication.Lifetime property ApplicationStarted.
The Application_End() and Application_Disposed() methods are fired upon application termination. They can be migrated by registering the logic with the WebApplication.Lifetime properties ApplicationStopping and ApplicationStopped.
Therefore, the following Global.asax.cs snippet would migrate as per the figures below.
public class MvcApplication : HttpApplication{protected void Application_Start() {Console.WriteLine("Start");}protected void Application_Init() {Console.WriteLine("Init");}protected void Application_Stopping() {Console.WriteLine("Stopping");}protected void Application_Stopped() {Console.WriteLine("Stopped");}}
β Figure: Basic example application code from a Global.asax.cs file.
Console.WriteLine("Start");var builder = WebApplication.CreateBuilder(args);// ...var app = builder.Build();app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine("Init"));app.Lifetime.ApplicationStopping.Register(() => Console.WriteLine("Stopping"));app.Lifetime.ApplicationStopped.Register(() => Console.WriteLine("Stopped"));
β Figure: The above code migrated to ASP.NET Core.
The Session_Start() is called when a new user session is detected. The Session_Start() method can be replaced using middleware that determines if a pre-set session variable was previously set. Additional approaches for replacing Session_Start() can be found in this StackOverflow thread.
Session_End() is called when a user session is ended, typically by timeout. There is no equivalent functionality for Session_End() in ASP.NET Core, and the any session management logic will need to be refactored to account for this.
The events raised during a request are documented in the HttpApplication API. The logic to be executed before and after a request should be implemented using middleware.
public class MvcApplication : HttpApplication{protected void Application_BeginRequest() {Console.WriteLine("Begin request");}protected void Application_EndRequest() {Console.WriteLine("End request");}}
β Figure: Basic example request lifecycle code from a Global.asax.cs file.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.Use(async (context, next) =>{Console.WriteLine("Begin request");await next.Invoke();Console.WriteLine("End request");})
β Figure: Using middleware to execute logic before and after a request.
Global error handling logic in Application_Error() method should be migrated to use middleware registered with the UseExceptionHandler() method.
public class MvcApplication : HttpApplication{protected void Application_Error(object sender, EventArgs e) {var error = Server.GetLastError();Console.WriteLine("Error was: " + error.ToString());}}
β Figure: Basic example error handling code from a Global.asax.cs file.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.UseExceptionHandler(exceptionHandlerApp =>{exceptionHandlerApp.Run(async context =>{var handlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();var error = handlerPathFeature.Error;Console.WriteLine("Error was: " + error.ToString());// NOTE: context.Response allows you to set the returned status code// and response contents.})});
β Figure: Using exception handling middleware.
See here for more options for handling errors in ASP.NET Core.
OWIN is the Open Web Interface for .NET, which was intended to provide a standard interface between .NET web servers and web applications for ASP.NET. It provided the ability to chain middleware together to form pipelines and to register modules.
The Katana libraries provided a flexible set of popular components for OWIN-based web applications. These components were supplied through packages prefixed with Microsoft.Owin.
Middleware and module registering functionality are now core features of ASP.NET Core. Microsoft provides adapters to and from the OWIN interface for ASP.NET that can be used to gradually migrate custom OWIN components. By contrast, ASP.NET Core has native ports for Katana components.
CORS functionality was enabled in OWIN with the UseCors(...) extension method. For ASP.NET Core, it is provided by the UseCors(...) extension method in the Microsoft.AspNet.Cors package.
public void Configuration(Owin.IAppBuilder app) {// ... other logic ...app.UseCors(getCorsOption());// ... other logic ...}private static CorsOptions BuildCorsOptions() {var corsPolicy = new CorsPolicy{AllowAnyMethod = true,AllowAnyHeader = true,SupportsCredentials = true};corsPolicy.Origins.Add("https://staging.northwind.com");return new CorsOptions{PolicyProvider = new CorsPolicyProvider{PolicyResolver = context => Task.FromResult(corsPolicy)}};}
β Figure: Basic OWIN CORS example.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.UseCors(corsPolicyBuilder => {corsPolicyBuilder.AllowAnyMethod().AllowAnyHeader().AllowCredentials().WithOrigins(new string[] { "https://staging.northwind.com" });})
β Figure: Basic OWIN CORS example ported to ASP.NET Core.
A common use for OWIN was to provide access to third-party authentication sources. AspNet.Security.OAuth.Providers is a collection of security middleware that works natively in ASP.NET Core to support authentication sources such as GitHub and Azure DevOps. See the full list of providers, and the details of the migration differ from one provider to another.
The Web.Config file was used in ASP.NET to control the behaviour of individual ASP.NET applications and configure IIS. By default, modern ASP.NET Core applications use the Kestrel web server which is configured in code. Unless you are deploying your application using IIS, you will need to migrate your Web.Config file.
The Web.Config file contains data about the package inclusions, module inclusions and configuration values.
In ASP.NET Core, project inclusions are listed in the project's CSPROJ file. The dependencies of your application will need to be reviewed as to whether they are still required, and should be added as required using the NuGet Package Manager.
The server's configuration needs to be transferred to code within the Program.cs file.
The <customErrors> element within <system.web> specifies redirects for the server to use if a response with a HTTP error code is generated. When the relevant SSW Rule on useful error pages is followed, the mode will be 'RemoteOnly', meaning that the redirect will only be used if accessed from a separate host. The <customErrors> element will provide a default redirect, and may contain <error> elements that provide more specific redirects for specific error codes.
The easiest way to transcode this configuration is using UseStatusCodePagesWithRedirects.
<customErrors mode="RemoteOnly" defaultRedirect="~/Error"><error statusCode="403" redirect="~/Error?code=403" /><error statusCode="404" redirect="~/Error?code=404" /></customErrors>
Figure: Typical example of custom error redirection
var app = builder.Build();app.UseStatusCodePagesWithRedirects("/Error?code={0}");
Figure: The migrated configuration to ASP.NET Core
The <pages>/<namespaces> element defines import directives to use during assembly pre-compilation. The same affect can be achieved using modern C# implicit import functionality.
The <httpHandlers> element links routes to IHttpHandler implementations. See the ASP.NET Core Fundamentals article on Routing for replacement options, including the use of MapGet and MapPost.
The <httpModules> element configures modules that register themselves with the HttpApplication. See the documentation of individual modules regarding how their modern equivalents are to be used with an ASP.NET Core application.
Custom configuration values for the application are stored in the <appSettings> element. Where configuration values will be moved will depend on whether they should be secret or not.
In the case of non-secret values, they can be moved to an appsettings.json file.
<appSettings><add key="DefaultVisibility" value="public" /><add key="DefaultClientCount" value="30" /></appSettings>
Figure: Typical example of application settings in Web.config
{"DefaultVisibility": "public","DefaultClientCount": 30}
Figure: The application settings example migrated to appsettings.json
The class used to access configuration values will also need to be changed if the program is using System.Configuration.ConfigurationManager as that class is not available under ASP.NET Core. Instead, use a dependency injected IConfiguration implementation from the Microsoft.Extensions.Configuration package.
String visibility = ConfigurationManager.AppSettings["DefaultVisibility"];int clientCountStr = int.Parse(ConfigurationManager.AppSettings["DefaultClientCount"]);// Perform action with configuration values.
Figure: A typical example of using ConfigurationManager to retrieve settings
public class TestService{private readonly IConfiguration Configuration;public TestService(IConfiguration configuration){Configuration = configuration;}public void Act() {var visibility = Configuration.GetValue<string>("DefaultVisibility");var clientCountStr = Configuration.GetValue<int>("DefaultClientCount");// Perform action with configuration values.}}
Figure: The example code migrated to ASP.NET Core
Connections strings are stored in the <connectionStrings> element, and may be directly transferred to the appsettings.json file so long as they do not contain any secrets.
<connectionStrings><add name="DefaultConnection"providerName="System.Data.SqlClient"connectionString="Server=localhost,1200" /></connectionStrings>
Figure: A typical example Connection string in Web.config
{"ConnectionStrings": {"DefaultConnection": "Server=localhost,1200"}}
Figure: The connection string example migrated to ASP.NET Core
As discussed above, the ConfigurationManager class is no longer available and its usages need to be replaced with calls using IConfiguration.
var connStr = ConfigurationManager.ConnectionsStrings["DefaultConnection"].ConnectionString;
Figure: A typical example of how to access a Connection string from Web.config
var build = WebApplication.CreateBuilder(args);var app = builder.Build();var connStr = app.Configuration.GetConnectionString("DefaultConnection");
Figure: The example migrated to accessing a connection string within Program.cs
If there are secrets in the connection string, then it should be stored using the secrets manager as per storing secrets securely. Connection strings have a "ConnectionStrings:" prefix, as demonstrated below. The value is accessible through IConfiguration as demonstrated above.
dotnet user-secrets set ConnectionStrings:DefaultConnection "Server=localhost,1200"
Figure: Command to set the connection string for local development within the project
Migrating your project to a new Target Framework Moniker (TFM) can be a complex task, especially when you're dealing with compatibility issues between different Target Framework Monikers (TFMs). It is suggested to handle your migration PBIs (Product Backlog Items) collectively and transition your main branch to the new TFM. Making this judgment call requires careful consideration of factors like the number of PBIs and their estimated completion time.
Here are some essential tips for managing changes that are not compatible with both the old and new TFMs: