TL;DR
- .NET 8 reaches end of support on November 10, 2026; upgrading directly to .NET 10 LTS avoids an intermediate migration.
- Watch out for breaking changes in IPNetwork (forwarded headers behind proxies) and exception handling middleware.
- Pin your SDK in global.json and verify telemetry alerts aren’t swallowed silently.
Level: Advanced · Reading time: 15 min · Category: Case Studies
This is an illustrative case study. It combines situations that are common in production .NET systems. The company, people, and details are invented, and no numbers are presented as real measurements unless they come from the demo code in this article.
The ticket looked harmless: “Move Store.Api to .NET 10.” Elif, the team lead, attached one line from Microsoft’s support policy page. .NET 8 and .NET 9 both reach end of support on November 10, 2026. After that date there are no more security patches, and the security team would start flagging the API in every audit.
Kerem opened the first pull request on a Monday. He changed net8.0 to net10.0, pushed, and watched CI turn red. By Thursday the build was green again, the staging deploy looked healthy, and the error-rate alert had gone completely quiet. That silence turned out to be the most dangerous break of all.
The situation
Store.Api is the backend for a mid-size Turkish e-commerce company: an ASP.NET Core Web API with controllers, Entity Framework Core (EF Core) on SQL Server, and JSON Web Tokens (JWT) for the storefront. A small back office reports area uses a separate cookie scheme named BackOffice. The API runs in Docker behind a load balancer, so it relies on forwarded headers. Swagger UI comes from Swashbuckle 6 with a JWT security definition and one operation filter. CI builds with TreatWarningsAsErrors.
The support dates, from the official .NET support policy page:
| Version | Type | End of support |
|---|---|---|
| .NET 8 | LTS | November 10, 2026 |
| .NET 9 | STS | November 10, 2026 |
| .NET 10 | LTS | November 14, 2028 |
Skipping .NET 9 was an easy call. The plan had five mechanical steps before any code changes.
Pin the SDK in global.json at the repository root:
| |
Change the target framework in one place, Directory.Build.props:
| |
List what is outdated, then align every Microsoft.* package on the same major version:
| |
Keep the versions in Directory.Packages.props so no project can drift:
| |
Finally, change the container base image tags from 8.0-bookworm-slim to the .NET 10 images. That step broke too.
Symptoms
The breaks arrived in three waves.
At build time:
error ASPDEPR005: 'ForwardedHeadersOptions.KnownNetworks' is obsoleteinProgram.cs.error NU1510on a directSystem.Text.Jsonpackage reference nobody remembered adding.NU1903vulnerability warnings, promoted to errors, for a package the team never referenced directly.- Every Swashbuckle file failed with
error CS0234: The type or namespace name 'Models' does not exist in the namespace 'Microsoft.OpenApi'. - The nightly job that cancels stale orders no longer compiled at its
ExecuteUpdateAsynccall.
At image build time:
docker buildfailed because the image tagmcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slimdoes not exist.
On staging:
- An integration test that expected
302 Foundfor an anonymous back office request got401 Unauthorized. - The “5xx errors with exceptions” alert went silent, while the dashboard still showed 500 responses.
- Database CPU rose briefly right after the deploy, then settled.
Investigation
The team grouped build errors by code first, so they could fix one category at a time:
| |
For the vulnerability warnings, they listed vulnerable transitive packages and asked NuGet which top-level package pulled each one in:
| |
The silent alert took longer. The alert query counted log events from one category, Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware. On .NET 8, every exception that reached UseExceptionHandler produced this line, even when Store.Api’s IExceptionHandler turned it into a ProblemDetails response:
| |
On .NET 10 the 500 response was identical, but that log line never appeared. The handler returned true, and that now means “handled, record nothing”.
The 401 was reproducible from a terminal:
| |
| |
The Location header was still there. Only the status code had changed.
For the database CPU bump, the team compared the SQL that EF Core logged on both versions. EF Core 10 names parameters after the variable (@status instead of @__status_0) and translates ids.Contains(o.Id) into IN (@ids1, @ids2, @ids3) instead of an OPENJSON parameter. New SQL text means new cached plans, so every query compiled once after the deploy.
Root cause
Every symptom mapped to a documented breaking change in .NET 10 or in a package upgraded with it:
| Symptom | Breaking change | Type |
|---|---|---|
| ASPDEPR005 | IPNetwork and ForwardedHeadersOptions.KnownNetworks are obsolete | Source incompatible |
| Silent alert | Exception diagnostics suppressed when TryHandleAsync returns true | Behavioral |
| 401 instead of 302 | Cookie login redirects disabled for known API endpoints | Behavioral |
| Swashbuckle errors | Swashbuckle 10 moved to Microsoft.OpenApi 2.x (namespace and model changes) | Source incompatible |
ExecuteUpdateAsync error | EF Core 10 setters are a plain lambda, not an expression tree | Source incompatible |
| SQL text and plans | Parameterized collections use multiple parameters; parameter names simplified | Behavioral |
| NU1510 | NuGet raises NU1510 for direct references pruned by the framework | Source incompatible |
| NU1903 | dotnet restore audits transitive packages for net10.0 projects | Behavioral |
| Missing image tag | Default .NET 10 images use Ubuntu, and no Debian images ship | Behavioral |
Two more changes did not break anything but needed attention. dotnet new sln now creates an .slnx file, and the EF Core tools require --framework on multi-targeted projects.
The fix
Forwarded headers
Before (.NET 8):
| |
After (.NET 10), using System.Net.IPNetwork:
| |
The fully qualified name avoids a clash with the obsolete Microsoft.AspNetCore.HttpOverrides.IPNetwork.
Exception logging
The team decided the handler should own its logging, so the alert no longer depends on middleware internals:
| |
They updated the alert to the new category, Store.Api.Infrastructure.GlobalExceptionHandler. A team that wants the old behavior instead, including the error.type tag on the http.server.request.duration metric, can opt out of suppression:
| |
Cookie redirects
For [ApiController] endpoints, 401 and 403 are the right answers, so the team fixed the test, not the code:
| |
The test project references Microsoft.AspNetCore.Mvc.Testing and xUnit, and Program must be visible to it (on .NET 10 a source generator makes it public; on .NET 8 add public partial class Program; at the end of Program.cs). One endpoint really was opened from links in emails, a CSV download, so it opts back in to redirects:
| |
Swashbuckle 10 and Microsoft.OpenApi 2.x
The Models namespace is gone, Type is a JsonSchemaType flags enum, and security requirements reference schemes through the document. Before (Swashbuckle 6):
| |
After (Swashbuckle 10):
| |
| |
The old filter set Type = "string", which no longer compiles. Swashbuckle 10 still writes OpenAPI 3.0 unless a project opts in to 3.1.
EF Core 10 bulk updates
The stale-order job built its setters as a reusable expression. Before (EF Core 8):
| |
EF Core 10 takes an Action<UpdateSettersBuilder<Order>>, so conditional setters become ordinary if statements. Store.Api’s real Order has a CancelReason column beyond the minimal shape:
| |
Inline chained lambdas such as s => s.SetProperty(...).SetProperty(...) keep compiling. Only stored expression trees break.
For the new collection translation, the team kept the default and watched Query Store. If one query regresses, change that query only:
| |
The global switch is UseSqlServer(connectionString, o => o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter)).
NuGet warnings
They deleted the System.Text.Json reference (the framework provides it), and upgraded the top-level package that dotnet nuget why named. To keep audit warnings visible without letting a new advisory block an urgent hotfix, they added this to Directory.Build.props:
| |
A nightly job still fails on any audit warning, so nobody can ignore them for long.
Solution file and Dockerfile
| |
| |
Port 8080 and the non-root app user are the same as on .NET 8. Re-check any apt-get install lines, because package names can differ between Debian and Ubuntu.
A CI matrix for the transition
For two weeks the team built both frameworks, so a hotfix could still ship on .NET 8. They temporarily replaced <TargetFramework> with <TargetFrameworks>net8.0;net10.0</TargetFrameworks> and made versions conditional:
| |
Code that differs goes behind #if NET10_0_OR_GREATER. The workflow runs each framework as its own job:
| |
While multi-targeting, every EF Core tools command needs the framework: dotnet ef migrations add AddCancelReason --framework net10.0.
Results
Store.Api went to production on .NET 10 three weeks before the support deadline. The rollout itself was uneventful because every break had already surfaced in CI or staging. The error alert fired again in staging within minutes of a deliberately broken request, and the database CPU bump after deploy matched the one-time plan recompilation the EF Core documentation warns about. The team removed the net8.0 target, the conditional package versions, and the #if blocks in a follow-up pull request.
What the team would do differently
- Read the breaking change lists first. Every surprise was documented. One hour with the ASP.NET Core, EF Core, SDK, and containers lists would have turned them into planned tasks.
- Upgrade cross-framework packages before the framework. Swashbuckle 10 also supports net8.0. Doing that migration on .NET 8 would have kept the framework pull request small.
- Test the alerts, not just the API. A green test suite said nothing about the log line an alert depended on.
- Treat behavioral changes as release notes for operations. Status codes, log categories, and SQL text are contracts with other teams.
Checklist for your project
- Confirm the support dates for every runtime you deploy on the official .NET support policy page.
- Add
global.jsonwith the .NET 10 SDK and arollForwardpolicy. - Move
TargetFrameworkand package versions toDirectory.Build.propsandDirectory.Packages.props. - Run
dotnet list package --outdatedand align allMicrosoft.*packages on 10.0.x. - Search for
KnownNetworks,WithOpenApi,WebHostBuilder, andMicrosoft.OpenApi.Models. - Search for stored
SetPropertyCallsexpressions used withExecuteUpdate. - Check every
IExceptionHandler: does it log, and does any alert depend on middleware logs? - Check tests and clients that expect cookie redirects from API endpoints.
- Run
dotnet package list --vulnerable --include-transitiveand decide how NU190x warnings should fail builds. - Replace Debian image tags and re-test
apt-getlines on Ubuntu. - Decide between
.slnand.slnxbefore someone runsdotnet new sln. - Watch database CPU and Query Store for a day after the first deploy.
Related reading
- Swashbuckle vs NSwag vs Built-in OpenAPI with Scalar
- dotnet CLI Cheat Sheet
- Migrating a .NET Framework 4.8 App to Modern .NET Step by Step
- Global Exception Handling with IExceptionHandler and ProblemDetails (in “The .NET Implementation Playbook”)
- Docker, Health Checks, and Deploying .NET Apps (in “Zero to Full-Stack .NET”)
Tags: dotnet, aspnet-core, entity-framework-core, dotnet-10, migration
Part of The .NET Field Guide. How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.
