What's New in .NET 11: Async Validation, OpenAPI 3.2 and C# 15 Union Types in ASP.NET Core
.NET 11 STS brings async validation and localized messages to minimal APIs, [ShortCircuit], C# 15 union types, OpenAPI 3.2 with QUERY and itemSchema, removed APIs and support until 2028-11-14.
Version note: This post covers what .NET 11 introduced; the code targets .NET 11.
Guess First
When adding a product, we want its SKU (Stock Keeping Unit) to be unique. We put the rule into an attribute: [UniqueSku] runs an async query against the database. The same request DTO (Data Transfer Object) is used by a minimal API endpoint and by an [ApiController] controller:
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
// ...
app.MapPost("/products", (CreateProductRequest request) =>
TypedResults.Created($"/products/42", new ProductDto(42, request.Name, request.Sku, request.Price)));
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public ActionResult<ProductDto> Create(CreateProductRequest request) =>
CreatedAtAction(nameof(Create), new ProductDto(42, request.Name, request.Sku, request.Price));
}
public class CreateProductRequest
{
[Required, StringLength(100)]
public string Name { get; set; } = "";
[Required, UniqueSku]
public string Sku { get; set; } = "";
[Range(0.01, 100000)]
public decimal Price { get; set; }
}
A product with SKU MS-002 already exists in the database. If you POST the body {"name":"Wireless Mouse","sku":"MS-002","price":29.90} to both endpoints, which status codes come back? Is it 400 for both?
.NET 11, released on November 10, 2026, is an STS (Standard-Term Support) release. In this post we look only at the part that matters to Web API developers: async validation and localized validation messages, endpoint filters that see binding failures, the [ShortCircuit] attribute, C# 15 union types, OpenAPI 3.2 and the breaking changes waiting for you during the upgrade. Blazor, SignalR and MAUI are out of scope here.
Async Validation: A Rule That Asks the Database
In the model validation post we derived our own attribute from ValidationAttribute. Its IsValid was synchronous; if you wanted to hit the database you either blocked a thread or moved the rule into the service layer. .NET 11 adds AsyncValidationAttribute to Data Annotations:
public sealed class UniqueSkuAttribute : AsyncValidationAttribute
{
// The rule is async-only; the sync path fails on purpose
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =>
throw new InvalidOperationException("UniqueSku only validates asynchronously.");
protected override async Task<ValidationResult?> IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken)
{
IProductRepository products = validationContext.GetRequiredService<IProductRepository>();
if (value is string sku && await products.SkuExistsAsync(sku, cancellationToken))
{
return new ValidationResult($"SKU '{sku}' is already in use.", [validationContext.MemberName!]);
}
return ValidationResult.Success;
}
}
public interface IProductRepository
{
Task<bool> SkuExistsAsync(string sku, CancellationToken cancellationToken);
}
public class InMemoryProductRepository : IProductRepository
{
private readonly HashSet<string> _skus = ["KB-001", "MS-002"];
public async Task<bool> SkuExistsAsync(string sku, CancellationToken cancellationToken)
{
// Simulates a real database query
await Task.Delay(50, cancellationToken);
return _skus.Contains(sku);
}
}
When I tried to compile this attribute in a project targeting net10.0 I got CS0234; the class does not exist in .NET 10. The synchronous IsValid is abstract too, so you have to override it. If the rule only works asynchronously, Learn recommends throwing from there, so that a synchronous validator cannot skip the rule silently. In a minimal API, AddValidation() calls the async path itself:
$ curl -i -X POST localhost:5111/products -H "Content-Type: application/json" -d '{"name":"Wireless Mouse","sku":"MS-002","price":29.90}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Sku":["SKU 'MS-002' is already in use."]},"traceId":"00-ae3645e3bc2a62c62115473e9dfa81b4-8ba4b6aba01fca4d-02"}
The first half of the guess holds. The controller, however, returned this (output shortened):
$ curl -i -X POST localhost:5111/api/products -H "Content-Type: application/json" -d '{"name":"Wireless Mouse","sku":"MS-002","price":29.90}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"System.InvalidOperationException","status":500,"detail":"UniqueSku only validates asynchronously.", ...}
MVC model validation is still synchronous in .NET 11 and calls the attribute's synchronous IsValid; our exception turned into a 500. For now, async validation applies to the side that uses Microsoft.Extensions.Validation, which means minimal APIs. For rules that look at several properties there is IAsyncValidatableObject; ValidateAsync produces its results as an IAsyncEnumerable<ValidationResult>. The framework runs validators concurrently where it can: async attributes on the same member start together, and collection items are validated in parallel.
Validation Messages Can Now Be Localized
In .NET 10 the messages from AddValidation() were fixed. In .NET 11, if you also call AddLocalization(), messages and display names are resolved through IStringLocalizer; no extra package is needed. Messages can come from resx files, but if you register your own IStringLocalizerFactory they can come from a database or JSON as well. A dictionary was enough for a test:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Localization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IStringLocalizerFactory, DictionaryStringLocalizerFactory>();
builder.Services.AddLocalization();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseRequestLocalization("en-US", "tr-TR");
app.MapPost("/products", (CreateProductRequest request) => TypedResults.Ok(request));
app.Run();
public class CreateProductRequest
{
[Required]
public string Name { get; set; } = "";
[Range(0.01, 100000)]
public decimal Price { get; set; }
}
public class DictionaryStringLocalizerFactory : IStringLocalizerFactory
{
public IStringLocalizer Create(Type resourceSource) => new DictionaryStringLocalizer();
public IStringLocalizer Create(string baseName, string location) => new DictionaryStringLocalizer();
}
public class DictionaryStringLocalizer : IStringLocalizer
{
// Turkish messages; {0} is the field name, {1} and {2} are the Range bounds
private static readonly Dictionary<string, string> Turkish = new()
{
["RequiredAttribute_Error"] = "{0} alanı zorunludur.",
["RangeAttribute_Error"] = "{0} alanı {1} ile {2} arasında olmalıdır.",
};
public LocalizedString this[string name]
{
get
{
bool found = Thread.CurrentThread.CurrentUICulture.Name == "tr-TR" && Turkish.ContainsKey(name);
return new LocalizedString(name, found ? Turkish[name] : name, resourceNotFound: !found);
}
}
public LocalizedString this[string name, params object[] arguments] =>
new(name, string.Format(this[name].Value, arguments), this[name].ResourceNotFound);
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) =>
Turkish.Select(pair => new LocalizedString(pair.Key, pair.Value));
}
Because we did not set ErrorMessage on the attribute, localization tries the keys {DeclaringType}_{MemberName}_{AttributeType}_Error, {DeclaringType}_{AttributeType}_Error and {AttributeType}_Error, from most to least specific. We filled in the most general one, so every [Required] in the app became Turkish with a single line:
$ curl -X POST localhost:5130/products -H "Accept-Language: en-US" -H "Content-Type: application/json" -d '{"name":"","price":0}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-73b5c57553c5392018c3d25b25c78a38-fd1e478d32dcf931-02"}
$ curl -X POST localhost:5130/products -H "Accept-Language: tr-TR" -H "Content-Type: application/json" -d '{"name":"","price":0}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["Name alanı zorunludur."],"Price":["Price alanı 0,01 ile 100000 arasında olmalıdır."]},"traceId":"00-5efeb75403f01868d6b9b35df319ff18-4c9e4532975af61d-02"}
When a key is not found, validation falls back to the attribute's own message; that is why the English request came back as before. Note that 0,01 was also formatted for the culture.
Endpoint Filters and [ShortCircuit]
We met endpoint filters in the .NET 7 post. Learn's .NET 11 list has this item: if an endpoint has filters, the filter pipeline runs even when parameter binding fails, so a filter can see the 400 and write its own body. Think of the paging parameters from the pagination post:
builder.Services.Configure<RouteHandlerOptions>(options => options.ThrowOnBadRequest = false);
// ...
app.MapGet("/products", (int page, int pageSize) =>
TypedResults.Ok(new { page, pageSize }))
.AddEndpointFilter(async (context, next) =>
{
// If binding failed, the status code is already 400
if (context.HttpContext.Response.StatusCode == StatusCodes.Status400BadRequest)
{
app.Logger.LogWarning("Binding failed for {Path}{Query}", context.HttpContext.Request.Path, context.HttpContext.Request.QueryString);
return TypedResults.Problem(
title: "Invalid paging parameters.",
detail: "page and pageSize must be integers.",
statusCode: StatusCodes.Status400BadRequest);
}
return await next(context);
});
$ curl -i "localhost:5111/products?page=abc&pageSize=20"
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid paging parameters.","status":400,"detail":"page and pageSize must be integers.","traceId":"00-9308a63ef575c391d4a62b9ad7e51a90-ab025b5d9ebc6507-02"}
An honest note here: when I ran the same code on the .NET 10.0.13 runtime, the filter saw the 400 as well. I caught the difference on the 10.0.3 runtime: with AddValidation() enabled, a request with a malformed Guid still ran the handler, which wrote its own result into the body of the 400. .NET 11 guarantees that the handler never runs after a binding failure; if you stay on .NET 10, keep your runtime patches up to date.
ShortCircuit() and MapShortCircuit from the .NET 8 post were specific to minimal APIs. .NET 11 adds an attribute form that works on controllers too:
[ApiController]
[Route("robots.txt")]
[ShortCircuit]
public class RobotsController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Content("User-agent: *\nDisallow:", "text/plain");
}
// Program.cs
app.MapGet("/health", [ShortCircuit] () => "Healthy");
I added a middleware to the pipeline that puts an X-Pipeline header on every response. The /products response had the header; the /robots.txt and /health responses did not. They ran right after endpoint routing and the rest of the middleware was skipped. It is a good fit for the liveness endpoint from the health check post.
C# 15 Union Types: One Field, Two Shapes
In product search, let the client send the price filter either as a single upper bound (50) or as a range ({"min":50,"max":400}). In .NET 10 you either added two nullable properties or wrote a custom JsonConverter. C# 15 union types work wherever System.Text.Json is used, including minimal API and MVC bodies:
app.MapPost("/products/search", (ProductSearch search) =>
TypedResults.Ok(catalog.Where(p => search.Price switch
{
decimal max => p.Price <= max,
PriceRange range => p.Price >= range.Min && p.Price <= range.Max,
})));
public record PriceRange(decimal Min, decimal Max);
public union PriceFilter(decimal, PriceRange);
public record ProductSearch(PriceFilter Price);
$ curl -X POST localhost:5111/products/search -H "Content-Type: application/json" -d '{"price":50}'
[{"id":2,"name":"Wireless Mouse","sku":"MS-002","price":29.90}]
$ curl -X POST localhost:5111/products/search -H "Content-Type: application/json" -d '{"price":{"min":50,"max":400}}'
[{"id":1,"name":"Mechanical Keyboard","sku":"KB-001","price":89.90},{"id":3,"name":"4K Monitor","sku":"MN-003","price":349.00}]
The switch has no default arm and the compiler did not warn; it knows the union's cases. Unlike polymorphism, there is no discriminator such as $type in the JSON; the shape of the value decides the case. In the OpenAPI document the field came out as anyOf:
"PriceFilter": {
"anyOf": [
{
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
},
{
"$ref": "#/components/schemas/PriceRange"
}
]
}
Unions work only in the body; route, query string, header and form binding are not supported. If two cases serialize to the same JSON shape, Learn tells you to disambiguate them with [JsonUnion].
OpenAPI 3.2 Is the Default
In the .NET 10 post we saw AddOpenApi() produce 3.1.1. In .NET 11 the same call gives:
$ curl localhost:5111/openapi/v1.json
{
"openapi": "3.2.0",
Under the hood it moved to Microsoft.OpenApi 3.x; my project got 3.11.0. Three things that come with 3.2 are useful for APIs right away. The first is the HTTP QUERY method: safe and idempotent like GET, but able to carry a body. When search criteria are too large for a URL, you can use it instead of falling back to POST. Routing already accepted any verb; what is new is that the document recognizes it:
app.MapMethods("/products/search", ["QUERY"], (ProductSearch search) =>
TypedResults.Ok(catalog.Where(p => search.Price switch
{
decimal max => p.Price <= max,
PriceRange range => p.Price >= range.Min && p.Price <= range.Max,
})));
$ curl -X QUERY localhost:5111/products/search -H "Content-Type: application/json" -d '{"price":100}'
[{"id":1,"name":"Mechanical Keyboard","sku":"KB-001","price":89.90},{"id":2,"name":"Wireless Mouse","sku":"MS-002","price":29.90}]
$ curl localhost:5111/openapi/v1.json
"/products/search": {
"post": { ... },
"query": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProductSearch"
}
}
},
"required": true
},
...
The second is [Obsolete]. In the versioning post we talked about deprecating old endpoints; now the marker on the C# side flows into the document automatically:
#pragma warning disable CS0618
app.MapGet("/v1/products/{id:int}", GetLegacyProduct);
#pragma warning restore CS0618
// ...
#pragma warning disable CS0618
[Obsolete("Use /products/{id}.")]
static ProductDto GetLegacyProduct(int id) => new(id, "Mechanical Keyboard", "KB-001", 89.90m);
#pragma warning restore CS0618
"/v1/products/{id}": {
"get": {
...
"deprecated": true
}
}
The third is Server-Sent Events. When I rewrote the order status stream from the .NET 10 post to return SseItem<T>, the document described the shape of each event with the 3.2 itemSchema field:
app.MapGet("/orders/{id:int}/status-stream", (int id, CancellationToken cancellationToken) =>
TypedResults.ServerSentEvents(GetStatusesAsync(id, cancellationToken)));
static async IAsyncEnumerable<SseItem<OrderStatus>> GetStatusesAsync(int orderId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
string[] statuses = ["Received", "Packed", "Shipped"];
foreach (string status in statuses)
{
await Task.Delay(500, cancellationToken);
yield return new SseItem<OrderStatus>(new OrderStatus(orderId, status), "order-status");
}
}
"text/event-stream": {
"itemSchema": {
"required": [
"data"
],
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/OrderStatus"
},
"event": {
"type": "string"
},
"id": {
"type": "string"
}
}
}
}
If your tools cannot read 3.2 yet, lower the version by hand:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1;
});
With this setting the document became 3.1.2; the QUERY operation moved under the x-oai-additionalOperations extension and the SSE schema under x-oai-itemSchema.
Quick Notes: Observability, Rate Limiting and More
ASP.NET Core now writes the OpenTelemetry semantic convention tags onto the HTTP server activity itself. Without installing any package, an ActivityListener on the Microsoft.AspNetCore activity source showed me the tags http.request.method=QUERY, http.route=/products/search and http.response.status_code=200 for the QUERY request. If you use OpenTelemetry, AddSource("Microsoft.AspNetCore") is enough; you no longer need the OpenTelemetry.Instrumentation.AspNetCore package.
In the .NET 7 post we suggested writing Retry-After in the rate limiter's OnRejected callback. The RetryAfter metadata from the fixed window limiter now points to the next window boundary correctly, so the same code writes the right interval. For output caching, IOutputCachePolicyProvider arrived to pick a policy at run time, for example per tenant.
Zstandard was added to the response compression and request decompression middleware and is enabled by default. With compression on, every response now gets Vary: Accept-Encoding; good news if you sit behind a CDN. The SDK ships the dotnet new mcpserver template, and dotnet user-jwts create --file app.cs works with single-file apps. ASP.NET Core's shared framework libraries are also compiled with runtime-async: the runtime, not the compiler, generates the async state machine. Microsoft promises fewer allocations and more readable stack traces; your code needs no changes.
Common Pitfalls
First, using an async attribute in a controller. We saw it in Guess First: the same DTO returned 400 from the minimal API and 500 from the controller. If you share the DTO between the two, check the async rule in the service layer on the controller side.
Second, ThrowOnBadRequest in the Development environment. When I tried without turning this setting off, the filter never ran; the response was a 400 titled Microsoft.AspNetCore.Http.BadHttpRequestException carrying the exception details. In Production this setting is already false; don't be puzzled by "it fails locally but works in prod".
Third, using a union in the query string. When I made PriceFilter a parameter of a GET endpoint, the first request got an InvalidOperationException with the message Body was inferred but the method does not allow inferred body parameters; the framework treats the union as a body.
Fourth, unions accepting numbers as strings. {"price":"50"} also returned 200 and landed in the decimal case; the web defaults allow numbers to be read from strings. That is also where the ["number", "string"] type in the document comes from. When I set NumberHandling to Strict through ConfigureHttpJsonOptions, the same request returned 400.
Upgrading to .NET 11
<TargetFramework>net11.0</TargetFramework>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="11.0.0" />
You need the .NET 11 SDK; I built the examples with SDK 11.0.100. Bump the Microsoft.* packages to 11.0.x. C# 15 is the default language for projects that target net11.0; union types need no extra setting.
Two removals break the build. SetCompatibilityVersion, which you still find in projects from the Startup.cs era we examined in the post on reading old projects, has done nothing since ASP.NET Core 3.0 and is now gone. The ConcurrencyLimiter middleware, obsolete since .NET 8, was removed as well; use the rate limiter's concurrency limiter instead. Compiling both gave me:
error CS1061: 'IMvcBuilder' does not contain a definition for 'SetCompatibilityVersion' ...
error CS0103: The name 'CompatibilityVersion' does not exist in the current context
error CS1061: 'IServiceCollection' does not contain a definition for 'AddQueuePolicy' ...
error CS1061: 'WebApplication' does not contain a definition for 'UseConcurrencyLimiter' ...
Behavioral changes that affect APIs: on HTTP/1.1, Kestrel closes the connection after a request that carries both Content-Length and Transfer-Encoding (against request smuggling), and on HTTP/2 and HTTP/3 it rejects connection-specific headers; if you are behind an old proxy, test it. If you wrote several [ProducesResponseType] attributes for the same status code, all of them now appear in the document. If you wrote transformers, check the type changes in Microsoft.OpenApi 3.x. Since the OpenTelemetry tags are now on by default, update the instrumentation package, or the tags may be written twice.
As an STS release, .NET 11 is supported for two years, until 2028-11-14. That is the same day .NET 10 LTS reaches end of support. .NET 8 and .NET 9 went out of support on 2026-11-10. So moving to .NET 11 buys you no extra support time; the decision depends on whether you need the new features. A team that wants to stay on LTS in production can stay on .NET 10 and wait for the next LTS.
A Small Experiment
★☆☆ Separate CreateProductRequest from the controller: give the controller its own DTO without the async attribute and move the SKU check into a service. Watch the same request return 400 on both sides.
★★☆ Write the rule "no two products with the same name in the same category" with IAsyncValidatableObject. Throw from Validate, ask the repository in ValidateAsync, and see under which key the error lands in errors.
★★★ Add a third case to PriceFilter: an upper bound with a currency, such as {"currency":"EUR","max":50}. Use curl to see whether this case gets mixed up with PriceRange; if it does, look at [JsonUnion] and examine how the anyOf in the OpenAPI document changes.
Through the version posts, the core structure we built on .NET 6 has reached today's .NET 11. The next stop is .NET 12, expected in November 2027 and, by policy, an LTS release; once it ships we will check what is here to stay and continue the series from this point.