The Obligatory "How I Upgraded To .NET Core 3.1" Post

And No, It Wasn't Much Fun

by JamesQMurphy | December 28, 2019

Scott Hanselman did it. Rick Strahl did it. So I guess I needed to do it as well -- and, of course, blog about it. Yep, it was time to put aside the normal development work and upgrade my ASP .NET Core 2.2 site to .NET Core 3.1.

And with good reason; .NET Core 2.2 has officially reached end-of-support status, which means that Microsoft will no longer release patch updates for .NET Core 2.2. .NET Core 3.0 awaits the same fate on March 23, 2020. But .NET Core 3.1 is a Long-Term Support (LTS) version, which means it will be supported for three years.

So basically, if you're on .NET Core 2.2 or 3.01, now is the time to upgrade. I went through it, and although it wasn't bad, it wasn't exactly fun either. Make sure you plan enough time to go through the upgrade process, and don't forget to test your app. Microsoft's official migration documention is a good starting place, and it's what I used to guide myself through the migration.

All of the changes I did for the upgrade are in release v0.3.10. Most of the changes that I had to do are the same well-documented changes that everybody else had to do, but there were a few issues that I had to figure out on my own.

The Journey Of 1,000 Miles

The very first step was to update the project files to target netcoreapp31. You can also remove the explicit references to the Microsoft.AspNetCore.* packages and the Microsoft.VisualStudio.Web.CodeGeneration.Design package, since they are now included when you use the netcoreapp31 Target Framework Moniker. While I was in there, I also updated the Amazon.Lambda.* packages to the latest versions.

Before

<PropertyGroup>
  <TargetFramework>netcoreapp2.2</TargetFramework>
  <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
  .
  .
  .
<ItemGroup>
  <PackageReference Include="Amazon.AspNetCore.DataProtection.SSM" Version="1.1.0" />
  <PackageReference Include="Amazon.Lambda.AspNetCoreServer" Version="3.1.0" />
  <PackageReference Include="Amazon.Lambda.Core" Version="1.1.0" />
  <PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.0.0" />
  <PackageReference Include="Amazon.Lambda.Serialization.Json" Version="1.6.0" />
  <PackageReference Include="AWSSDK.DynamoDBv2" Version="3.3.101.64" />
  <PackageReference Include="Microsoft.AspNetCore.App" />
  <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
  <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.2.3" />  
</ItemGroup>

After

<PropertyGroup>
  <TargetFramework>netcoreapp3.1</TargetFramework>
  <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
  .
  .
  .
<ItemGroup>
  <PackageReference Include="Amazon.AspNetCore.DataProtection.SSM" Version="1.1.0" />
  <PackageReference Include="Amazon.Lambda.AspNetCoreServer" Version="4.1.0" />
  <PackageReference Include="Amazon.Lambda.Core" Version="1.1.0" />
  <PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.1.0" />
  <PackageReference Include="Amazon.Lambda.Serialization.Json" Version="1.7.0" />
  <PackageReference Include="AWSSDK.DynamoDBv2" Version="3.3.101.64" />
</ItemGroup>

Easy enough, but it wasn't the only project file I had to change. I had several other assemblies that targetted netstandard20. But for assemblies that also contained references to Microsoft.AspNetCore.Identity (like JamesQMurphy.Auth, for example), I needed to upgrade those projects as well to target netcoreapp31. In addition, since the project SDK was Microsoft.NET.Sdk and not Microsoft.NET.Sdk.Web, I needed to add a Framework reference2.

Before

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
  </ItemGroup>

</Project>

After

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

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

</Project>

Changes To Startup.cs

Most of the changes that I needed to do were in Startup.cs, and that's where you'll likely have to do the majority of your changes.

Fixing Syntax Errors

First, there were a few syntax changes that I needed to take care of. IHostingEnvironment has been replaced by IWebHostEnvironment, so the signature of Configure needed to change:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

The IsDevelopment() extension method is in a different namespace now (Microsoft.Extensions.Hosting), so I needed to add a using statement3.

using Microsoft.Extensions.Hosting;

MVC Service Registration

ASP.NET Core 3.0 offers some flexibility in configuring just how much MVC you want to use. The details can be found here, but in short, the "new" way to do services.AddMvc() is like this:

services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute())
);
services.AddRazorPages();

Endpoint Routing

Microsoft recommends switching to Endpoint Routing "if possible" (source). Thankfully, they have made it pretty straightforward as you can see below. Note that the Health Check endpoint also moved into the call.

Before

app.UseMvc(routes =>
{
    routes.MapControllerRoute(
        name: "blogIndex",
        template: "blog/{year?}/{month?}",
        defaults: new { controller = "blog", action = "index" });

    routes.MapControllerRoute(
        name: "blogDetails",
        template: "blog/{year}/{month}/{slug}",
        defaults: new { controller = "blog", action = "details" });

    routes.MapControllerRoute(
        name: "default",
        template: "{controller=home}/{action=index}/{id?}");
});

After

app.UseRouting();

app.UseEndpoints(endpoints =>
{
    endpoints.MapHealthChecks(Configuration["WarmUrl"]);

    endpoints.MapControllerRoute(
        name: "blogIndex",
        pattern: "blog/{year?}/{month?}",
        defaults: new { controller = "blog", action = "index" });

    endpoints.MapControllerRoute(
        name: "blogDetails",
        pattern: "blog/{year}/{month}/{slug}",
        defaults: new { controller = "blog", action = "details" });

    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=home}/{action=index}/{id?}");
});

Using Authorization and Ordering of Services

Heed Microsoft's warning about the ordering of the services:

For most apps, calls to UseAuthentication, UseAuthorization, and UseCors must appear between the calls to UseRouting and UseEndpoints to be effective.

Because of this warning, I shuffled around the calls to the various services:

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/home/error");
    app.UseHsts();
}

app.UseStaticFiles(); // abridged; see my source code for the full version

app.UseRouting();  // New for ASP.NET Core 3.0
app.UseAuthentication();
app.UseAuthorization(); // New for ASP.NET Core 3.0
app.UseHttpsRedirection();
app.UseCookiePolicy();
app.UseEndPoints(endpoints => ...);

I also needed one other change in Startup.cs, but in order to explain it, I need to talk about a breaking change introduced in the SignInManager class.

Changes to ApplicationSignInManager.cs

This is a breaking change that will only affect you if you are providing your own implementation of SignInManager:

Identity: SignInManager constructor accepts new parameter

Starting with ASP.NET Core 3.0, a new IUserConfirmation<TUser> parameter was added to the SignInManager constructor. For more information, see aspnet/AspNetCore#8356. (Source)

And since I am, in fact, implementing my own SignInManager, I followed Microsoft's recommended action:

If manually constructing a SignInManager, provide an implementation of IUserConfirmation or grab one from dependency injection to provide.

Hence, ApplicationUserConfirmation was born:

public class ApplicationUserConfirmation : IUserConfirmation<ApplicationUser>
{
    public Task<bool> IsConfirmedAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
    {
        return Task.FromResult(user.EmailConfirmed);
    }
}

Since this class is completely stateless and depends only on the ApplicationUser object passed in, a Singleton instance is an appropriate choice for dependency injection:

services.AddSingleton<ApplicationUserConfirmation>();

A Change Out Of Left Field

And then there was the breaking change that nobody mentioned4. ASP.NET Core defines an interface ILookupNormalizer which allows you to define how you want to "normalize" case-insensitive comparisons. For example, the default implementation simply converts all strings to uppercase; the strings username and UserName both normalize to USERNAME, and thus compare as equal.

What's the breaking change? The interface completely changed from ASP.NET Core 2.2:

# ASP.NET Core 2.2
public interface ILookupNormalizer
{
    string Normalize(string key);
}

To ASP.NET Core 3.0:

# ASP.NET Core 3.0
public interface ILookupNormalizer
{
        string NormalizeEmail(string email);
        string NormalizeName(string name);
}

In the old days of Microsoft, this would have been named ILookupNormalizer2 or something silly like that. I'm assuming that since this particular interface is rarely called by client code, they felt justified in breaking it. I'm totally fine with the change -- it allows implementers to use a different normalization mechanism for email and usernames -- but I would have liked to seen it listed somewhere.

This also means that the NormalizeKey() method on UserManager has been replaced with NormalizeEmail() and NormalizeName() methods. As it turns out, I had one call to NormalizeKey() inside of AccountController.cs, and I don't know why I even had it -- it wasn't necessary, since FindByEmailAsync() will call NormalizeEmail() for you.

var user = await _userManager.FindByEmailAsync(model.Email);

So after all that, the interface change really shouldn't have affected my website code. That said, I did have several instances of ILookupNormalizer.NormalizeKey() in my unit test project that had to be changed to either NormalizeEmail() or NormalizeName().

Build Changes

Aside from a typo in the endpoint routing pattern, the updated code worked immediately on my desktop. Surely I wouldn't need any changes to the CI/CD pipeline, right? Well, I shouldn't have, but unfortunately I did.

It appears that, at the time of this writing, the Azure Pipelines Build Agents (at least the Linux ones) have .NET Core 3.0 installed, not .NET Core 3.1. Fortunately, user Bernard Vander Beken on StackOverflow posted this answer, which was to add a UseDotNet step to the build process:

- task: UseDotNet@2
  displayName: 'Install .NET Core sdk'
  inputs:
    packageType: sdk
    version: 3.1.100
    installationPath: $(Agent.ToolsDirectory)/dotnet

And that did the trick.

Deployment Changes

Ok, so the build was working. What about the deployment process to AWS?

Fortunately, no changes were needed. In an older blog post, I showed how the build process currently produces a self-contained deployment, which means that the build artifact has everything it needs for the target platform. The Lambda function is configured as a "custom" image, which means it doesn't have anything special. AWS Lambda doesn't know nor care that this is a different version of ASP.NET Core.

At least, that's the case for now. The AWS Lambda team supports LTS versions of .NET Core, which is why they support .NET Core 2.1. They will support .NET Core 3.1, but it will take some time. Once it happens, I'll be able to publish a framework-dependent version of the website, which will reduce the artifact size dramatically and even speed load times. I'll be watching the associated GiHub issue (https://github.com/aws/aws-lambda-dotnet/issues/554).

Summary

Like I said, the upgrade wasn't bad, but it wasn't fun either. But it's what I do as a DevOps engineer. Looking back, I see that I referenced no less than four StackOverflow questions and a couple of official documentation pages. And for a relatively young technology, that's about par for the course.

I'm glad it's done. Now I can get back to working on the comments feature for this blog!


  1. If you're still on .NET Core 2.1, and you don't need the features of the newer version, then there's no hurry to upgrade since .NET Core 2.1 is also an LTS version. The full details can be found here.

  2. Thanks to user Twenty at StackOverflow for this answer.

  3. Thanks to user Rena at StackOverflow for this answer.

  4. Except for user VahidN at StackOverflow and this answer.