Reading Old .NET Core 3.1 and .NET 5 Projects: Migrating from Startup.cs to .NET 6
We read the Startup.cs, Generic Host and UseRouting/UseEndpoints structure of the .NET Core 3.1 and .NET 5 era, then move the same ASP.NET Core API to .NET 6 step by step, including System.Text.Json differences.
Version note: This post covers reading .NET Core 3.1 and .NET 5 projects and moving them to .NET 6; the code targets .NET 6.
When You Open an Old Project
In the first part of the series we built the skeleton on .NET 6: a single Program.cs, top-level statements, WebApplication.CreateBuilder. That's what you get when you create a new project. But the repo you're handed in your first week at a new job probably won't look like that. There will be a Startup.cs in the folder, a Main method in Program.cs, and netcoreapp3.1 in the csproj.
That's not an edge case. In JetBrains' 2025 .NET survey, roughly 10 percent of respondents said they still work with .NET 5 or earlier. So we start the version posts by stepping two releases back in time: first we learn to read the skeleton of the .NET Core 3.1 and .NET 5 era, then we move the same project to .NET 6 step by step.
Guess First
In the Startup.Configure method you're about to see, imagine taking the app.UseAuthorization() line and moving it below the app.UseEndpoints(...) block. The project still compiles. Then you send a request without a token to DELETE /api/products/1, which is marked with [Authorize]. Which status code comes back: 401, 403, or something else? And what does the public GET /api/products do?
Program.cs: Starting Up with the Generic Host
In a Web API created with .NET Core 3.1 or .NET 5, Program.cs almost always looks like this:
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace ProductCatalog.Api
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
// EF Core tools also find the host through this method
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
There are three layers here. Host.CreateDefaultBuilder sets up the Generic Host, the default since 3.0: it prepares the configuration sources (appsettings.json, appsettings.{Environment}.json, environment variables, the command line), logging and the dependency injection container. ConfigureWebHostDefaults adds the web layer on top of that host; it configures Kestrel and IIS integration. UseStartup<Startup>() says "you'll find everything else in this class".
If you noticed, that's exactly the work WebApplication.CreateBuilder does in one line in .NET 6. The Generic Host underneath didn't change; .NET 6 just lets you use it with less ceremony. In an old project you're entering the same building through the back door, that's all.
Startup.cs: ConfigureServices and Configure
The real work happens in the Startup class. Here's the .NET Core 3.1 version of our ProductCatalog API:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProductCatalog.Api.Services;
namespace ProductCatalog.Api
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// Service registrations: the counterpart of builder.Services in .NET 6
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IProductService, InMemoryProductService>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
}
// Middleware pipeline: the counterpart of the app.UseXxx lines in .NET 6
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
Two methods, two separate phases. ConfigureServices tells the container what will be registered; the app isn't taking requests yet. Configure orders the middleware pipeline the request will pass through. Think of it as opening a restaurant: ConfigureServices stocks the kitchen and puts the cooks in place, Configure decides which stations an order goes through and in what order.
The runtime finds and calls these two methods via reflection. The method names are a contract; if you type ConfigureService with one letter missing, the compiler says nothing, the method is never called, and you only find out once the app is running. Besides IWebHostEnvironment, Configure can also ask for other services from the container as parameters, and the runtime resolves them too. Also note the UseDeveloperExceptionPage() line: in 3.1 and 5 you had to add it by hand, while in .NET 6 it comes automatically in the Development environment.
The Zone Between UseRouting and UseEndpoints
Endpoint routing, introduced in 3.0, split routing into two parts. UseRouting() decides which endpoint matches the incoming request and writes that into the HttpContext. UseEndpoints() runs the selected endpoint. Middleware placed in between can see the selected endpoint; whether it has an [Authorize] attribute, what the CORS policy says, all of that is read in this zone.
Now the answer to the guess. I moved UseAuthorization() below UseEndpoints, ran the app in the Production environment and sent a request without a token:
HTTP/1.1 500 Internal Server Error
Content-Length: 0
Server: Kestrel
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPPTSAMGTI7", Request id "0HNPPTSAMGTI7:00000001": An unhandled exception was thrown by the application.
System.InvalidOperationException: Endpoint ProductCatalog.Api.Controllers.ProductsController.Delete (ProductCatalog.Api) contains authorization metadata, but a middleware was not found that supports authorization.
Configure your application startup by adding app.UseAuthorization() in the application startup code. If there are calls to app.UseRouting() and app.UseEndpoints(...), the call to app.UseAuthorization() must go between them.
Neither 401 nor 403: it's a 500. The endpoint middleware sees that the endpoint it's about to run has authorization metadata, notices that the request never went through an authorization check, and throws instead of opening a security hole. The public GET /api/products keeps returning 200, because that endpoint has no authorization metadata. So the bug only shows up when a request hits a protected endpoint. Put the line back between the two calls and the DELETE request returns 401 Unauthorized with a WWW-Authenticate: Bearer header, as expected.
The Same API with .NET 6 Minimal Hosting
Let's collapse the two files into a single Program.cs:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using ProductCatalog.Api.Services;
var builder = WebApplication.CreateBuilder(args);
// Formerly ConfigureServices
builder.Services.AddControllers();
builder.Services.AddSingleton<IProductService, InMemoryProductService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
var app = builder.Build();
// Formerly Configure: UseRouting and UseEndpoints are no longer needed
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
The body of ConfigureServices moved onto builder.Services, and the body of Configure moved onto app. Replacing the services. prefix with builder.Services. is usually enough. The UseRouting and UseEndpoints calls are gone, because WebApplication adds the routing middleware at the start of the pipeline and the endpoint middleware at the end on its own. We can call app.MapControllers() directly on app. And since the developer exception page now comes automatically in Development, that if block is gone too.
When I built and ran this on .NET 6, GET /api/products/1 returned {"id":1,"name":"Mechanical Keyboard","price":1499.90}, and the DELETE without a token returned 401. Exactly the same behavior; the only thing that changed is the amount of ceremony.
From Newtonsoft.Json to System.Text.Json
There's another line you'll often see in old projects: services.AddControllers().AddNewtonsoftJson(). With ASP.NET Core 3.0, System.Text.Json became the default JSON serializer and Newtonsoft.Json was removed from the shared framework. Projects that depended on the old behavior added the Microsoft.AspNetCore.Mvc.NewtonsoftJson package and carried on. Before you delete that line during a migration, you need to know the differences. The most common thing that blows up is circular references, which are very typical with entities coming from EF Core:
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<Product> Products { get; set; } = new();
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Product points to its Category, and Category points to its Products
public Category? Category { get; set; }
}
Return this category straight from an action and System.Text.Json tries to follow the relationship forever, giving up at a depth of 32. The response is a 500, and the log says:
System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Products.Category.Products.Category...
.NET 6 introduced ReferenceHandler.IgnoreCycles for exactly this:
using System.Text.Json.Serialization;
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
// Response:
// {"id":1,"name":"Peripherals","products":[{"id":1,"name":"Mechanical Keyboard","category":null}]}
The Newtonsoft equivalent looks like this:
using Newtonsoft.Json;
builder.Services.AddControllers()
.AddNewtonsoftJson(options =>
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore);
// Response:
// {"id":1,"name":"Peripherals","products":[{"id":1,"name":"Mechanical Keyboard"}]}
A small but important difference: Newtonsoft doesn't write the property that closes the loop at all, while System.Text.Json writes "category":null. If a client reading this response distinguishes between "no property" and "property is null", switching serializers can break it. The real fix, of course, is returning a DTO instead of an entity; as we saw earlier, a DTO has no cycles.
C# 8, 9 and 10: Traces You'll Notice in the Code
When you open an old file, a few signs tell you which C# era it's from. .NET Core 3.1 ships with C# 8, .NET 5 with C# 9 and .NET 6 with C# 10. Two versions of the same DTO:
// .NET Core 3.1 era: namespace block, nullable off, classic class
namespace ProductCatalog.Api.Dtos
{
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
}
// .NET 6 era: GlobalUsings.cs
global using ProductCatalog.Api.Dtos;
// Dtos/ProductDto.cs: file-scoped namespace and a record
namespace ProductCatalog.Api.Dtos;
public record ProductDto(int Id, string Name, decimal Price);
C# 8 brought nullable reference types but left them opt-in per project; in most old projects they're off. C# 9 brought records, init setters, target-typed new() and top-level statements, so seeing a record in a .NET 5 project is normal. C# 10 added global using, file-scoped namespaces and record structs. The ImplicitUsings setting in the .NET 6 templates adds commonly used namespaces such as System.Linq or Microsoft.AspNetCore.Builder as global usings behind the scenes; that's why the long using list at the top of old files is missing in new projects.
Steps for Moving to .NET 6
The first step is the csproj. The old project file:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.32" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.32" />
</ItemGroup>
</Project>
The new one:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<!-- Default in new projects; in an old project, brace for a flood of warnings -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.36" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.36" />
</ItemGroup>
</Project>
The TargetFramework value goes from netcoreapp3.1 or net5.0 to net6.0. The Microsoft.AspNetCore.*, Microsoft.Extensions.* and EF Core packages move to the same major version as the framework; the last patch in the 6.0 line is 6.0.36. On the 3.1 and 5.0 side, the last versions are 3.1.32 and 5.0.17 respectively. If the repo has a global.json, update the SDK version in it too, or the old SDK will be looked for.
The second step is hosting. There's good news here: according to Microsoft's migration guide, apps moving to .NET 6 don't have to adopt minimal hosting; Startup and the Generic Host are fully supported. I built the Program.cs and Startup.cs above against net6.0 without changing anything; the build was clean and the app returned the same responses. So you can change just the TargetFramework first, get the tests green, and deal with hosting later. There's one more stop in between: keep the Startup class and call it by hand from the new host.
using ProductCatalog.Api;
var builder = WebApplication.CreateBuilder(args);
// You call Startup now, not reflection
var startup = new Startup(builder.Configuration);
startup.ConfigureServices(builder.Services);
var app = builder.Build();
startup.Configure(app, app.Environment);
app.Run();
In this form I got the same 200 and 401 responses. If you don't want to take apart a big Startup in one go, this intermediate step is a safe bridge.
The third step is the breaking change list. The items in the ASP.NET Core 6 list that matter most for a Web API are these: in an action returning ActionResult<T>, if you set Response.StatusCode to 202 by hand and return a T, you now get 200, so use Accepted(...) or StatusCode(202, ...) instead; with System.Text.Json, MVC no longer buffers IAsyncEnumerable<T>; and the framework's nullable annotations changed, which means new warnings. If you're coming from 3.1, check the 5.0 list as well.
Finally, support status. .NET Core 3.1 was LTS and went out of support on December 13, 2022; .NET 5 was STS and went out of support on May 10, 2022. Our target, .NET 6, was LTS as well and went out of support on November 12, 2024. So this migration isn't a destination, it's the first stop. Once you're on 6, moving to later versions is much calmer, because the Program.cs skeleton stays the same all the way to .NET 10.
Common Pitfalls
The classic one is trying to attach Startup to the new builder the old way. The line builder.WebHost.UseStartup<Startup>() compiles, but the app blows up on startup:
Unhandled exception. System.NotSupportedException: UseStartup() is not supported by WebApplicationBuilder.WebHost. Use the WebApplication returned by WebApplicationBuilder.Build() instead.
Either stay on the old Host.CreateDefaultBuilder skeleton or call Startup by hand as shown above; there's no way to mix the two.
The second is turning on Nullable and trying to fix everything on the same day. Even in our tiny project, the first build produced CS8618 for Product.Name and CS8603 for the method returning FirstOrDefault. In a real project that number runs into the hundreds. Migrate the framework first, then turn on nullable as a separate task, file by file with #nullable enable if needed.
The third is a subtle change to the application name. According to the guide, in .NET 6 WebApplicationBuilder normalizes the content root path by adding a trailing directory separator, which can make the app name differ from the old one. In a setup that isolates Data Protection keys by application name, that means old cookies or protected data can't be read. If you rely on that, pin the name with SetApplicationName.
The fourth is services injected into Configure. In the intermediate step where you call Startup by hand, the runtime no longer resolves extra parameters of Configure; you have to get them from app.Services and pass them yourself. If you need a scoped service, create a scope first.
A Small Experiment
★☆☆ Set up a project with the Program.cs, Startup.cs and ProductsController from this post, change TargetFramework to net6.0 and build without touching a single line. Note the responses to GET /api/products and to DELETE /api/products/1 without a token.
★★☆ Convert the same project first to the intermediate step that calls Startup by hand, then to single-file minimal hosting. The responses to the two requests shouldn't change at any step. Then move UseAuthorization() below UseEndpoints, see the 500 for yourself, and also run the app in the Development environment to see how the same error looks there.
★★★ In the Category and Product example, try ReferenceHandler.IgnoreCycles first, then ReferenceHandler.Preserve, and compare the two JSON outputs. Then switch to AddNewtonsoftJson and put the output of the same endpoint side by side. Which one would you be comfortable handing to a client?
You can now read an old project and move it onto the .NET 6 skeleton. In the next post we move to .NET 7: with MapGroup and TypedResults, which matured minimal APIs, plus endpoint filters, built-in rate limiting and output caching, we'll see how many of the things we wrote by hand in .NET 6 moved into the framework.