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 is 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, whereas .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.
ASP.NET Core
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 straightforward. 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 were 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 to switch to use Newtonsoft.Json
on the server too:
services
.AddControllersWithViews()
.AddNewtonsoftJson();
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:
{"Hello":"World"}
When returning the same from ASP.NET Core, the returned JSON is camel-cased:
{"hello":"World"}
If you, like us, already have a JavaScript-based client expecting pascal-casing for C# serialized objects, you can change the casing using this:
services
.AddControllersWithViews()
.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
:
services
.AddControllersWithViews()
.AddRazorRuntimeCompilation();
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"?>
<configuration>
<system.webServer>
<security>
<requestFiltering removeServerHeader="true" />
</security>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>
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 BundlerAndMinifier
which 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": [
"wwwroot/css/style1.css",
"wwwroot/css/style2.css"
]
}
]
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 does 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)
{
builder.Services.AddHttpClient(...);
var config = new ConfigurationBuilder()
.AddJsonFile("local.settings.json")
.Build();
builder.Services.AddLogging(logging =>
{
logging.AddSerilog(dispose:true);
});
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 avoiding output bindings 100%. While the idea is clever, you lose control of what happens in those bindings. Also, each binding is implemented differently. Some with retries. Some without. This answer from Stephen Cleary explains it 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 into 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.
Tools
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:
- The .NET Portability Analyzer
- .NET API analyzer
- try-convert
- Porting Assistant for .NET
- Upgrade Assistant
Conclusion
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.