cd /news/developer-tools/upgrading-a-production-api-from-net-… · home › topics › developer-tools › article
[ARTICLE · art-163940] src=blog.devgenius.io ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Upgrading a Production API from .NET 8 to .NET 10: What Broke

A production ASP.NET Core Web API was moved from .NET 8 to .NET 10 LTS ahead of the November 10, 2026 end-of-support date for both .NET 8 and .NET 9, and the migration surfaced mostly loud build errors plus two silent failures, according to a Softinbit Technology case study. The loud breaks included obsolete IPNetwork, Swashbuckle 10 and EF Core 10 ExecuteUpdate changes, while the silent ones were a missing exception log that muted an error-rate alert and 401 responses where tests expected cookie redirects. The case study, which states the company, people and details are invented, pins SDK 10.0.100 in global.json, targets net10.0 via Directory.Build.props and centralizes package versions including Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14, Microsoft.EntityFrameworkCore.SqlServer 10.0.14 and Swashbuckle.AspNetCore 10.3.1, the latest as of October 25, 2026, when .NET 10.0.14 and 8.0.33 shipped as an out-of-band security update.

by read10 min views2 publishedNov 4, 2026

Note: this article was drafted with AI assistance and reviewed by Softinbit Technology before publishing. It is part of the series The .NET Field Guide*.*

TL;DR — .NET 8 and .NET 9 both lose support on November 10, 2026. This case study moves a production API straight to .NET 10 LTS and walks through every break the team hit. — Most breaks were loud build errors (obsolete IPNetwork, Swashbuckle 10, EF Core 10 ExecuteUpdate). Two were silent: a missing exception log that muted an alert, and 401s where tests expected cookie redirects. - You get before/after code for each fix, a temporary net8.0/net10.0 CI matrix, and a checklist to run on your own project.

Level: Advanced · Prerequisites: ASP.NET Core Web API and EF Core experience, basic Docker and CI · Reading time: 13 min

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.

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:

Skipping .NET 9 was an easy call, because it loses support on the same day as .NET 8. Waiting for .NET 11 made no sense either: it is expected in November 2026 as a standard-term support (STS) release, with two years of patches instead of the three an LTS gets. The plan had five mechanical steps before any code changes.

Pin the SDK in global.json at the repository root:

{  "sdk": {    "version": "10.0.100",    "rollForward": "latestFeature"  }}

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

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

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:

<Project>  <PropertyGroup>    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>  </PropertyGroup>  <ItemGroup>    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.14" />    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />    <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.3.1" />  </ItemGroup></Project>

The versions above were the latest on October 25, 2026, when .NET 10.0.14 and 8.0.33 shipped as an out-of-band security update. Take the newest patch on the day you upgrade, not the one in an article.

Finally, change the container base image tags from 8.0-bookworm-slim to the .NET 10 images. That step broke too.

The breaks arrived in three waves.

At build time:

At image build time:

On staging:

The team grouped build errors by code first, so they could fix one category at a time:

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:

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

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:

curl -i https://localhost:7001/api/admin/reports/daily-sales
HTTP/1.1 401 UnauthorizedLocation: 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.

Every symptom mapped to a documented breaking change in .NET 10 or in a package upgraded with it:

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.

Before (.NET 8):

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:

// 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.

The team decided the handler should own its logging, so the alert no longer depends on middleware internals:

// File: Infrastructure/GlobalExceptionHandler.csusing 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:

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

// File: tests/Store.Api.Tests/BackOfficeAuthTests.csusing 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 });
js
        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:

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

The Models namespace is gone, Type is a JsonSchemaType flags enum, and security requirements reference schemes through the document. Before (Swashbuckle 6):

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

After (Swashbuckle 10):

// 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)] = []    });});
// File: OpenApi/CorrelationIdHeaderFilter.csusing 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.

The stale-order job built its setters as a reusable expression. Before (EF Core 8):

private static Expression<Func<SetPropertyCalls<Order>, SetPropertyCalls<Order>>> CancelSetters() =>    s => s.SetProperty(o => o.Status, OrderStatus.Cancelled);
js
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:

// File: Orders/StaleOrderCanceller.csusing 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;
js
        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:

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

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:

<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.

dotnet sln Store.sln migrate        # writes Store.slnx; update CI paths afterwardsdotnet new sln --format sln         # only if a tool in your pipeline cannot read .slnx yet
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /srcCOPY . .RUN dotnet publish src/Store.Api/Store.Api.csproj -c Release -o /app/publish

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.

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:

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">  <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.33" /></ItemGroup><ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">  <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.14" /></ItemGroup>

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

jobs:  test:    runs-on: ubuntu-latest    strategy:      matrix:        tfm: [net8.0, net10.0]    steps:      - uses: actions/checkout@v7      - uses: actions/setup-dotnet@v6        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.

Store.Api went to production on .NET 10 two 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.

Originally published at www.softinbit.com*.*

Get the next articles by email: subscribe to the newsletter*.*

Upgrading a Production API from .NET 8 to .NET 10: What Broke was originally published in Dev Genius on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #developer-tools 4 stories · sorted by recency
── more on @softinbit technology 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/upgrading-a-producti…] indexed:0 read:10min 2026-11-04 · —