Lessons learned after migrating 25+ projects to .NET Core


We recently finished one of the biggest refactoring tasks we have ever done on elmah.io: migrating everything to .NET Core. elmah.io currently consists of 5 web apps and 57 Azure Functions spread across roughly 25 Function apps. In this post, I'll share some of the lessons learned we have had while solving this task.

Let's jump right in. I have split this post into three categories. One around issues migrating from .NET Framework to .NET Core in general. Another specifically about migrating from ASP.NET to ASP.NET Core. And the third one about migrating Azure Functions to the most recent version. Feel free to dive into the subject that you are interested in. Some of the content is split into specific issues we ran into, while others are more of a textual description of where we currently stand.

.NET Core

To start with some good news, I was expecting way more problems migrating the code from .NET Framework to .NET Core. When we started experimenting with the migration, .NET Core was in version 1.x and a lot of .NET Framework features were missing. From version 2.2 and forward, I don't remember missing anything. If you jump directly on the newest stable version of .NET Core (don't see why you shouldn't), there's a good chance that your code both compiles and works without any changes needed.

Be aware of support levels

One thing that you need to be aware of when jumping from .NET Framework to .NET Core, is a faster roll-out of new versions. That includes shorter support intervals too. With .NET Framework, 10 years of support wasn't unseen, where .NET Core 3 years seem like the normal interval. Also, when picking which version of .NET Core you want to target, you need to look into the support level of each version. Microsoft marks certain versions with long time support (LTS) which is around 3 years, while others are versions in between. Stable, but still versions with a shorter support period. Overall, these changes require you to update the .NET Core version more often than you have been used to or accept to run on an unsupported framework version.

Here's a good overview of the different versions and their support levels: .NET Core Support Policy.


Migrating our ASP.NET MVC websites to ASP.NET Core has been the biggest task of them all. I don't want to scare you away from migrating and a lot of the time was spent migrating away from some old authentication frameworks and similar .NET Core unrelated tasks. The MVC part in ASP.NET Core works a lot like the old one and you get a long way doing some global search and replace patterns. In the following sections, I have listed several issues that we ran into while migrating.

How to upgrade it

The upgrade path isn't exactly straight-forward. There might be some tools to help with this, but I ended up migrating everything by hand. For each website, I took a copy of the entire repo. Then deleted all files in the working folder and created a new ASP.NET Core MVC project. I then ported each thing one by one. Starting with copying in controllers, views, and models and making some global search-replace patterns to make it compile. Pretty much everything was different from there. We were using an old auth framework before which was ported to the built-in auth feature in core (not Identity). Same with swagger which requires a new NuGet package for core. etc. The main website took a lot of time to migrate for sure.

Understanding middleware

As you may already know, a lot of features in ASP.NET Core is built up of middleware. Middleware isn't a concept available in ASP.NET and therefore something you need to learn. The way you configure middleware and especially the order you install them is something that has caused a bit of a headache. My recommendation is to study the documentation from Microsoft very thoroughly through this one. Also, Andrew Lock wrote a long series of high-quality posts around middleware that I recommend everyone to dive into: https://andrewlock.net/tag/middleware/

Newtonsoft.Json vs System.Text.Json

Up until ASP.NET Core 3.0, JSON serialization and deserialization was carried out by the extremely popular Newtonsoft.Json package. Microsoft decided to roll their own package in ASP.NET Core 3.0 and forward, which caused a few problems when we upgraded from 2.2. to 3.1.

System.Text.Json already seems like a good implementation and after some testing, we decided to go with that (the default choice anyway). We quickly found out that Newtonsoft.Json and System.Text.Json aren't compatible with the way they serialize and deserialize C# objects. Since our client use Newtonsoft.Json to serialize JSON, we experienced a few scenarios where the client would generate JSON that couldn't be deserialized back to C# on the server. My recommendation, in case you are using Newtonsoft.Json on the client, is switching to use Newtonsoft.Json on the server too:


After calling AddControllersWithViews or whatever method you call to set up endpoints, you can call the AddNewtonsoftJson method to switch ASP.NET Core to use that package.

Switching requires an additional NuGet package as well:

Install-Package Microsoft.AspNetCore.Mvc.NewtonsoftJson

JSON casing

An issue that we faced in our main app was the casing of serialized JSON. If returning JSON from an ASP.NET Web API controller action:

return Json(new { Hello = "World" });

The returned JSON is pascal-cased:


When returning the same from ASP.NET Core, the returned JSON is camel-cased:


If you, like us, already have a JavaScript-based client expecting pascal-casing for C# serialized objects, you can change the casing using this:

    .AddJsonOptions(options =>
        options.JsonSerializerOptions.PropertyNamingPolicy = null;

Razor runtime compilation

While porting the Razor views from ASP.NET MVC to ASP.NET Core didn't require much work, I wasn't aware of Razor views not being compiled at runtime in ASP.NET Core. I wrote the following blog post about it: Add Razor runtime compilation when developing ASP.NET Core. In short, you need to either check the Enable Razor runtime compilation option when creating the project or install the Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation NuGet package and enable runtime compilation in Startup.cs:


Web.config files are still valid

Most documentation and blog posts around ASP.NET Core mention the web.config file as replaced by the appsettings.json file. While this is partly true, we still needed the web.config file to implement some of the security headers as described in this post: The ASP.NET Core security headers guide.

Here's an example of the web.config file we use to remove the Server and X-Powered-By automatically put in HTTP responses when hosting on IIS:

<?xml version="1.0" encoding="utf-8"?>
      <requestFiltering removeServerHeader="true" />
        <remove name="X-Powered-By" />

Azure Support

If running on Azure (like we do) you need to be aware of which versions of .NET Core different regions support. When launching new .NET Core versions, Azure regions are upgraded over a period spanning weeks and maybe even months. Before upgrading, you need to check if your region supports the version you are upgrading to. The best overview is found at the .NET Core on App Service Dashboard.

Bundling and minification

Bundling and minification were some of the things that I think worked great in ASP.NET MVC. In ASP.NET Core you have a range of different options which is good but also a bit confusing when coming directly from ASP.NET MVC. Microsoft has a good document around the subject here: Bundle and minify static assets in ASP.NET Core.

We ended up with Mads Kristensen's BundlerAndMinifierwhich I may want to write a dedicated blog post about. In short, we specify input and output files in a bundleconfig.json file:

    "outputFileName": "wwwroot/bundles/style.min.css",
    "inputFiles": [

Then parse the bundleconfig.json file in Gulp and do this bundling and minification with gulp-cssmin, gulp-concat, and similar npm packages. This makes it possible to run both locally (you can hook up Gulp tasks to Visual Studio build if you'd like) and on our Azure DevOps server.

Look ma, no more potentially dangerous requests

If you have been running on ASP.NET MVC and logging failed requests, you have probably already seen this error numerous times:

A potentially dangerous Request.Form value was detected from the client

ASP.NET MVC didn't allow <script> tags and similar as part of POST requests to MVC controllers. ASP.NET Core changed that and do accept input like that. If securing against content like this is important, you will need a filter or similar that checks for this. In our case, we escape everything using Knockout.js why making requests including markup fail isn't important. But a thing to be aware of for sure.

Azure Functions

Migrating to the newest version of Azure Functions (currently v3) has been a long process. A lot of features on elmah.io are powered by scheduled tasks and long-running services. Like consuming messages from Azure Service Bus, sending out a daily digest email, etc. A few years ago, all of these "jobs" were running as Windows scheduled tasks and Windows Service on a virtual machine on Azure. We migrated everything but a single service to Azure Functions v1 running on .NET Framework.

When Azure Functions v2 came out, we started migrating a function app to v2 running on .NET Core with poor results. All sorts of problems existed and the available set of base library classes just wasn't good enough. When .NET Core 2.2 came out we finally made the jump and ported all of the functions.

As part of the recent migration task, we migrated all of the function apps to Azure Functions v3 running on .NET Core 3.1. The code has been running extremely stable since we migrated and I would recommend this set up for production use.

How to upgrade it

Upgrading was way faster than ASP.NET Core. v1, v2 and v3 pretty much follow the same file structure and most of the feature set. To upgrade, I simply updated the target framework in each project and upgraded all of the NuGet packages.

Use Microsoft.Azure.Functions.Extensions

If you, like us, started creating Azure Functions with the v1 runtime and .NET Framework, you have probably wondered how to do dependency injection and initialization of your functions. When upgrading to v2 and .NET Core 2.2 we started using the Microsoft.Azure.Functions.Extensions NuGet package. When installed, you can specify a Startup.cs file in your Function app, just as you'd have done it in ASP.NET Core. In there, you can open database connections, configure logging, etc.:

using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

[assembly: FunctionsStartup(typeof(My.Function.Startup))]

namespace My.Function
    public class Startup : FunctionsStartup
        public override void Configure(IFunctionsHostBuilder builder)

            var config = new ConfigurationBuilder()

            builder.Services.AddLogging(logging =>

            builder.Services.AddSingleton<IFunctionFilter, PerformanceFilter>();

The code above is just a random set of config lines I've picked from one of our functions. It should look familiar to ASP.NET Core developers, though.

Don't use output bindings

One of the nice things about Azure Functions is an extended catalog of input and output bindings. Want to run when a new blob is written to blob storage? Add a blob input binding. Write a message to a queue once a function is completed? Add a service bus output binding. We had some old code ported from Windows Services, that did all of this communication manually and I wanted to switch to using output bindings when porting to v3.

I ended up rolling back most of these changes and avoid output bindings 100%. While the idea is clever, you loose control of what happens in those bindings. Also, each binding is implemented differently. Some with retries. Some without. This answer from Stephen Cleary explains is pretty well. In the recent iteration of the code, I create all of the database clients, topic clients, etc. in the Startup.cs file and inject them in the constructor for each function. I then have full control of when to make requests, how many retries I want to execute, etc. Another advantage is that the function code now looks a lot like the code in the ASP.NET Core websites, which initialized and injects dependencies in the exact same way.


Before I wrap up, I want to put a few words on automatic migration tools. A few options existed when we started the migration project. And even more came along since. I haven't tried any one of them, but enough choices are now available that I would probably do so if starting out today. Check out:


Migrating has been an overall great decision for us. We see a lot of advantages already. Simpler framework. Faster build times. Razor compilation way faster. Easier to work in Code for those who prefer that. Better throughput on Azure and less resource consumption (primarily memory). The possibility to move hosting to Linux. And much more. With that said, migrating took up a lot of time.

The sections above are listed from my memory as of writing this post. I might want to include more sections when I remember something or find new issues. If you have any specific subjects you want to learn more about or questions regarding migrating, feel free to reach out.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website