What's New in .NET 10: AddValidation, OpenAPI 3.1 and Server-Sent Events in ASP.NET Core
.NET 10 LTS brings AddValidation to minimal APIs, OpenAPI 3.1 and YAML, TypedResults.ServerSentEvents, System.Text.Json-based JSON Patch, dotnet run app.cs, C# 14 and breaking changes such as cookie auth 401/403.
Version note: This post covers what .NET 10 introduced; the code targets .NET 10.
Guess First
We have a simple minimal API endpoint written in .NET 6. The request DTO carries [Required] and [Range] attributes:
using System.ComponentModel.DataAnnotations;
// ...
app.MapPost("/products", (CreateProductRequest request) =>
Results.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description)));
public record CreateProductRequest(
[property: Required, StringLength(100)] string Name,
[property: Range(0.01, 100000)] decimal Price,
string? Description);
public record ProductDto(int Id, string Name, decimal Price, string? Description);
If you send {"name":"","price":0} to this endpoint, which status code comes back? In the controller post we saw [ApiController] return an automatic 400; do you expect the same from a minimal API?
.NET 10, released on November 11, 2025, is an LTS release and the last stop in this part of the series. In this post we will see validation arrive in minimal APIs, OpenAPI 3.1, Server-Sent Events, JSON Patch without Newtonsoft, single-file apps and C# 14. In the migration section we will also look at two things whose behavior changed.
Validation Is Now Built into Minimal APIs
The answer to the guess came when I built and ran the project targeting net6.0 (on the .NET 8 runtime, since there is no .NET 6 runtime on my machine; minimal APIs behave the same way here up to .NET 9):
$ curl -i -X POST localhost:5061/products -H "Content-Type: application/json" -d '{"name":"","price":0}'
HTTP/1.1 201 Created
Location: /products/1
{"id":1,"name":"","price":0,"description":null}
201. Minimal APIs never looked at the attributes; a product with an empty name and a zero price was silently accepted. In .NET 6 we either reached for FluentValidation or wrote this in every endpoint:
app.MapPost("/v2/products", (CreateProductRequest request) =>
{
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true))
{
Dictionary<string, string[]> errors = results
.GroupBy(r => r.MemberNames.FirstOrDefault() ?? "")
.ToDictionary(g => g.Key, g => g.Select(r => r.ErrorMessage ?? "").ToArray());
return Results.ValidationProblem(errors);
}
return Results.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description));
});
In .NET 10 all of that comes down to two lines:
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
// ...
app.MapPost("/products", (CreateProductRequest request) =>
TypedResults.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description)));
$ curl -i -X POST localhost:5101/products -H "Content-Type: application/json" -d '{"name":"","price":0}'
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":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-797bed7f91d985b1420a00b87e547734-f56972a59d1c5eab-00"}
AddValidation() does not work through reflection; at build time a source generator finds the parameter types of your endpoints and generates the validation code. It understands Data Annotations, IValidatableObject and records. To turn it off for one endpoint there is .DisableValidation(). The APIs live in the Microsoft.Extensions.Validation namespace; the top-level AddValidation is stable, while the deeper resolver APIs are experimental.
OpenAPI 3.1 Is the New Default
In the previous post we saw that the .NET 9 document starts with "openapi": "3.0.4". The same AddOpenApi() call produces this in .NET 10:
$ curl localhost:5101/openapi/v1.json
{
"openapi": "3.1.1",
The difference is not just the version number. The nullable Description field was written as "type": "string", "nullable": true in the .NET 9 document. Because OpenAPI 3.1 aligns with JSON Schema, the same field became "type": ["null", "string"] in .NET 10. Publishing the document as YAML is now a single line too:
app.MapOpenApi();
app.MapOpenApi("/openapi/{documentName}.yaml");
$ curl localhost:5101/openapi/v1.yaml
openapi: '3.1.1'
info:
title: ProductCatalog.Api | v1
version: 1.0.0
Under the hood the generator moved to Microsoft.OpenApi 2.0, and that is a breaking change if you wrote transformers. For example, .NET 9 code that puts an example value on a schema with OpenApiString no longer compiles on .NET 10; it fails with CS0234 on the using Microsoft.OpenApi.Any; line. In the new model example values are JsonNode:
using System.Text.Json.Nodes;
// ...
options.AddSchemaTransformer((schema, context, cancellationToken) =>
{
if (context.JsonTypeInfo.Type == typeof(decimal))
{
schema.Example = JsonValue.Create("89.90");
}
return Task.CompletedTask;
});
Server-Sent Events: TypedResults.ServerSentEvents
Say you want to stream an order's status to the client live. SignalR is overkill, and polling is wasteful. Server-Sent Events is built for exactly this: a one-way stream of events from server to client over plain HTTP. In .NET 6 we wrote the protocol by hand:
using System.Text.Json;
// ...
app.MapGet("/orders/{id:int}/status-stream", async (int id, HttpContext context, CancellationToken cancellationToken) =>
{
context.Response.Headers.ContentType = "text/event-stream";
string[] statuses = { "Received", "Packed", "Shipped" };
foreach (string status in statuses)
{
await Task.Delay(1000, cancellationToken);
string json = JsonSerializer.Serialize(new OrderStatus(id, status), new JsonSerializerOptions(JsonSerializerDefaults.Web));
await context.Response.WriteAsync($"event: order-status\ndata: {json}\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
}
});
Remembering the header, the line format and the flush was your job. In .NET 10 you hand over an IAsyncEnumerable and leave the rest to the framework:
using System.Runtime.CompilerServices;
// ...
app.MapGet("/orders/{id:int}/status-stream", (int id, CancellationToken cancellationToken) =>
TypedResults.ServerSentEvents(GetStatusesAsync(id, cancellationToken), eventType: "order-status"));
static async IAsyncEnumerable<OrderStatus> GetStatusesAsync(int orderId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
string[] statuses = ["Received", "Packed", "Shipped"];
foreach (string status in statuses)
{
await Task.Delay(1000, cancellationToken);
yield return new OrderStatus(orderId, status);
}
}
public record OrderStatus(int OrderId, string Status);
$ curl -N -i localhost:5101/orders/42/status-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache,no-store
event: order-status
data: {"orderId":42,"status":"Received"}
event: order-status
data: {"orderId":42,"status":"Packed"}
event: order-status
data: {"orderId":42,"status":"Shipped"}
The lines arrived one second apart. When the client closes the connection, the CancellationToken fires and the loop stops. The OpenAPI document also listed a text/event-stream response for this endpoint on its own. You can return the same result type from controller actions as well.
JSON Patch Breaks Free of Newtonsoft
JSON Patch lets you send only the changed fields of a resource as a list of operations. In .NET 6 the only way to do it was Newtonsoft: the Microsoft.AspNetCore.Mvc.NewtonsoftJson package, AddControllers().AddNewtonsoftJson() and the Microsoft.AspNetCore.JsonPatch namespace. While the rest of the app used System.Text.Json, you carried a second JSON library for a single feature. .NET 10 has a package based on System.Text.Json:
<PackageReference Include="Microsoft.AspNetCore.JsonPatch.SystemTextJson" Version="10.0.14" />
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPatch("{id:int}")]
public ActionResult<Product> Patch(int id, [FromBody] JsonPatchDocument<Product> patch)
{
var product = new Product { Id = id, Name = "Mechanical Keyboard", Price = 89.90m, Stock = 10 };
patch.ApplyTo(product, error => ModelState.AddModelError(error.Operation.path, error.ErrorMessage));
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
return product;
}
}
$ curl -X PATCH localhost:5101/api/products/1 -H "Content-Type: application/json-patch+json" \
-d '[{"op":"replace","path":"/price","value":79.90},{"op":"replace","path":"/stock","value":0}]'
{"id":1,"name":"Mechanical Keyboard","price":79.90,"stock":0}
$ curl -X PATCH localhost:5101/api/products/1 -H "Content-Type: application/json-patch+json" \
-d '[{"op":"replace","path":"/color","value":"black"}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"/color":["The target location specified by path segment 'color' was not found."]},"traceId":"00-e440e3353e5fe8192d65652b58f90047-79389282fb767bd1-00"}
One difference: the ApplyTo(product, ModelState) overload from the Newtonsoft version is not here; when I tried to compile it I got CS1503. You move the errors into ModelState with a callback as shown above. The package is not a drop-in replacement either; for example, ExpandoObject is not supported.
A Single-File API: dotnet run app.cs
You no longer need a project to try out an idea. A single .cs file is enough:
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
var app = WebApplication.Create(args);
app.MapGet("/products", () => new ProductDto[] { new(1, "Mechanical Keyboard", 89.90m) });
app.Run("http://localhost:5103");
record ProductDto(int Id, string Name, decimal Price);
$ dotnet run products.cs
$ curl localhost:5103/products
[{"id":1,"name":"Mechanical Keyboard","price":89.90}]
#:sdk tells which SDK compiles the file, #:package adds NuGet packages, and #:property sets MSBuild properties. When the script grows, dotnet project convert turns it into a regular project. I will explain why the PublishAot=false line is there in the pitfalls.
Quick notes: ASP.NET Core Identity gained passkey (WebAuthn) support; public partial class Program is now produced by a source generator, so you no longer add it by hand for integration tests; and dotnet new sln creates .slnx by default.
C# 14: Extension Members and field
The field keyword, which the compiler rejected in the .NET 9 post because it was a preview, is stable in C# 14. Extension members come along with it: next to extension methods you can now write extension properties and static extension members.
var keyboard = new Product { Name = " Mechanical Keyboard ", Stock = 3 };
Console.WriteLine($"[{keyboard.Name}] in stock: {keyboard.IsInStock}");
Product placeholder = Product.Placeholder;
Console.WriteLine($"[{placeholder.Name}] in stock: {placeholder.IsInStock}");
Product? missing = null;
missing?.Stock = 10;
Console.WriteLine(missing is null);
public class Product
{
public string Name
{
get;
set => field = value.Trim();
} = "";
public int Stock { get; set; }
}
public static class ProductExtensions
{
extension(Product product)
{
public bool IsInStock => product.Stock > 0;
}
extension(Product)
{
public static Product Placeholder => new() { Name = "Unnamed product" };
}
}
[Mechanical Keyboard] in stock: True
[Unnamed product] in stock: False
True
field lets you use the field behind an auto-property without naming it; you no longer declare a private field just to add one rule to a setter. missing?.Stock = 10 is the new null-conditional assignment: if the object is null, the assignment does not happen at all.
Common Pitfalls
First, forgetting AddProblemDetails(). Validation works without it, but the response comes back as application/json without type, status or traceId; it only has title and errors. If you want the same shape as your other error responses, register both.
Second, the attribute target on records. AddValidation also sees attributes on constructor parameters, but Validator.TryValidateObject from .NET 6 does not. When I tried without the [property: ...] target, the manual validation accepted the product with an empty name. If the code has to work in both worlds, write [property: ...].
Third, the Native AOT default of single-file apps. Without the PublishAot=false line the same file compiled, but the first request failed with System.NotSupportedException: JsonTypeInfo metadata for type 'ProductDto[]' was not provided by TypeInfoResolver. In AOT mode reflection-based JSON is off; either add that line or write a JsonSerializerContext. An endpoint returning an anonymous type even produced a compile error in the code the source generator emitted.
Fourth, the type of numbers in OpenAPI 3.1. A decimal field came out in the .NET 10 document as "type": ["number", "string"] with a pattern, because the web defaults allow numbers to be read from strings. When I set NumberHandling to Strict, the field went back to plain number. If you generate clients from the document, check this.
Moving to .NET 10
<TargetFramework>net10.0</TargetFramework>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.14" />
You need the .NET 10 SDK; I built the examples with the 10.0.402 SDK. Bump your Microsoft.* packages to 10.0.x; EF Core 10 runs only on .NET 10. If you are staying on Swashbuckle, its 10.x line supports .NET 10.
The first behavior change is cookie authentication. When you called a cookie-protected endpoint without signing in, .NET 9 and earlier redirected you to the login page. I ran the same code on the .NET 8 runtime and on .NET 10:
# .NET 8
HTTP/1.1 302 Found
Location: http://localhost:5061/Account/Login?ReturnUrl=%2Forders
# .NET 10
HTTP/1.1 401 Unauthorized
Location: http://localhost:5101/Account/Login?ReturnUrl=%2Forders
[ApiController] controllers, minimal APIs that return JSON and endpoints using TypedResults now count as "API endpoints" and return 401/403 instead of a redirect. If your client was written around the 302, you need to update it.
The second is IExceptionHandler. When TryHandleAsync returns true, the middleware no longer logs the exception. When I tried it with the default settings, not a single fail line showed up in the log; if you want the old behavior:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = context => false
});
With this setting the "An unhandled exception has occurred while executing the request." line came back. The list has other warnings too: WithOpenApi(), which we have used since .NET 7, now raises an ASPDEPR002 warning, and WebHostBuilder and IActionContextAccessor are obsolete.
.NET 10 is an LTS release supported until 2028-11-14. When .NET 8 and .NET 9 go out of support together on 2026-11-10, it will be the only supported GA release.
A Small Experiment
★☆☆ Set up the endpoint from Guess First on .NET 10 with AddValidation(). Send the same invalid request first without AddProblemDetails(), then with it, and compare the two responses.
★★☆ Add IValidatableObject to CreateProductRequest and write a rule that Description, when present, must be at least 10 characters. Then add .DisableValidation() to the endpoint and watch the same request go through.
★★★ Move the order status stream into a route group, write a small HTML page that listens with EventSource in the browser, and confirm with a log line that the loop on the server stops when you close the tab.
Through these version posts, the core structure we built on .NET 6 has reached today's .NET 10 LTS. The series does not end here: .NET 11, an STS release, is currently at the RC stage and its GA is expected on 2026-11-10. Once it ships and we know what is final, we will pick up from here with .NET 11.