.NET Framework 4.8 to .NET 6 migration


I was recently tasked to migrate an application with around 150 projects from the "old"It is still supported .NET Framework 4.8 to a recent .NET 6. As the application is still under development and used, the migration should be done step by step over time in iterative steps rather than a significant bang refactoring. This blog post will go into more detail about how I approached the situation and what I learned.

This is a highly subjective post, and there are plenty of ways of approaching it - so take it with a grain of salt, and mileage may vary.

Advertisment on our own behalf: If you need help, just reach out to me here or on my official page: giesel.engineering.

The application

The application itself isn't that special - at least not from the point of view of the migration. It is a typical 3-tier application with an Angular Frontend and a .NET Framework 4.8 backend. The backend is split into multiple projects, most of which are smaller tools like console applications or library projects. And then there are two ASP.NET Core WebAPIs and some Windows services that are done in TopShelf.

The task

As said earlier, the application is still under development - doing the Big Bang was not a viable option. And even if no one is currently working on the application, it is still not a good approach in this scenario. So, I needed a concept to go from .NET Framework 4.8 to .NET 6 step-by-step and in an iterative way. Best case scenario: You have multiple JIRA Tickets that can be done occasionally until the whole application is migrated.

Before we go into detail, we have to clarify something important: Some terminology, because this will be important throughout the whole post.

.NET Standard / .NET Framework / .NET

We have to understand what these three different words are. Let's start with the official picture from Microsoft:

.NET Source: https://learn.microsoft.com/en-us/dotnet/standard/library-guidance/cross-platform-targeting

  • .NET Framework 4.8 describes the "old" .NET that runs only under Windows (I excluded Mono here). It is not cross-platform capable.
  • .NET 6 The "modern" .NET that is cross-platform capable. It is the successor of .NET Core 3.1 and .NET 5.0. It is the future of .NET and the default for new projects.
  • .NET Standard is a specification that describes the API surface of a .NET implementation. It is a subset of the .NET Framework and .NET. It is used to create libraries that can be used by both .NET Framework and .NET. It is not a runtime, it is just a specification.

The last part sounds "odd". But imagine .NET Standard like an interface (or multiple thousand interfaces and classes) that a client has to implement. The client would be .NET Framework or .NET.

interface ITask
    void Execute();

class NetFramework48 : ITask

class Net6 : ITask

Obviously, it is not like that - but from an oversimplified mental model, you can think of it like that. Why is this important? Well, there is a thing called .NET Standard 2.0 and the beauty is that .NET Framework 4.8 and .NET 6 implement that. That means if you have a library that is .NET Standard 2.0, you can use this library in both .NET Framework 4.8 and .NET 6. And that is the key to the migration (at least in this scenario). Microsoft officially supports .NET Standard 2.0, but they will not create new standards. So you should not rely on them anymore and migrate sooner or later to .NET 6 or later.


Before we can start the migration itself, we have to do some groundwork. And this is independent of the next steps - but big benefit: You already gain some benefits from it. Migrate to the SDK-style csproj format. If you forgot what the old csproj format looks like, here is a reminder:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <!-- Add your references here. An example to add System.Data.dll is shown -->
    <Reference Include="System.Data" />
    <!-- Add your source files here. An example to add Program.cs is shown -->
    <Compile Include="Program.cs" />
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
  <!-- To modify your build process, add your task inside one of the targets below and uncomment it. 
       Other similar extension points exist, see Microsoft.Common.targets.
  <Target Name="BeforeBuild">
  <Target Name="AfterBuild">

And this example is almost the smallest you can get - look at the modern sdk style and this is a reasonable big project file! On top, in the old world you also have a packages.config file that contains all the NuGet packages. It seems cumbersome to migrate projects, but it is not really. There are plenty of good tools doing the heavy lifting for you. The main tool I used is the Upgrade Assistant from Microsoft. This tool can migrate your projects to the new format and also look for NuGet packages that are incompatible.

Yes that is something you also have to do sooner or later: Migrate your NuGet packages. For the sole migration of the project format it is not necessary but for the migartion process as a whole it is.Basically I went through all projects and checked whether or not the NuGet package is compatible with the .NET Standard 2.0. If so, you don't have to do anything. Again we want to do this step by step. You can move to a new major version or replace it after your migration is done. It is really important to stay on track!

For packages that are not compatible, you have to find a replacement. And this is the tricky part. There are plenty of packages that are not compatible with .NET Standard 2.0. Sometimes you have to find a new major version and sometimes a completely new approach. For example, we had TopShelf in use - but that isn't maintained anymore. More to that later. For now, you should have all references up to date or at least compatible with .NET Standard 2.0.

By the way the Upgrade Assistant can also help you to get rid of the <Reference Include="" /> tags, as they are also not supported anymore. More often than not, they can be replaced by a NuGet package from Microsoft. If you are only interested in migrating your project files without anything else, there is also a tool called CsprojToVs2017 that can do the job for you - at least to some extend. For example we had some pre- and postbuild tasks. Unforunately, it didn't work out from the getgo and I had to migrate them by hand afterwards. But hey - still tons of time saved!

Independent of the tools you are using - make a concrete plan first! Don't just rush into a migration or you will get lost.

Console Application

In our case we could start very easy - we had some console applications that had literally no references to any library (if so you have to start with the next step, I will describe afterwards). It was plain simple to replace:

<Project Sdk="Microsoft.NET.Sdk">

-   <TargetFramework>net48</TargetFramework>
+   <TargetFramework>net6.0</TargetFramework>

And we were done. Yes the compiler might complain, but issues were easy to fix! If you have a console application that references libraries, you have to do the next step first.

Migrate library project to .NET Standard 2.0

Here we can use the power of .NET Standard 2.0. When we migrate a library to .NET Standard 2.0, we can use it in both .NET Framework 4.8 and .NET 6. As with the console application described above you can set the TargetFramework to netstandard2.0. Now, there can be things that will not work. In our case the biggest issue was HttpContext.Current. HttpContext.Current was used in a library project, and that thing doesn't exist anymore in the modern .NET world - which is good! But for a migration that goes step by step, we need that thing for the time being.

We can make an easy transition for that. The library project aka .NET Standard 2.0 was already using Dependency Injection - so we can leverage that. Instead of HttpContext.Current we now inject the IHttpContextAccessor that is the default in the modern world. But wait, does someone have to register that thing? What I did is, in my ASP.NET Web API project I created an implementation and registered it into the Autofac container:

public class HttpContextAccessor : IHttpContextAccessor
    public HttpContext HttpContext 
        get => HttpContext.Current;
        set => throw new NotSupportedException("This is only for migration purposes");

A consuming service now only gets the IHttpContextAccessor injected and can use it like this:

public class MyService
    private readonly IHttpContextAccessor _httpContextAccessor;

    public MyService(IHttpContextAccessor httpContextAccessor)
        _httpContextAccessor = httpContextAccessor;

    public void DoSomething()
        var context = _httpContextAccessor.HttpContext;
        // do something with the context

You can do this step by step until all of your library projects are migrated.

Unit tests

Unit test projects are a bit tricky. You can't just migrate them to .NET Standard 2.0. Basically .NET Standard is no runtime - so you can not execute your tests. There are two ways - if they only depend on now migrated projects that target .NET Standard 2.0, you can upgrade them to .NET 6. If they are still referencing .NET Framework 4.8 projects, you have to wait until those are migrated as well!

ASP.NET Web API - Migrate to ASP.NET Core 6

This is the biggest part of the migration. Well - at least to some extent. I would suggest you create a new project from scratch and add the files you had before. Some things like Global.asax don't exist anymore and you have to migrate them manually.

Global.asax has some entry points like Begin_Request, Application_Start and so on. Application_Start is now in your Program.cs where you configure the application. Begin_Request and End_Request are now middlewares. Here an example of such middleware:

public class MyMiddleware
    private readonly RequestDelegate _next;

    public MyMiddleware(RequestDelegate next)
        _next = next;

    public async Task Invoke(HttpContext context)
        // Begin_Request
        await _next(context);
        // End_Request

Basically all of those events are mapable to the new .NET 6 world. Another thing you will discover is that the WebApiConfig doesn't exist anymore either - this is now your Startup.cs or, in the newest template, also part of your Program.cs. Another change we had to make while migrating is the SignalR package - they are not compatible between .NET Framework 4.8 and .NET 6. This also had a direct impact on our front end as we had to switch here as well.


We used TopShelf to create Windows services. But this is not maintained anymore. So we had to find a replacement. The go-to approach is to use a BackgroundService. There is a whole article from Microsoft about that. But it is your everyday BackgroundService, so I will not go into too much detail here.

Migrate to .NET 6

Once all our projects are on either .NET Standard 2.0 or .NET 6 it is time to migrate the .NET Standard 2.0 ones to .NET 6. As I said earlier, .NET Standard 2.0 shouldn't be used anymore, and .NET 6 has a richer API. So all of your libraries should be migrated to .NET 6. But this is a very easy step - just change the TargetFramework to net6.0 and you are done.

<Project Sdk="Microsoft.NET.Sdk">

-   <TargetFramework>netstandard2.0</TargetFramework>
+   <TargetFramework>net6.0</TargetFramework>

It should also compile - because, as said, they are compatible!

Some other considerations and general words

Many enterprise applications are relying on Kerberos! To support Kerberos with .NET 6 and onwards, there are special packages available provided by Microsoft. The short version:

  1. Install the "Kerberos" package: Microsoft.AspNetCore.Authentication.Negotiate
  2. Now you can extend the authentication part of your Program.cs

For us, as we were hosting the application on IIS, I took a slightly different approach of using the IISDefaults schema:


Here is the lengthy documentation from Microsoft itself: Windows Authentication.

That said, there is a general shift if you are using .NET Framework 4.8 with IIS rather than using IIS with ASP.NET Core 6 onwards. Many things inside the .NET Framework were shifted out, and it is now the responsibility of the server. For example, to make the authentication work, we had to enable "URL Authorization" for IIS to make the whole thing work.

Generally speaking - if you are using IIS - you have to install the .NET Hosting Bundle. The hosting bundle bridges the communication between your IIS and your ASP.NET Core application.


Many .NET Framework programs are relying on Newtonsoft.Json, and so was ours. If you switch to a new ASP.NET Core application the default is using System.Text.Json as Serializer. While this is a good default choice, there are some scenarios where Newtonsoft.Json is better and easier to use. At least that was the case for us. To switch out the default serializer in ASP.NET Core to Newtonsoft.Json you can do the following.

First you have to install the Microsoft.AspNetCore.Mvc.NewtonsoftJson package. Then you can adjust your controllers and SignalR if needed.

For controllers


For SignalR:


We had quite some serializer settings done in the .NET Framework, and refactoring those while doing the .NET migration seemed too much at once. If you need a good overview of the difference between those two: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft?pivots=dotnet-8-0

.NET Core is stricter

What becomes quickly imminent is that .NET Core is way stricter than its predecessor. We had DTO's in the backend like this:

public class MyDTO
  public int Id { get; set;}

But the frontend would send something like:

  id: "12"

So it is sending a string. While this worked in .NET Framework 4.8, it did not anymore in .NET 6 and onwards. To be honest: I do like that! But this is something you will not see at compile time!


Now that your migration is complete and well-tested, you can think of all the new things you can do. For example, we used Autofac and decided to go with the default ASP.NET Core. Anyway, I would argue do the migration without much refactoring or introduction of new concepts as it makes it much harder than it could be! Afterward, you can improve whatever you want.


I hope this article helps you to migrate your .NET Framework 4.8 application to .NET 6. Or it helps you to get a better understanding of the steps and underlying mechanisms!

An error has occurred. This application may no longer respond until reloaded. Reload x