{"slug": "upgrading-a-production-api-from-net-8-to-net-10-what-broke", "title": "Upgrading a Production API from .NET 8 to .NET 10: What Broke", "summary": "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.", "body_md": "*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**.*\n\n***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.*\n\n***Level:*** *Advanced ·* ***Prerequisites:*** *ASP.NET Core Web API and EF Core experience, basic Docker and CI ·* ***Reading time:*** *13 min*\n\n*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.*\n\nThe 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.\n\nKerem 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.\n\nStore.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.\n\nThe support dates, from the official .NET support policy page:\n\nSkipping .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.\n\nPin the SDK in global.json at the repository root:\n\n```\n{  \"sdk\": {    \"version\": \"10.0.100\",    \"rollForward\": \"latestFeature\"  }}\n```\n\nChange the target framework in one place, Directory.Build.props:\n\n```\n<Project>  <PropertyGroup>    <TargetFramework>net10.0</TargetFramework>    <Nullable>enable</Nullable>    <ImplicitUsings>enable</ImplicitUsings>    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>  </PropertyGroup></Project>\n```\n\nList what is outdated, then align every Microsoft.* package on the same major version:\n\n```\ndotnet list package --outdated# .NET 10 SDK also accepts the noun-first form:dotnet package list --outdated --format json > outdated.json\n```\n\nKeep the versions in Directory.Packages.props so no project can drift:\n\n```\n<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>\n```\n\nThe 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.\n\nFinally, change the container base image tags from 8.0-bookworm-slim to the .NET 10 images. That step broke too.\n\nThe breaks arrived in three waves.\n\n**At build time:**\n\n**At image build time:**\n\n**On staging:**\n\nThe team grouped build errors by code first, so they could fix one category at a time:\n\n```\ndotnet build -c Release 2>&1 | grep -oE \"(error|warning) [A-Z]+[0-9]+\" | sort | uniq -c\n```\n\nFor the vulnerability warnings, they listed vulnerable transitive packages and asked NuGet which top-level package pulled each one in:\n\n```\n(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 $_ }\n```\n\nThe 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:\n\n```\nfail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]      An unhandled exception has occurred while executing the request.      System.InvalidOperationException: Pricing service returned null\n```\n\nOn .NET 10 the 500 response was identical, but that log line never appeared. The handler returned true, and that now means \"handled, record nothing\".\n\nThe 401 was reproducible from a terminal:\n\n```\ncurl -i https://localhost:7001/api/admin/reports/daily-sales\nHTTP/1.1 401 UnauthorizedLocation: https://localhost:7001/admin/login?ReturnUrl=%2Fapi%2Fadmin%2Freports%2Fdaily-sales\n```\n\nThe Location header was still there. Only the status code had changed.\n\nFor 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.\n\nEvery symptom mapped to a documented breaking change in .NET 10 or in a package upgraded with it:\n\nTwo 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.\n\nBefore (.NET 8):\n\n``` js\nbuilder.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));});\n```\n\nAfter (.NET 10), using System.Net.IPNetwork:\n\n```\n// 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\"));});\n```\n\nThe fully qualified name avoids a clash with the obsolete Microsoft.AspNetCore.HttpOverrides.IPNetwork.\n\nThe team decided the handler should own its logging, so the alert no longer depends on middleware internals:\n\n```\n// File: Infrastructure/GlobalExceptionHandler.csusing Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;\nnamespace Store.Api.Infrastructure;\npublic 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);\nhttpContext.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.\"            }        });    }}\n```\n\nThey 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:\n\n```\n// Program.cs, in the pipeline after builder.Build()app.UseExceptionHandler(new ExceptionHandlerOptions{    SuppressDiagnosticsCallback = _ => false});\n```\n\nFor [ApiController] endpoints, 401 and 403 are the right answers, so the team fixed the test, not the code:\n\n```\n// File: tests/Store.Api.Tests/BackOfficeAuthTests.csusing System.Net;using Microsoft.AspNetCore.Mvc.Testing;using Xunit;\nnamespace Store.Api.Tests;\npublic 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 });\njs\n        var response = await client.GetAsync(\"/api/admin/reports/daily-sales\");\nAssert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);        Assert.StartsWith(\"/admin/login\", response.Headers.Location?.AbsolutePath);    }}\n```\n\nThe 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:\n\n```\n[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\");\n```\n\nThe Models namespace is gone, Type is a JsonSchemaType flags enum, and security requirements reference schemes through the document. Before (Swashbuckle 6):\n\n```\nusing Microsoft.OpenApi.Models;\noptions.AddSecurityRequirement(new OpenApiSecurityRequirement{    {        new OpenApiSecurityScheme        {            Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = \"Bearer\" }        },        Array.Empty<string>()    }});\n```\n\nAfter (Swashbuckle 10):\n\n```\n// 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)] = []    });});\n// File: OpenApi/CorrelationIdHeaderFilter.csusing Microsoft.OpenApi;using Swashbuckle.AspNetCore.SwaggerGen;\nnamespace Store.Api.OpenApi;\npublic 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\" }        });    }}\n```\n\nThe old filter set Type = \"string\", which no longer compiles. Swashbuckle 10 still writes OpenAPI 3.0 unless a project opts in to 3.1.\n\nThe stale-order job built its setters as a reusable expression. Before (EF Core 8):\n\n```\nprivate static Expression<Func<SetPropertyCalls<Order>, SetPropertyCalls<Order>>> CancelSetters() =>    s => s.SetProperty(o => o.Status, OrderStatus.Cancelled);\njs\nawait db.Orders    .Where(o => o.Status == OrderStatus.Pending && o.CreatedAtUtc < cutoffUtc)    .ExecuteUpdateAsync(CancelSetters(), ct);\n```\n\nEF 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:\n\n```\n// File: Orders/StaleOrderCanceller.csusing Microsoft.EntityFrameworkCore;using Store.Api.Data;\nnamespace Store.Api.Orders;\npublic sealed class StaleOrderCanceller(AppDbContext db, TimeProvider clock){    public Task<int> CancelAsync(TimeSpan maxAge, string? reason, CancellationToken ct)    {        var cutoffUtc = clock.GetUtcNow().UtcDateTime - maxAge;\njs\n        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);    }}\n```\n\nInline chained lambdas such as s => s.SetProperty(...).SetProperty(...) keep compiling. Only stored expression trees break.\n\nFor the new collection translation, the team kept the default and watched Query Store. If one query regresses, change that query only:\n\n``` js\nvar orders = await db.Orders    .Where(o => EF.Parameter(orderIds).Contains(o.Id)) // single JSON parameter, the EF Core 8 and 9 shape    .ToListAsync(ct);\n```\n\nThe global switch is UseSqlServer(connectionString, o => o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter)).\n\nThey 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:\n\n```\n<PropertyGroup>  <WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904;$(WarningsNotAsErrors)</WarningsNotAsErrors></PropertyGroup>\n```\n\nA nightly job still fails on any audit warning, so nobody can ignore them for long.\n\n```\ndotnet 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\nFROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /srcCOPY . .RUN dotnet publish src/Store.Api/Store.Api.csproj -c Release -o /app/publish\n# 10.0 is Ubuntu 24.04 \"Noble\"; there is no Debian variant for .NET 10FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS finalWORKDIR /appCOPY --from=build /app/publish .USER $APP_UIDENTRYPOINT [\"dotnet\", \"Store.Api.dll\"]\n```\n\nPort 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.\n\nFor 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:\n\n```\n<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>\n```\n\nCode that differs goes behind #if NET10_0_OR_GREATER. The workflow runs each framework as its own job:\n\n```\njobs:  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 }}\n```\n\nWhile multi-targeting, every EF Core tools command needs the framework: dotnet ef migrations add AddCancelReason --framework net10.0.\n\nStore.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.\n\n*Originally published at* *www.softinbit.com**.*\n\n*Get the next articles by email:* *subscribe to the newsletter**.*\n\n[Upgrading a Production API from .NET 8 to .NET 10: What Broke](https://blog.devgenius.io/upgrading-a-production-api-from-net-8-to-net-10-what-broke-918b3cf500f5) was originally published in [Dev Genius](https://blog.devgenius.io) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/upgrading-a-production-api-from-net-8-to-net-10-what-broke", "canonical_source": "https://blog.devgenius.io/upgrading-a-production-api-from-net-8-to-net-10-what-broke-918b3cf500f5?source=rss----4e2c1156667e---4", "published_at": "2026-11-04 16:15:46+00:00", "updated_at": "2026-11-04 16:49:39.080063+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Softinbit Technology", ".NET 10", ".NET 8", ".NET 9", "ASP.NET Core", "Entity Framework Core", "Swashbuckle", "Microsoft"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/upgrading-a-production-api-from-net-8-to-net-10-what-broke", "markdown": "https://wpnews.pro/news/upgrading-a-production-api-from-net-8-to-net-10-what-broke.md", "text": "https://wpnews.pro/news/upgrading-a-production-api-from-net-8-to-net-10-what-broke.txt", "jsonld": "https://wpnews.pro/news/upgrading-a-production-api-from-net-8-to-net-10-what-broke.jsonld"}}