Mert Özen Further With Every Line
Backend .NET Core Web API — #34

What's New in ASP.NET Core with .NET 8: Keyed DI, IExceptionHandler, Identity API and Native AOT

Mert Özen Nov 4, 2026 9 min 1 views
What's New in ASP.NET Core with .NET 8: Keyed DI, IExceptionHandler, Identity API and Native AOT

Keyed DI, IExceptionHandler, MapIdentityApi, ShortCircuit, TimeProvider, Native AOT and the minimal API template in .NET 8, compared with .NET 6, plus C# 12, migration notes and the November 10, 2026 end of support.

Version note: This post covers what .NET 8 introduced; the code targets .NET 8.

.NET 8: The Most Common LTS in the Field

.NET 8, released on November 14, 2023, is the first LTS release after .NET 6. In JetBrains' 2025 survey, more than half of the respondents said they use .NET 8, so an API you inherit today is very likely running on it. On top of the steps .NET 7 took for minimal APIs, this release touches DI, error handling, authentication and performance. C# 12 shipped in the same package.

In this post we'll look at keyed DI, IExceptionHandler, Identity API endpoints, short-circuit routing, TimeProvider, Native AOT and the template change, compared with .NET 6. At the end there are migration notes and an important reminder about the support date.

Guess First

You register two implementations of the same interface one after the other: first EmailNotificationService, then SmsNotificationService. An endpoint asks for an INotificationService parameter. Which one does it get: the first registered, the last registered, or an exception?

Keyed DI and [FromKeyedServices]

The answer: the last one registered. When I ran this code on .NET 6, the /ship endpoint returned "SMS sent for order 42". If you needed the email one, you didn't have many options:

builder.Services.AddSingleton<INotificationService, EmailNotificationService>();
builder.Services.AddSingleton<INotificationService, SmsNotificationService>();

var app = builder.Build();

app.MapPost("/api/orders/{id:int}/ship", (int id, INotificationService notifier) =>
    Results.Ok(notifier.Send(id)));

app.MapPost("/api/orders/{id:int}/confirm", (int id, IEnumerable<INotificationService> notifiers) =>
{
    // We have to pick the right implementation by looking at its type
    var email = notifiers.OfType<EmailNotificationService>().Single();
    return Results.Ok(email.Send(id));
});

Asking for the whole list and filtering by concrete type defeats the point of using an interface. The alternatives were a factory class or a third-party container such as Autofac. .NET 8 moved this into the built-in container:

builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");

// Minimal API: the key goes on the parameter
app.MapPost("/api/orders/{id:int}/ship", (int id, [FromKeyedServices("sms")] INotificationService notifier) =>
    TypedResults.Ok(notifier.Send(id)));

// Controller: the key goes on the constructor parameter
[ApiController]
[Route("api/orders")]
public class OrdersController([FromKeyedServices("email")] INotificationService notifier) : ControllerBase
{
    [HttpPost("{id:int}/confirm")]
    public IActionResult Confirm(int id)
    {
        return Ok(notifier.Send(id));
    }
}

/ship now returns SMS sent for order 42 and /confirm returns Email sent for order 42. There are also AddKeyedScoped and AddKeyedTransient; the lifetime rules are the same as the ones we saw earlier. The key doesn't have to be a string, it can be any object; using an enum is good insurance against typos.

C# 12: Primary Constructors and Collection Expressions

If the controller's declaration caught your eye, that's C# 12's primary constructor. The parameter list next to the class name is the constructor itself; the private readonly field and assignment lines we wrote in .NET 6 are gone. Here's the same feature on a service and an exception class:

public class ProductNotFoundException(int id) : Exception($"Product {id} was not found.");

public class InMemoryProductService : IProductService
{
    // C# 12 collection expression
    private readonly List<Product> _products =
    [
        new() { Id = 1, Name = "Mechanical Keyboard", Price = 1499.90m },
        new() { Id = 2, Name = "Wireless Mouse", Price = 649.00m }
    ];

    public Product GetById(int id) =>
        _products.FirstOrDefault(p => p.Id == id) ?? throw new ProductNotFoundException(id);
}

The square bracket [...] syntax gives arrays and collections such as List<T> and Span<T> a common notation; there's no need to write new List<Product> { ... }. Keep in mind that primary constructor parameters don't turn into properties the way they do in a record: they stay as parameters usable only inside the class.

Error Handling with IExceptionHandler

In the global error handling post we wrote our own middleware to map domain exceptions to status codes. .NET 8 introduced a contract for this:

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Api;

public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        // Leave exceptions we don't recognize to the next handler
        if (exception is not ProductNotFoundException)
        {
            return false;
        }

        logger.LogWarning("Handled: {Message}", exception.Message);

        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = StatusCodes.Status404NotFound,
            Title = "Product not found",
            Detail = exception.Message
        }, options: null, contentType: "application/problem+json", cancellationToken);

        return true;
    }
}

// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();

If TryHandleAsync returns true, the work is done. If it returns false, the next handler is tried; you can register more than one handler, and they run in registration order. If none of them takes it, the default 500 response from AddProblemDetails kicks in. The results of two requests:

GET /api/products/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"title":"Product not found","status":404,"detail":"Product 99 was not found."}

GET /boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500}

Because the handler is a service, it gets constructor injection, it's testable, and it doesn't make you think about middleware order.

Identity API Endpoints

In .NET 6, when you wanted to write login for a SPA or a mobile app, you either set up JWT issuing yourself or reached for a heavy solution like IdentityServer. .NET 8 added ready-made endpoints that speak JSON to ASP.NET Core Identity:

public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbContext<IdentityUser>(options)
{
}

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("ProductCatalog"));
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();

app.MapGroup("/account").MapIdentityApi<IdentityUser>();

app.MapGet("/api/me", (ClaimsPrincipal user) => TypedResults.Ok(user.Identity!.Name))
    .RequireAuthorization();

I used the InMemory provider in the example; a real database and migrations are a topic for the EF Core series. In one line, MapIdentityApi opens /register, /login, /refresh, email confirmation, password reset and two-factor authentication endpoints. I tried it:

POST /account/register  {"email":"ada@example.com","password":"Passw0rd!"}
HTTP/1.1 200 OK

POST /account/register  {"email":"alan@example.com","password":"123"}
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PasswordTooShort":["Passwords must be at least 6 characters."],"PasswordRequiresNonAlphanumeric":["Passwords must have at least one non alphanumeric character."],"PasswordRequiresLower":["Passwords must have at least one lowercase ('a'-'z')."],"PasswordRequiresUpper":["Passwords must have at least one uppercase ('A'-'Z')."]}}

POST /account/login  {"email":"ada@example.com","password":"Passw0rd!"}
{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFC...","expiresIn":3600,"refreshToken":"..."}

/api/me without a token returned 401; with an Authorization: Bearer ... header it returned "ada@example.com". A login with the wrong password returned 401 and "detail":"Failed".

ShortCircuit and TimeProvider

Some requests don't deserve to go through the whole pipeline. For the favicon.ico that browsers request automatically, or the liveness endpoint a load balancer hits every second, running authentication, logging and the rest of the middleware is wasted work. With .NET 8 you can cut them off at the routing level:

app.MapShortCircuit(404, "robots.txt", "favicon.ico");
app.MapGet("/health/live", () => "Healthy").ShortCircuit();

The favicon.ico request returned a 404 with no body, and /health/live returned Healthy. The response is written as soon as the endpoint matches, and the middleware after that never runs.

The second small but valuable addition is TimeProvider. When you wrote a time-based rule with DateTime.UtcNow, testing it meant actually waiting until 8 PM. In .NET 8 you take time as a dependency too:

public class FlashSaleService(TimeProvider timeProvider)
{
    private static readonly TimeSpan SaleStart = TimeSpan.FromHours(20);
    private static readonly TimeSpan SaleEnd = TimeSpan.FromHours(22);

    public bool IsActive()
    {
        var now = timeProvider.GetUtcNow().TimeOfDay;
        return now >= SaleStart && now < SaleEnd;
    }
}

// Program.cs
builder.Services.AddSingleton(TimeProvider.System);

// Test: Microsoft.Extensions.TimeProvider.Testing package
var time = new FakeTimeProvider(new DateTimeOffset(2024, 11, 29, 20, 30, 0, TimeSpan.Zero));
var sale = new FlashSaleService(time);
Assert.True(sale.IsActive());

time.Advance(TimeSpan.FromHours(2));
Assert.False(sale.IsActive());

The test passed. With FakeTimeProvider.Advance you skip two hours in one line.

The Template Change and Native AOT

With the .NET 8 SDK, dotnet new webapi now produces minimal APIs by default; in .NET 6 and 7 you got a project with controllers. If you want the controller template, you have to pass the option explicitly:

dotnet new webapi -o ProductCatalog.Api
dotnet new webapi --use-controllers -o ProductCatalog.Api

Nothing changes on the Swagger side; both templates still include Swashbuckle.AspNetCore (6.6.2). This is the last time that's true.

The more radical addition is Native AOT support. The new webapiaot template compiles the app into a single native file that doesn't need a JIT:

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});

var app = builder.Build();

Product[] products =
[
    new(1, "Mechanical Keyboard", 1499.90m),
    new(2, "Wireless Mouse", 649.00m)
];

var api = app.MapGroup("/api/products");
api.MapGet("/", () => products);
api.MapGet("/categories", () => new Category(1, "Peripherals"));

app.Run();

public record Product(int Id, string Name, decimal Price);

public record Category(int Id, string Name);

// No reflection under AOT: every type that goes into JSON must be listed here
[JsonSerializable(typeof(Product[]))]
[JsonSerializable(typeof(Category))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

CreateSlimBuilder is a lightweight builder that leaves out pieces that are incompatible with AOT or rarely needed (such as IIS integration, HTTPS and regex route constraints). With <PublishAot>true</PublishAot> in the csproj, running dotnet publish -c Release on macOS arm64 produced a single executable of about 11 MB. The price is the restrictions: most of EF Core, MVC controllers and libraries that rely on reflection don't work with AOT. So consider AOT for small services that need to start fast.

Moving to .NET 8

The TargetFramework value goes to net8.0, and the Microsoft.AspNetCore.*, Microsoft.Extensions.* and EF Core packages move to the 8.0 line; the last patch is 8.0.33. If you're coming from 6, you also take on 7's breaking changes. The items in the ASP.NET Core 8 list that matter most for a Web API are these:

context.SecurityToken in JWT bearer events is no longer a JwtSecurityToken but a JsonWebToken. Code that casts with as JwtSecurityToken inside OnTokenValidated silently starts getting null; change the cast to JsonWebToken or go back to the old behavior with UseSecurityTokenValidators = true. In container images, ASP.NET Core's default port moved from 80 to 8080, and a non-root app user was added to the Linux images; check the port mappings from the Docker post accordingly. UseRateLimiter no longer works without AddRateLimiter, and UseHttpLogging no longer works without AddHttpLogging. And in minimal APIs, IFormFile parameters now require an antiforgery check.

On the support side there's an urgent note. .NET 8 got three years of support as an LTS, and that support ends on November 10, 2026, the same day as .NET 9. I'm writing these lines in the first week of November 2026, which means projects still on .NET 8 stop receiving security patches within days. After that date, the only supported GA LTS release is .NET 10. If you're on 8, your plan should be moving to 10; 9 isn't even a stopover, because its support ends on the same day.

Common Pitfalls

The sneakiest bug under AOT is forgetting to add a type to the JsonSerializerContext. I removed the Category line from the example above and tried it. With a normal dotnet run everything worked, because in JIT mode the serializer falls back to reflection. After the AOT publish, though, not just /categories but every endpoint, /api/products included, returned 500, and the log said: System.NotSupportedException: JsonTypeInfo metadata for type 'Category' was not provided by TypeInfoResolver of type '[AppJsonSerializerContext]'. The build didn't produce a single warning. Don't ship AOT to production without actually publishing and testing it in CI.

Forgetting [FromKeyedServices] in keyed DI also gives an interesting result. With only keyed registrations, when I asked for a plain INotificationService parameter in a minimal API, the framework couldn't find it in DI and tried to read it from the body: a 400 for a request without a body, and a 500 with a Deserialization of interface types is not supported exception when I sent {}.

The token MapIdentityApi gives you isn't a JWT. The access token I got was a 710-character opaque value without a single dot in it, protected by ASP.NET Core's Data Protection. That means another service can't validate this token on its own. If more than one service needs to trust the same token, you need the approach from the JWT post or a separate identity provider.

Finally, the logging behavior of IExceptionHandler. In .NET 8, even exceptions for which the handler returns true are logged at the fail level by the exception handler middleware; my log had this line first, then the handler's own warning. Seeing an error log for an expected 404 can trigger alerting systems for no reason.

A Small Experiment

★☆☆ Write a third implementation of INotificationService (PushNotificationService), register it with the "push" key and use it in an endpoint. Define the keys with an enum instead of strings and see the difference.

★★☆ Next to GlobalExceptionHandler, add a second IExceptionHandler that turns ArgumentExceptions into 400s. Register the two handlers in different orders and watch in the log which one runs first.

★★★ Build a small product API with the webapiaot template and get a native output with dotnet publish -c Release. Then remove a type from the JsonSerializerContext, run it first with dotnet run, then with the published file, and see the difference for yourself.

With .NET 8 the template moved to minimal APIs, but Swagger still came from Swashbuckle. In the next post we'll look at .NET 9: Swashbuckle's removal from the template, the built-in AddOpenApi and MapOpenApi, Scalar as a documentation UI, HybridCache, and STS support growing to 24 months.