.NET 11 Preview 6: The Features Grow Up

.NET 11 Preview 6: The Features Grow Up

Preview 5 brought the headliners: Runtime Async, unions, vector search. Preview 6 is where they grow up. If my breakdown of .NET 11 Preview 5's features that actually matter was about the ideas, this post is about those ideas taking production shape. Unions now serialize and show up in your OpenAPI docs. Validation finally learned async after fifteen years of DataAnnotations forcing sync-only hacks. SignalR deletes the token-expiry reconnect dance we've all hand-rolled. And EF Core 11 closes a couple of long-standing raw-SQL escape hatches.

One framing note before we start, because it changes what you do with this post. GA lands in November 2026. That makes Preview 6, not the RCs, the release you should be running your test suites against right now. Waiting for RC is leaving free lead time on the table. Everything below is new since Preview 5. No rehash.

The full details live in the official Preview 6 announcement and the GitHub release notes. This is my filter on what will actually change your code.


Async Validation: DataAnnotations Finally Learns I/O

Here's a design smell we all normalized because the framework gave us no choice: validation logic scattered across handlers. Any rule that needed a database lookup or a remote call couldn't live in DataAnnotations, because IsValid is synchronous and always has been. Unique email? VAT registry check? "Does this SKU exist"? All of it got bolted onto FluentValidation, or worse, smeared across endpoint handlers where nobody reading the model can see it.

.NET 11 Preview 6 fixes the root cause. DataAnnotations goes async, three ways:

  1. AsyncValidationAttribute: derive from it and override IsValidAsync(object? value, ValidationContext context, CancellationToken ct), returning Task<ValidationResult?>.
  2. IAsyncValidatableObject: implement ValidateAsync returning IAsyncEnumerable<ValidationResult> for cross-property async rules.
  3. New static entry points: Validator.ValidateObjectAsync, TryValidateObjectAsync, ValidatePropertyAsync, and ValidateValueAsync when you drive validation yourself.

The money shot is what a real async attribute looks like. Here's the official example, a VAT number check against a remote registry, with the service resolved straight from the validation context via context.GetRequiredService<T>():

public sealed class ValidVatNumberAttribute : AsyncValidationAttribute
{
    public override async Task<ValidationResult?> IsValidAsync(
        object? value, ValidationContext context, CancellationToken ct)
    {
        if (value is not string vatNumber)
            return ValidationResult.Success;

        var registry = context.GetRequiredService<IVatRegistry>();

        return await registry.IsRegisteredAsync(vatNumber, ct)
            ? ValidationResult.Success
            : new ValidationResult($"VAT number '{vatNumber}' is not registered.");
    }

    // Async-only attribute — the sync path is intentionally unsupported.
    public override bool IsValid(object? value) =>
        throw new InvalidOperationException(
            "This attribute validates asynchronously. Use IsValidAsync.");
}

Note the sync IsValid at the bottom. It's abstract too, and the official guidance is exactly this: if your attribute only makes sense asynchronously, throw InvalidOperationException from the sync path. Honest failure beats silently skipping the check.

Here's what makes it a design win rather than just another API. Async and sync attributes compose on the same model. The cheap sync checks and the expensive async one live side by side, in one place, readable at a glance:

public class ReservationRequest
{
    [Required]
    [StringLength(14, MinimumLength = 8)]
    [RegularExpression(@"^[A-Z]{2}[A-Z0-9]+$")]
    [ValidVatNumber]
    public required string VatNumber { get; set; }
}

Minimal APIs run all of it automatically. Call builder.Services.AddValidation() and the framework validates the model, sync and async rules both, before your endpoint ever executes:

builder.Services.AddValidation();

app.MapPost("/reservations",
    (ReservationRequest request) => Results.Ok(request));

Two performance details worth a sentence each. Async attributes on the same member start together, so three remote checks on one property run concurrently, not serially. And items in a collection validate in parallel. Ordering guarantees between member-level, type-level, and IValidatableObject validation are preserved, so the semantics you rely on don't change.

If you're weighing where validation should live in your pipeline, I compared the options in my FastEndpoints vs controllers vs Minimal APIs breakdown. This feature meaningfully strengthens the Minimal APIs column of that comparison.

One more piece that deserves more attention than it will get: Microsoft.Extensions.Options gains async validation too, including at startup via the new IAsyncStartupValidator. If an option needs a network check, say verifying an API key against the provider, your app now refuses to boot with broken configuration instead of failing at 2 AM. Fail-fast config is one of those boring features that quietly saves an on-call rotation.

The 75% math here is simple. One attribute on the model replaces checks scattered across N handlers, a FluentValidation registration, and the mental overhead of remembering where the "real" rules live.


Unions Grow Up: In the Box, in JSON, in Your OpenAPI Docs

Preview 5 introduced union declarations and patterns. I covered the feature itself in the Preview 5 post, so I won't re-explain it here. The catch back then: you had to hand-author the UnionAttribute and IUnion support types yourself. Preview 6 ships System.Runtime.CompilerServices.UnionAttribute and System.Runtime.CompilerServices.IUnion in the framework. Zero boilerplate. You still need <LangVersion>preview</LangVersion>, and I'd be lying if I called the feature settled, but the friction to try it just dropped to nothing:

public record class Dog(string Name);
public record class Cat(int Lives);
public union Pet(Dog, Cat);

static string Describe(Pet pet) => pet switch
{
    Dog(var name) => $"dog: {name}",
    Cat(var lives) => $"cat: {lives}"
};

The bigger Preview 6 story is System.Text.Json support. Unions now serialize and deserialize, and the serializer writes the active case directly. A Pet holding Dog("Rex") becomes {"Name":"Rex"}, and a union of int and string round-trips as plain 42 or "hello". No discriminator envelope forced on you by default. No wrapper object noise.

Under the hood there's a new JsonTypeInfoKind.Union contract kind, plus a customization surface (JsonUnionAttribute, JsonUnionCaseInfo, JsonTypeClassifier, and JsonSerializerOptions.TypeClassifiers) for controlling how cases are discovered and named when the defaults don't fit your wire format. And critically, it works with both the reflection-based serializer and the source generator, so Native AOT isn't left behind.

ASP.NET Core closes the loop: union return types are now described in OpenAPI documents. This is the piece I care about most, and I'll take the position plainly. This is return-type honesty for APIs. Every non-trivial endpoint has always had multiple outcomes: the order, or a validation problem, or a not-found. We've historically modeled that as object, or IResult, or a wrapper DTO hierarchy that lies about what the endpoint can actually return. A union Result(Order, ValidationProblem) return type now serializes correctly and self-documents in your OpenAPI spec. Your API contract finally tells the truth, and your frontend team's generated client knows every case. Good backend types producing honest frontend contracts. That's architecture serving UX.

Preview 6 also lands a batch of language refinements, one line each:

  • Non-public single-parameter constructors are now allowed on case types.
  • The not pattern applies to the union value itself, not the contained value, which matches what you'd intuitively expect.
  • Custom unions inherit generated Create methods properly.
  • A clear compiler error fires when a custom union is missing the required APIs, instead of cryptic downstream failures.

SignalR: Kill the Token-Expiry Reconnect Dance

I've built enough real-time systems (dashboards, chat, live data feeds on Orleans) to have written this exact plumbing more times than I'd like to admit. The access token expires, the connection dies with a 401, the client reconnects, messages get missed in the gap, and the user watches a "reconnecting..." banner flash across their dashboard. Everyone who's shipped SignalR with bearer tokens has hand-rolled some version of this. Preview 6 deletes the whole layer.

SignalR connections can now refresh authentication without dropping. The server exposes a /refresh endpoint alongside /negotiate and reports the token lifetime in the negotiate response. The .NET client re-authenticates before expiry. The connection never drops.

SignalR authentication refresh flow in .NET 11 preventing connection drops on token expiry

Enabling it on the server is one option on MapHub:

app.MapHub<ChatHub>("/chat", options =>
{
    options.EnableAuthenticationRefresh = true;
    // Optional: decide whether a given connection may refresh.
    options.OnAuthenticationRefresh = context => ValueTask.FromResult(true);
});

Your hub can also override OnAuthenticationRefreshedAsync() to react after a refresh. By that point the connection's User has already been updated, so claim changes flow through mid-connection.

On the client, auto-refresh is on by default. The options exist for tuning:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chat")
    .WithAuthenticationRefresh(options =>
    {
        // EnableAutoRefresh is true by default.
        options.RefreshBeforeExpiration = TimeSpan.FromMinutes(1);
        options.OnAuthenticationRefreshed = context => Task.CompletedTask;
        options.OnAuthenticationRefreshFailed = context => Task.CompletedTask;
    })
    .Build();

Honest caveat, because I don't oversell: this is .NET client only for now. JS/TS client support and Azure SignalR Service support are in progress. If your real-time dashboard is a SPA, and let's be honest, most are, you're waiting a bit longer. Test it from your .NET clients and background services today. Watch the JS client for the piece that matters most.

The payoff goes beyond deleted code. Users never see the reconnect flash. The live dashboard just stays live. This is my recurring theme made concrete: good backend plumbing IS the UX. Nobody praises a dashboard for not flickering. They just trust it more.

Second, smaller SignalR win: you can now cancel regular hub invocations from the client. Previously only streaming invocations were cancelable. Now passing a CancellationToken to InvokeAsync on a non-streaming hub method sends a cancellation message, and the hub method's own CancellationToken parameter fires server-side:

using var cts = new CancellationTokenSource();
var work = connection.InvokeAsync("LongRunningWork", cts.Token);
cts.Cancel(); // the server-side token fires — work actually stops

On the hub, it's just a normal token parameter: public async Task LongRunningWork(CancellationToken cancellationToken). No more server-side work grinding on for a client that gave up and navigated away.


EF Core 11: Queries and Models Get Sharper

I called EF Core 11 the star of Preview 5, and Preview 6 keeps compounding. This batch is about closing the escape hatches where you previously had to drop to raw SQL or pretend your database was tidier than it is.

FULL OUTER JOIN lands in LINQ

.NET 10 gave us LeftJoin and RightJoin. Preview 6 completes the set with Queryable.FullJoin. Unmatched rows on either side appear with nulls, which is exactly what you need for reconciliation and diff queries. The classic "which customers have no orders AND which orders have no customer" shape always forced raw SQL. Not anymore:

var result = await context.Customers
    .FullJoin(
        context.Orders,
        c => c.Id,
        o => o.CustomerId,
        (c, o) => new { Customer = c, Order = o })
    .ToListAsync();

The generated SQL is precisely what you'd write by hand:

SELECT ...
FROM [Customers] AS [c]
FULL OUTER JOIN [Orders] AS [o] ON [c].[Id] = [o].[CustomerId]

Another raw-SQL escape hatch, closed.

Keys and indexes on complex-type properties

HasKey, HasAlternateKey, and HasIndex now accept lambdas (and dotted paths) that traverse non-collection complex properties:

modelBuilder.Entity<Customer>()
    .HasIndex(c => c.Address.ZipCode);

modelBuilder.Entity<Order>()
    .HasAlternateKey(o => o.ShippingAddress.Street);

Properties pulled into a key or index are automatically marked required, and SQL Server gains JSON indexes on JSON-mapped columns via the model builder. My read: DDD-style value objects finally become first-class citizens in the relational model instead of second-class mappings you route around. If you're already shaping models carefully, the kind of work I covered in EF Core named query filters for multi-tenant apps, this removes one more place where value objects forced compromises.

Unconstrained foreign keys: EF stops pretending

This one lands squarely in fifteen-years-of-real-databases territory. Real legacy databases and cross-service data don't have FK constraints. The constraint was dropped for bulk-load performance in 2014, or the "principal" lives in another service entirely. EF has always modeled relationships as if the constraint exists. Now it stops pretending. The new IsConstrained(false) tells EF the relationship is logical, not enforced:

modelBuilder.Entity<Order>()
    .HasOne(o => o.Customer)
    .WithMany(c => c.Orders)
    .IsConstrained(false);

The behavior changes follow through correctly. Queries use LEFT JOIN instead of INNER JOIN, because the principal genuinely might not exist, and migrations skip the AddForeignKey call. Fittingly, the Cosmos provider now defaults all non-owned FKs to unconstrained. Document databases never had the constraint to begin with.

Quick hits

  • CASE WHEN x = const THEN NULL patterns now translate to NULLIF. Cleaner SQL for a common projection shape.
  • List<T>.Exists translates to an SQL EXISTS subquery instead of throwing.
  • SQL Server index changes in migrations now emit CREATE INDEX ... WITH (DROP_EXISTING = ON), so the old index stays available while the replacement builds.
  • Detached entries are now held weakly in the change tracker. A real memory-leak fix for long-lived DbContext instances.
  • TimeOnly members now translate on SQLite.

That DROP_EXISTING one is sneaky-important for anyone running migrations against hot production databases. The gap between "index dropped" and "index rebuilt" used to be a window where every query fell off a plan cliff. Now there's no gap.


The Verdict: Test Against This One

Preview 5 introduced the ideas. Preview 6 made them shippable-shaped. Unions serialize, self-document, and need zero boilerplate. Validation does I/O. SignalR connections survive token expiry. EF speaks FULL OUTER JOIN and admits your database has no FK constraints.

GA is roughly four months out, November 2026. Here's my concrete ask: install Preview 6 side-by-side, run your test suite, and try three specific things. (a) Convert one FluentValidation-heavy endpoint to async DataAnnotations and see how much handler code disappears. (b) Put one union return type on a real endpoint and look at what it does to your OpenAPI doc. (c) Enable SignalR auth refresh in a staging environment and watch the reconnect noise vanish from your logs.

Each of those is a layer of hand-rolled plumbing you get to delete. That's the whole point.

I'll cover Preview 7 and the RCs when they land, so subscribe or check back. The full changelog lives in the official announcement and the GitHub release notes. Tell me what breaks. Or better, tell me the first layer of plumbing you deleted.