SSW Foursquare

Rules to Better .NET 8 Migrations - 12 Rules

Ready to migrate to .NET 8? Check SSW's .NET 8 Migration consulting page.

  1. Do you know why to upgrade from .NET Framework to the latest .NET?

    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?

    The Evolution of .NET

    To understand the significance of upgrading from .NET Framework to the latest .NET, let's first take a brief look at the evolution of the .NET platform.

    .NET Framework: The Legacy

    The .NET Framework has been the backbone of Windows application development for nearly two decades. It has provided a robust and versatile environment for building Windows desktop applications, web applications, and services. However, as technology progressed, the limitations of .NET Framework became more apparent.

    The Pain Point:

    • Compatibility - .NET Framework applications run on Windows, limiting cross-platform deployment.
    • Performance - Each new version of .NET brings improved performance and resource utilization, allowing the same .NET code to perform better on the same hardware.
    • Security - Limited security support for older .NET Framework versions(4, 4.5 and 4.5.1), and the support for NET Framework versions(4.5.2 and later) follows the lifecycle policy of the underlying Windows OS on which it is installed.
    • Modern development features - Newer versions of .NET give us more opportunities to design products and services using many newer capabilities that are not backported to .NET Framework. This allows us to build, test and deploy faster, and more securely than ever before, keeping us competitive in today's tech landscape.

    Enter the Modern .NET

    Recognizing the need for change, Microsoft introduced a new direction for .NET with the release of .NET Core, which later evolved into .NET 5 and beyond. This modern .NET is designed to address the shortcomings of .NET Framework and meet the demands of contemporary software development.

    Key Reasons to upgrade from .NET Framework to the latest .NET

    Cross-Platform Compatibility

    The latest .NET is designed to be cross-platform. This means you can develop applications that run not only on Windows but also on macOS and Linux. This cross-platform capability not only expands your reach but also gives us more flexibility in deployment, opening up the world of containerization, as well as using the same libraries in Web, WebAssembly (.NET Blazor), and native mobile apps using .NET MAUI.

    Improved Performance and Efficiency

    The latest .NET versions are optimized for better performance and resource utilization, resulting in faster and more efficient applications. This is crucial in today's fast-paced digital world, where users expect seamless and responsive software.

    Modern Development Features

    The latest .NET provides access to modern development features, such as modern EF Core, enhanced tooling, and improved support for modern web development.

    Summary

    Upgrading from .NET Framework to the latest .NET is not just a technological shift; it's a strategic move to future-proof your applications. It brings improved performance, cross-platform compatibility, and access to modern development features. By making this transition, you can stay competitive, secure, and ready to embrace the evolving landscape of software development.

    For more information on the support policies of .NET Framework and .NET, you can refer to the following resources:

  2. Do you create a migration plan?

    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?

    Preparation

    The migration to a newer version of .NET is the perfect opportunity for you to take stock of your current application architecture, and address any technical debt your app has accumulated. Trying to migrate an application that's poorly architected or carrying a lot of tech debt will make your migration exponentially harder. Therefore, now is the time to perform a full audit of your app and ensure you have PBIs to rectify these problems before you consider it "migration-ready".

    Manual dependency analysis

    Imagine a typical N-tiered application. Over the course of its life, the lines between each tier will often get blurred, either accidentally or deliberately. This can result in certain dependencies appearing where they shouldn't - such as System.Web references showing up in your application or data layer. This is a very common code smell and a great starting point to cleaning up your app.

    If your app has 3rd party dependencies (e.g. with a financial system, reporting system, etc.) - now is the time to investigate those integration points to determine whether those services provide compatible libraries and how those libraries differ (if at all). Create PBIs for these as well.

    Infrastructure

    If you host your app on premise, it's also worth checking your infrastructure to ensure it has the necessary runtimes.

    related runtimes
    Figure: Install necessary .NET 8 runtimes

    Breaking changes

    Once you've addressed any technical debt or architectural concerns, you can start gauging the amount of work involved in the migration itself.

    Tip: You want to work from the bottom up in N-tiered applications (or inside-out with Onion architecture). This will allow you to work through the migration incrementally, and address any breaking changes upstream. If you migrate top-down (or outside-in), you will find yourself having to rewrite downstream code multiple times.

    Upgrade the csproj files

    The first thing you want to do is update your projects' csproj files to the new SDK-style format. This greatly simplifies the contents of the file, and will allow you to easily target multiple versions of .NET framework monikors simultaneously (more on this below).

    Tip: You can use the try-convert dotnet tool to convert your projects to the new sdk style csproj format.

    Install the tool using:

    dotnet tool install -g try-convert

    ...and your other projects using:

    try-convert --keep-current-tfms

    Note: For Web applications, we'll update at a later stage based on migrating Web Apps to .NET.

    legacy vs sdk
    Figure: The differences between the legacy csproj file and the new SDK csproj file

    Target multiple Target Framework Monikers (TFM)

    Now you have shiny new SDK-style csproj files, it's time to see what breaks!

    Targeting both your current .NET Framework version and your future .NET version will give you the following information:

    • Expose any build errors you receive when trying to build for .NET
    • Expose any build errors you receive when trying to build for .NET Framework

    Why is this important?

    Imagine you don't do this, and instead, you simply target the newer version of .NET. You get a list of 100 build errors due to breaking changes - too many for 1 Sprint (or 2 Sprints, or 3).

    You start fixing these build errors. You go from 100 errors to 50 - progress! Then you're told there's an urgent bug/feature/whatever that needs fixing ASAP. But you've still got 50 build errors when you're targeting .NET.

    "No problem", you say. "I'll just switch back to .NET Framework and do this fix, and push out a new deployment".

    You switch to .NET Framework, build the project, and...25 build errors?!

    While you were fixing those build errors, you wrote code that isn't compatible with .NET Framework. Now you have an urgent bug/feature/whatever, as well as 25 new build errors you have to solve ☠️.

    Using multiple TFMs from day 1 ensures you are fixing the breaking changes for .NET, without introducing breaking changes in .NET Framework.

    This allows you up to work on your migration PBIs incrementally, while still allowing you to deploy your app on the current .NET version - win/win!

    In all your project files, change the TargetFramework tag to TargetFrameworks. You want to do this early on to enable a smoother flow later to not need unload and reload projects or have to close and reopen Visual Studio.

    <TargetFrameworks>net472;net8.0</TargetFrameworks>

    good example vs bad example tfms
    Figure: Bad and good examples when targeting multiple target frameworks

    Creating the migration backlog

    At this point, ensure your project can target both the .NET Framework and the new target .NET. Some of the projects might not support both platforms right away and you can follow these steps to fix the issues and have a better understanding of how much work it might lies ahead.

    1. Add the target framework to your project
    2. Compile to see what breaks
    3. Fix what is easy to fix

      1. Remember to commit after each fix to help your reviewers 😉
    4. Anything that is not easy to fix, create a PBI with details of the issue

      1. This allows another developer on your team to work on that PBI independently
    5. If you have a project that is able to compile at this point you can leave the new TFM in your project and continue to the next project

      1. If not, you can remove the new TFM and continue to the next project
      2. Repeat these steps once the PBIs have been completed related to this project

    By the end of this process, you'll have a much clearer view (and backlog!) of your path to the latest .NET:

    • PBIs for technical debt
    • PBIs for architectural concerns
    • PBIs for breaking changes

    What's next?

    While this guide aims to give you a high-level view of migrating your app, there are other some special considerations when dealing with complex applications and web apps. Check out these other rules:

  3. Migrating Web Apps to .NET

    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?

    To YARP, or not to YARP

    There exists, somewhere, a line that separates the "big bang" and "stranger fig" approach as being the recommended way to tackle web app migrations. While this decision point is unique to every project, you can examine a couple of metrics to help guide your decision.

    • How many Sprints do you estimate the migration work will take?
    • Will feature development continue during the migration process?
    • Do you have plenty of leeway on both of the above?

    If your migration plan is solid, you should have a pretty clear idea of the effort involved in migrating your web app. If you're confident that you can get the migration done in a reasonable timeline, and you can implement a feature-freeze during that time, opting for the Big Bang approach may be a reasonable option.

    If, however, you know that the migration is going to take a long time, or there are other developers/teams that will be working on other, non-migration work (e.g. feature development), then adopting the Strangler Fig pattern with YARP is often a better choice, and one that we at SSW have had great success with.

    Create a side-by-side Web App project

    The first step is to create a brand new ASP.NET Core web application, where you will be migrating your pages/endpoints into incrementally.

    The best way to do this is via the .NET Upgrade Assistant.

    This will create a new .NET 8 project and include YARP. For functionalities that have not yet been migrated, YARP will redirect them to the .NET Framework web application.

    Configure YARP

    The next port of call is to configure YARP (Yet Another Reverse Proxy). This is the slice of code that will determine whether a request should be sent to your new ASP.NET Core web app (for routes that have been migrated) or your old .NET Framework web app (for the routes that have not yet been migrated).

    Here's a quick look at a sample YARP route config:

    var webRoutes = new List<RouteConfig>
    {
        // Route for token
        new()
        {
            RouteId = "tokenServePath",
            ClusterId = tokenClusterId,
            Match = new RouteMatch
            {
                Path = "/token/{**catch-all}",
            },
        },
    
        // Route for WebUI App
        new RouteConfig
        {
            RouteId = "webUIServePath",
            ClusterId = webUiClusterId,
            Match = new RouteMatch
            {
                Path = "/api/v2/{**catch-all}",
            },
        },
    
        // Route for WebApp App
        new RouteConfig
        {
            RouteId = "webAppServePath",
            ClusterId = webAppClusterId,
            Match = new RouteMatch
            {
                Path = "/api/{**catch-all}",
            },
        },
    
        // Route for Angular
        new RouteConfig
        {
            RouteId = "angularUIServePath",
            ClusterId = angularClusterId,
            Match = new RouteMatch
            {
                Path = "{**catch-all}",
            },
        }
    };

    Figure: Example code for setting up different paths within YARP's configuration

    Upgrading components using Upgrade Assistant

    Once you have created the side-by-side project, select the project that needs migration and right click | Upgrade on it.

    Figure: Context menu on the project to be migrated.

    Upgrade Assistant will show you a Summary view and detect that the project is linked to your Yarp proxy.You can also see the migration progress of your endpoints from .NET Framework to .NET as a pie chart.

    Figure: Upgrade Assistants Summary page

    From here you can explore your endpoints through the Endpoint explorer, which will also indicate what endpoints have already been migrated and which ones are still outstanding. The chain icon indicates that this endpoint has been migrated and is linked between the controller in the old project and the controller in the Yarp proxy project.

    Figure: Chain Icon

    Figure: Endpoint Explorer showing the endoints between the old .NET Framework project and the new .NET Core project

    Use the Upgrade functionality to apply automatic code transformations and speed up the migration process. In the best-case scenario, the controller has been fully ported across and does not require any manual work. In most scenarios, you will need to review the controller and update any custom code that the Upgrade Assistant could not automatically transform.

    Figure: Upgrade Assistant progress upgrading a controller

    Create PBIs to identify the upcoming tasks

    When a web project is heavily reliant on .NET Framework dependencies, the first step in gauging the effort required for a complete migration is to thoroughly examine these dependencies. This involves a detailed investigation, followed by the creation of PBIs for each dependency. These PBIs serve to accurately scope out the total effort that will be needed for the migration process.

    Listed below are rules crafted to aid in the project migration process. Please ensure to incorporate only those rules that are applicable to your specific project.

  4. Do you know how to modernize your .NET applications?

    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!

    The .NET Upgrade Assistant is a .NET global tool that helps you incrementally upgrade your .NET Framework-based Windows applications. It's available on GitHub as a public repository.

    upgrade assistant CLI
    Figure: Use the CLI version of Upgrade Assistant

    ✅ Benefits

    • Stay agile - Receive immediate feedback on your migration progress
    • Guided steps - Get the recommended steps for upgrading your application and action them as you see fit
    • Multiple project types and languages - Receive support for project types including:

      • ASP.NET MVC
      • Windows Forms
      • Windows Presentation Foundation
      • Console
      • Libraries
      • UWP to Windows App SDK (WinUI)
      • Xamarin.Forms to .NET MAUI
    • Extensible - Customize the .NET Upgrade Assistant as you see fit
    • Open source - Contribute features and bugfixes to help others achieve the same goal
    • Upgrading to modern .NET - Provides more opportunity for the future of your application

    ❌ Downsides

    • Generic - The .NET Upgrade Assistant tries its best to be generic, and therefore may not be able to identify and work with your proprietary NuGet packages. Those dependencies will need to be upgraded separately, or an alternative needs to be found.
    • Time consuming - While the .NET Upgrade Assistant does well to reduce the time and cost of upgrading your .NET applications, it will still take a significant amount of time, especially on live projects.
    • Code modernization - The tool will not upgrade the code style or use modern C# patterns. The bulk of code will remain identical which will still look distinctly .NET Framework-like code with some of the namespaces and minor refactors.

    If your project is overly complex or encounters significant challenges with .NET 8 Upgrade Assistant, you may need to revert to the original project and then continue with the instructions on how to handle complex .NET migrations.

  5. Do you know how to migrate from System.Web to modern alternatives

    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.

    1. Replace with IHttpContextAccessor (.NET only)

    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.

    2. Abstracting functionality

    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 Framework
    public sealed class LegacyHttpRequestContext : IRequestContext
    {
        public string GetCurrentUsername()
            => HttpContext.Current?.User?.Identity?.Name;
    }
    
    #else // Or #if NET
    
    // For .NET Core and .NET
    public 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.

    3. Abstract HttpContext Accessor (.NET Framework only)

    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 Framework
        public 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>

    4. ⚠️ Last resort: System.Web adapters

    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.

  6. Do you know how to migrate from EDMX to EF Core

    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.

    Strategies

    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:

    1. Abstract existing ObjectContext/Entities class with a custom IDbContext interface (e.g. ITenantDbContext)
    2. Scaffold DB

      1. EF Core Power Tools
      2. If the tool fails, use When to use EF Core 3.1 or EF Core 8+ CLI for scaffolding. EF Core 3.1 can better deal with older DB schemes than EF Core 8+
    3. Implement interface from step 1 and refactor entities

      1. Review entities, adjust generated code and update DbContext.OnConfiguring
      2. Replace ObjectSet<T> with DbSet<T>
      3. Make any other necessary refactors
      4. Nullables might be treated differently
      5. Some properties will be a different type and you'll need to fix the mapping
      6. Lazy loading can be an issue. Fix it with eager loading.
      7. When upgrading to EF Core 3.1, group by and some other features are not supported

        1. Use .AsEnumerable(), use raw SQL or change how the query works
        2. Add a TechDebt comment and PBI - Do you know the importance of paying back Technical Debt?
    4. Update namespaces (for Entities, EF Core namespaces and removing legacy namespaces)

      1. Remove System.Data.Entity namespace in all files using EF Core 3.1 (otherwise, you'll get odd Linq exceptions)
      2. Add Microsoft.EntityFrameworkCore namespace
    5. Update dependency injection

      1. Use modern .AddDbContext() or .AddDbContextPool()
    6. Update migration strategy (from DB-first to Code-first)

      1. Use EF Core CLI instead of DbUp
    7. Remove EDMX completely (can be done sooner if migration is done in 1 go rather than in steps)
    8. Optional: Upgrade to .NET 8+ (if on .NET Framework or .NET Core 3.1)
    9. Optional: Upgrade to EF Core 8+ (if EF Core 3.1 path was necessary)
    10. Test, test, test...

      1. Going from EDMX to EF Core 3.1 or later is a significant modernization with many under-the-hood changes
      2. Common issues are:

        1. Lazy loading
        2. Group by (if in EF Core 3.1)
        3. Unsupported queries (code that was secretly running on .NET side instead of SQL Server)
        4. Performance issues because of highly complicated queries
        5. Incorrect results from EF Core query

    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.

    1. Abstracting access to ObjectContext/Entities

    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.

    2. Scaffolding the DB

    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.

    1. Right click on the project | EF Core power Tools | Reverse Engineer

    project reverse engineer tool 1
    Figure: Select reverse engineer tool

    1. Choose your data connection and EF Core version

    project reverse engineer tool 2
    Figure: Data Connection

    1. Choose your database objects (tables, views, stored procedures, etc.)

    project reverse engineer tool 3
    Figure: Database Objects

    1. Choose the settings for your project

      1. Recommended: Use DataAnnotation attributes to configure the model to reduce a lot of lines of code in DbContext
      2. Optional: Install the EF Core provider package in the project if you have not yet done that
      3. Optional: Use table and column names directly from the database if your existing code relies on that naming scheme

    project reverse engineer tool 4
    Figure: Settings for project

    1. Code will generate under the path we decided (EntityTypes path). In this case, it's Persistence folder

    project reverse engineer tool 5
    Figure: Settings for project

    1. A DbContext class will be auto-generated by EF Core Power Tools

    project reverse engineer tool 6
    Figure: Settings for project

    Resources

    Alternative

    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:

    • EDMX is not supported in .NET Standard or .NET or any other SDK-style projects (required for .NET migrations)
    • Requires a dedicated .NET Framework project that is not yet upgraded to an SDK-style project to generate and update EDMX, models and ObjectContext
    • EF6 and EDMX are out of support
    • Built for EF Core 3.1 which is out of support
    • Unknown if it works on .NET 8 even with legacy .NET Framework support
    • ObjectContext (the core of EDMX) was slowly phasing out, being replaced by DbContext in 2012
  7. Do you know how to migrate Global.asax to ASP.NET Core?

    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.

    Application Events

    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.

    Session Events

    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.

    Request Lifecycle Methods

    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.

    Error Handling

    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.

  8. Do you know how to migrate OWIN to 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

    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.

    Third-Party Authentication

    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. The full list of providers is published here, and the details of the migration differ from provider to provider.

  9. Do you know how to migrate Web.config to ASP.NET Core?

    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.

    Package Inclusions

    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.

    Server Configuration

    The server's configuration needs to be transferred to code within the Program.cs file.

    Custom Error Pages

    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

    Namespaces

    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.

    HTTP Handler Routes

    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.

    HTTP Modules

    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

    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

    Connection Strings

    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

  10. Do you know how to manage compatibility between different Target Framework Monikers (TFMs)?

    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:

    Using #if Pragma Statements

    You can use #if pragma statements to compile code exclusively for a specific TFM. This technique also simplifies the removal process during post-migration cleanup, especially for incompatible code segments.

    Whenever possible, consider using dependency injection or factory patterns to inject the appropriate implementation based on the TFM you are targeting. This approach promotes code flexibility and maintainability, as it abstracts away TFM-specific details.

    public static class WebClientFactory
    {
      public static IWebClient GetWebClient()
      {
    #if NET472
        return new CustomWebClient();
    #else
        return new CustomHttpClient();
    #endif
      }
    }

    Code: Good example - Using #if Pragma statements and factory pattern

    Using MSBuild conditions

    You can use MSBuild conditions to add references to different libraries that are only compatible with a specific TFM. This enables you to manage references dynamically based on the TFM in use.

    <ItemGroup Condition="'$(TargetFramework)' == 'net472'">
        <Reference Include="System.Web" />
        <Reference Include="System.Web.Extensions" />
        <Reference Include="System.Web.ApplicationServices" />
    </ItemGroup>

    Code: Good example - Using MSBuild conditions

  11. Do you check your API serialisation format?

    Most REST APIs serialise/deserialise to and from JSON format. To perform this serialisation, a .NET web application typically relies on either Newtonsoft.Json or System.Text.Json.

    Modern .NET applications prefer System.Text.Json over Newtonsoft.Json - which is commonly found in earlier versions of .NET and .NET Framework projects. This, however, may break in certain usages.

    This issue needs to be addressed when migrating projects from .NET Framework to modern .NET.

    The primary reason for switching to System.Text.Json is its faster performance and lower memory usage compared to Newtonsoft.Json. However, it also breaks compatibility and lacks some features found in Newtonsoft.Json.

    The differences

    This Microsoft documentation contains a compiled list of differences between System.Text.Json and Newtonsoft.Json.

    Notable Things to Check

    • ⚠️ Default Serialisation Property Name Casing

      Since .NET Core 3.0, the default behaviour for JSON property name serialisation has switched to camelCase, whereas earlier versions followed the class's property names as-is (usually in PascalCase). Couple of options to address this when migrating controllers from legacy endpoints while maintaining compatibility:

      • Option A: Implement a per-controller override for migrated legacy APIs to maintain the same behaviour by setting JsonSerializerOptions.PropertyNamingPolicy = null, e.g., via a custom attribute using ActionFilterAttribute.
      • Option B: Apply a global JSON serialisation override to retain JsonSerializerOptions.PropertyNamingPolicy = null.
    • ⚠️ No Support for JSON Patch Documents

      Deserialisation of JSON Patch documents might fail due to lack of support for JSON Path queries, e.g., commonly used in legacy PATCH endpoints.

    • ⚠️ Limited OData Support

      OData might not work as expected when using System.Text.Json. See more: Example issues.

    • ⚠️ Limited Support for Date Formats

      While System.Text.Json supports the ISO 8601-1:2019 format for date and time components, Newtonsoft.Json accommodates a broader range of date-time strings. For example, System.Text.Json cannot deserialise the format 8:00am February, 24 2024.

  12. Migrating frontend to .NET 8

    In the case where the legacy .NET Framework application is hosting the frontend, it is recommended to also think about the hosting method of the frontend in .NET 8.

    This issue is best considered before or during the migration so the team will not spend time implementing something that will be removed shortly in the future.

    Depending on the scenario and the situation of the projects, there are many options available to host the frontend with .NET 8. Here are a couple of the many options available.

    Option 1: Keep hosting frontend integrated with .NET 8

    No changes to the flow, keep hosting the frontend the same way as before (e.g. bundling the frontend build artifacts as a static resource in .NET 8).

    • Pros:

      • 🟢 Simple setup with no need for additional infrastructure
      • 🟢 Familiar workflow if migrating from .NET Framework
    • Cons:

      • ❌ Frontend and backend must be built and deployed together
      • ❌ Less flexibility for future scalability and updates

    Option 2: Host frontend on its own

    This option fully separates the frontend from the backend. This requires serving the frontend on a separate hostname or using a gateway service to redirect API with frontend routes.e.g. Hosting frontend in Azure Static WebApp and use Azure Frontdoor to route requests to frontend on frontend routes and backend routes to the .NET 8 application.

    • Pros:

      • 🟢 Future-proof hosting that allows for independent frontend updates
      • 🟢 Supports preview environments for testing
      • 🟢 Faster build times due to separate frontend and backend builds
      • 🟢 Direct CDN integration (e.g. with Azure Front Door), improving performance
    • Cons:

      • ❌ More complex deployment story
      • ❌ Higher infrastructure cost
      • ❌ Requires extra configuration and management effort

    Option 3: Host frontend externally, serve it together with .NET 8

    Hosting your frontend application in a separate storage (e.g. in Azure Blob Storage) is the mixed transitionary approach where the frontend artifacts are hosted separately from the backend, and the backend will pass these artifacts to the frontend without statically embedding the resource in the backend.This option still allows a clean separation of concerns, allowing for independent scaling and deployment of the frontend and backend, while also sitting in a transition where we have the option to host the frontend standalone, similar to Option 2 setup.

    • Pros:

      • 🟢 Faster build times due to separate frontend and backend builds
      • 🟢 Similar serving setup as before
    • Cons:

      • ❌ Slightly more complex deployment story
      • ❌ Requires extra configuration and management effort
      • ❌ Increased ingress network load to the backend

    Option 2 is typically preferred for future-proofing, but Option 3 can also be recommended for its balance of cost, build efficiency, and future flexibility. Remember to weigh these factors in the context of your project's specific needs and constraints.

    Figure: Good Example - Deciding on frontend hosting, considering its scalability and cost benefits.

    Best Practices

    • Cost-Effectiveness: Evaluate the cost against benefits for each option. If budget is tight, Option 1 is less costly while Option 3 offers a good balance.
    • Development Workflow: Consider the impact on your development workflow. Option 2 and Option 3 promote a more decoupled architecture, which could be beneficial for teams.
    • Performance: Assess how each option affects the performance of your application. Decoupling the frontend can offer performance benefits through CDN caching.
    • Future-Proofing: Think long-term. Option 2 and Option 3 provide more flexibility for future changes and scalability.

    By carefully assessing your needs and understanding the trade-offs of each option, you can make an informed decision on how to serve your Angular applications in a .NET 8 environment.

Need some help with .NET 8 Migrations?

Meet our experts
We open source. Powered by GitHub