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:

VersionTypeEnd of support
.NET 8LTSNovember 10, 2026
.NET 9STSNovember 10, 2026
.NET 10LTSNovember 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:

1
2
3
4
5
6
{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  }
}

Change the target framework in one place, Directory.Build.props:

1
2
3
4
5
6
7
8
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

List what is outdated, then align every Microsoft.* package on the same major version:

1
2
3
dotnet list package --outdated
# .NET 10 SDK also accepts the noun-first form:
dotnet package list --outdated --format json > outdated.json

Keep the versions in Directory.Packages.props so no project can drift:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
    <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
  </ItemGroup>
</Project>

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 obsolete in Program.cs.
  • error NU1510 on a direct System.Text.Json package reference nobody remembered adding.
  • NU1903 vulnerability 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 ExecuteUpdateAsync call.

At image build time:

  • docker build failed because the image tag mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim does not exist.

On staging:

  • An integration test that expected 302 Found for an anonymous back office request got 401 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:

1
dotnet build -c Release 2>&1 | grep -oE "(error|warning) [A-Z]+[0-9]+" | sort | uniq -c

For the vulnerability warnings, they listed vulnerable transitive packages and asked NuGet which top-level package pulled each one in:

1
2
3
(dotnet package list --vulnerable --include-transitive --format json | ConvertFrom-Json).projects.frameworks.transitivePackages.id |
  Sort-Object -Unique |
  ForEach-Object { dotnet nuget why src/Store.Api/Store.Api.csproj $_ }

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:

1
2
3
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.InvalidOperationException: Pricing service returned null

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:

1
curl -i https://localhost:7001/api/admin/reports/daily-sales
1
2
HTTP/1.1 401 Unauthorized
Location: https://localhost:7001/admin/login?ReturnUrl=%2Fapi%2Fadmin%2Freports%2Fdaily-sales

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:

SymptomBreaking changeType
ASPDEPR005IPNetwork and ForwardedHeadersOptions.KnownNetworks are obsoleteSource incompatible
Silent alertException diagnostics suppressed when TryHandleAsync returns trueBehavioral
401 instead of 302Cookie login redirects disabled for known API endpointsBehavioral
Swashbuckle errorsSwashbuckle 10 moved to Microsoft.OpenApi 2.x (namespace and model changes)Source incompatible
ExecuteUpdateAsync errorEF Core 10 setters are a plain lambda, not an expression treeSource incompatible
SQL text and plansParameterized collections use multiple parameters; parameter names simplifiedBehavioral
NU1510NuGet raises NU1510 for direct references pruned by the frameworkSource incompatible
NU1903dotnet restore audits transitive packages for net10.0 projectsBehavioral
Missing image tagDefault .NET 10 images use Ubuntu, and no Debian images shipBehavioral

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):

1
2
3
4
5
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.20.0.0"), 16));
});

After (.NET 10), using System.Net.IPNetwork:

1
2
3
4
5
6
// Program.cs, before builder.Build(). Needs: using Microsoft.AspNetCore.HttpOverrides;
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.20.0.0/16"));
});

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// File: Infrastructure/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace Store.Api.Infrastructure;

public sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetails,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        // .NET 10 no longer logs exceptions that a handler reports as handled.
        logger.LogError(exception, "Unhandled exception for {Method} {Path}",
            httpContext.Request.Method, httpContext.Request.Path);

        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An unexpected error occurred."
            }
        });
    }
}

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:

1
2
3
4
5
// Program.cs, in the pipeline after builder.Build()
app.UseExceptionHandler(new ExceptionHandlerOptions
{
    SuppressDiagnosticsCallback = _ => false
});

For [ApiController] endpoints, 401 and 403 are the right answers, so the team fixed the test, not the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// File: tests/Store.Api.Tests/BackOfficeAuthTests.cs
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace Store.Api.Tests;

public sealed class BackOfficeAuthTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Anonymous_report_request_returns_401_with_login_location()
    {
        var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

        var response = await client.GetAsync("/api/admin/reports/daily-sales");

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        Assert.StartsWith("/admin/login", response.Headers.Location?.AbsolutePath);
    }
}

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:

1
2
3
4
[HttpGet("daily-sales.csv")]
[AllowCookieRedirect] // Microsoft.AspNetCore.Http, new in .NET 10
public IActionResult DailySalesCsv() =>
    File("date,total\n"u8.ToArray(), "text/csv", "daily-sales.csv");

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
using Microsoft.OpenApi.Models;

options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
    {
        new OpenApiSecurityScheme
        {
            Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
        },
        Array.Empty<string>()
    }
});

After (Swashbuckle 10):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Program.cs, before builder.Build(). Needs: using Microsoft.OpenApi;
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "Store.Api", Version = "v1" });
    options.OperationFilter<Store.Api.OpenApi.CorrelationIdHeaderFilter>();
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Paste the access token only."
    });
    options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
    {
        [new OpenApiSecuritySchemeReference("Bearer", document)] = []
    });
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// File: OpenApi/CorrelationIdHeaderFilter.cs
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Store.Api.OpenApi;

public sealed class CorrelationIdHeaderFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        operation.Parameters ??= [];
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "X-Correlation-Id",
            In = ParameterLocation.Header,
            Required = false,
            Schema = new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid" }
        });
    }
}

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):

1
2
3
4
5
6
private static Expression<Func<SetPropertyCalls<Order>, SetPropertyCalls<Order>>> CancelSetters() =>
    s => s.SetProperty(o => o.Status, OrderStatus.Cancelled);

await db.Orders
    .Where(o => o.Status == OrderStatus.Pending && o.CreatedAtUtc < cutoffUtc)
    .ExecuteUpdateAsync(CancelSetters(), ct);

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// File: Orders/StaleOrderCanceller.cs
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;

namespace Store.Api.Orders;

public sealed class StaleOrderCanceller(AppDbContext db, TimeProvider clock)
{
    public Task<int> CancelAsync(TimeSpan maxAge, string? reason, CancellationToken ct)
    {
        var cutoffUtc = clock.GetUtcNow().UtcDateTime - maxAge;

        return db.Orders
            .Where(o => o.Status == OrderStatus.Pending && o.CreatedAtUtc < cutoffUtc)
            .ExecuteUpdateAsync(s =>
            {
                s.SetProperty(o => o.Status, OrderStatus.Cancelled);
                if (reason is not null)
                {
                    s.SetProperty(o => o.CancelReason, reason);
                }
            }, ct);
    }
}

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:

1
2
3
var orders = await db.Orders
    .Where(o => EF.Parameter(orderIds).Contains(o.Id)) // single JSON parameter, the EF Core 8 and 9 shape
    .ToListAsync(ct);

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:

1
2
3
<PropertyGroup>
  <WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904;$(WarningsNotAsErrors)</WarningsNotAsErrors>
</PropertyGroup>

A nightly job still fails on any audit warning, so nobody can ignore them for long.

Solution file and Dockerfile

1
2
dotnet sln Store.sln migrate        # writes Store.slnx; update CI paths afterwards
dotnet new sln --format sln         # only if a tool in your pipeline cannot read .slnx yet
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Store.Api/Store.Api.csproj -c Release -o /app/publish

# 10.0 is Ubuntu 24.04 "Noble"; there is no Debian variant for .NET 10
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "Store.Api.dll"]

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:

1
2
3
4
5
6
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
  <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.31" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
  <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
</ItemGroup>

Code that differs goes behind #if NET10_0_OR_GREATER. The workflow runs each framework as its own job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        tfm: [net8.0, net10.0]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: |
            8.0.x
            10.0.x
      - run: dotnet test Store.slnx -c Release -f ${{ matrix.tfm }}

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.json with the .NET 10 SDK and a rollForward policy.
  • Move TargetFramework and package versions to Directory.Build.props and Directory.Packages.props.
  • Run dotnet list package --outdated and align all Microsoft.* packages on 10.0.x.
  • Search for KnownNetworks, WithOpenApi, WebHostBuilder, and Microsoft.OpenApi.Models.
  • Search for stored SetPropertyCalls expressions used with ExecuteUpdate.
  • 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-transitive and decide how NU190x warnings should fail builds.
  • Replace Debian image tags and re-test apt-get lines on Ubuntu.
  • Decide between .sln and .slnx before someone runs dotnet new sln.
  • Watch database CPU and Query Store for a day after the first deploy.
  • 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.