With the release of .NET 5, I wanted to update my Vertical Slice code example (Contoso University) to .NET 5, as well as leverage all the C# 9 goodness. The migration itself was very simple, but updating the dependencies along the way proved to be a bit more of a challenge.

Updating the runtime and dependencies

The first step was migrating the target framework to .NET 5. This is about as simple as it gets:

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

  <PropertyGroup>
-    <TargetFramework>netcoreapp3.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

</Project>

Next, I needed to update the dependencies. This application hadn't had its dependencies for a few months, so most of the changes were around that. Otherwise, any Microsoft.* dependency updated to a 5.0 version:

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="10.1.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
-   <PackageReference Include="DelegateDecompiler.EntityFrameworkCore" Version="0.28.0" />
-   <PackageReference Include="FluentValidation.AspNetCore" Version="9.2.0" />
-   <PackageReference Include="HtmlTags" Version="8.0.0" />
+   <PackageReference Include="DelegateDecompiler.EntityFrameworkCore5" Version="0.28.2" />
+   <PackageReference Include="FluentValidation.AspNetCore" Version="9.3.0" />
+   <PackageReference Include="HtmlTags" Version="8.1.1" />
    <PackageReference Include="MediatR" Version="9.0.0" />
    <PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
    <PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.2.1" />
    <PackageReference Include="MiniProfiler.EntityFrameworkCore" Version="4.2.1" />
-   <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.9" />
-   <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.9">
+   <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.0" />
+   <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
-   <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.1.9" />
-   <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.4" />
+   <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="5.0.0" />
+   <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.0" />
  </ItemGroup>

This was as simple as updating my packages to the latest version. From here, I compiled, ran the build, and ran the app. Everything worked as expected.

Some folks are put off by the fact that 5.0 is not "LTS" but that really shouldn't stop you from using it - you can always upgrade to the next LTS when it comes out.

One package I did need to change was the DelegateDecompiler.EntityFrameworkCore one, and this led to my last post and finally a new package that explicitly targets EF Core 5. This is because EF Core changed an API that broke the existing package, so  it was easier to create a new package than make the multi-targeting more complicated. With all the packages and runtime update, I next wanted to see how C# 9 might improve things.

Updating to C# 9

The runtime upgrade on a vanilla ASP.NET Core 3.1 application brings with it lots of performance improvements, but I was really looking forward to the C# 9 improvements (which many technically work on lower versions, but that's a different story).

C# 9 has a lot major and minor improvements, but a few I was really interested in:

  • Records
  • Init-only setters
  • Pattern-matching
  • Static lambda functions

First up are records. I wasn't quite sure if records were supported everywhere in my application, but I wanted to look at them for DTOs, which were all the request/response objects used with MediatR:

Request and responses with handlers

Incoming request objects use ASP.NET Core data binding, which does support records, and outgoing objects use AutoMapper with ProjectTo, so I needed to understand if EF Core 5 supported record types for LINQ projections.

It should however, as I've tested LINQ expressions and compilation and reflection, and they all still work with record types. Converting my DTOs to record types was simple, but I did need to decide between positional vs. nominal record types. I decided to go with nominal as I found it easier to read:

public record Query : IRequest<Command>
{
    public int? Id { get; init; }
}

public record Command : IRequest
{
    public int Id { get; init; }
    public string LastName { get; init; }

    [Display(Name = "First Name")]
    public string FirstMidName { get; init; }

    public DateTime? EnrollmentDate { get; init; }
}

I changed class to record and set to init, and now I've got an "immutable" record type. From there, I needed to fix some compile errors which were mainly around mutating DTOs. That wound up making the code easier to understand/follow, because instead of creating the DTO and mutating several times, I could either gather all the data I needed initially, or as part of the initialization expression:

var results = await students
    .ProjectTo<Model>(_configuration)
    .PaginatedListAsync(pageNumber, pageSize);

var model = new Result
{
    CurrentSort = message.SortOrder,
    NameSortParm = string.IsNullOrEmpty(message.SortOrder) ? "name_desc" : "",
    DateSortParm = message.SortOrder == "Date" ? "date_desc" : "Date",
    CurrentFilter = searchString,
    SearchString = searchString,
    Results = results
};      

Initially this code would instantiate the Result object, mutate it a few places, then return it. I found the places that mutated the DTOs to be much more confusing, which I'm sure the functional folks are scoffing at.

Less impactful were the pattern matching with switch expressions, but it did provide some improvement:

//before
students = message.SortOrder switch
{
    case "name_desc":
        students = students.OrderByDescending(s => s.LastName);
        break;
    case "Date":
        students = students.OrderBy(s => s.EnrollmentDate);
        break;
    case "date_desc":
        students = students.OrderByDescending(s => s.EnrollmentDate);
        break;
    default: // Name ascending 
        students = students.OrderBy(s => s.LastName);
        break;
}

//after
students = message.SortOrder switch
    "name_desc" => students.OrderByDescending(s => s.LastName),
    "Date" => students.OrderBy(s => s.EnrollmentDate),
    "date_desc" => students.OrderByDescending(s => s.EnrollmentDate),
    _ => students.OrderBy(s => s.LastName)
}

It's small, but removes a lot of the noise of the code. Finally, I tried to use the static lambdas as much as I could when the lambda didn't capture any closure variables (or, shouldn't).

All in all, a very easy migration. Next, I'll be updating my OSS to C# 9 where I can (without using breaking features).