.NET 10 has been out for a while now. It’s an LTS release, so it gets three years of support instead of the two that STS releases get. Every previous LTS brought something I still use daily.
.NET 8:
- Primary constructors
- Collection expressions
- Aspire
.NET 6:
- Top-level statements
- File-scoped namespaces
- Implicit global usings
- Minimal APIs
They share one trait: they are production ready and they make development simpler.
.NET 10 is no different.
The field keyword
For years, adding custom logic to a property meant this three-step ceremony:
public sealed class Product
{
private string _name = string.Empty;
public string Name
{
get => _name;
set => _name = value.Trim();
}
}
C# 14 removes the ceremony.
field is a contextual keyword that the compiler resolves to the generated backing field:
public sealed class Product
{
public Guid Id { get; init; }
public string Name
{
get;
set => field = value.Trim();
} = string.Empty;
public decimal Price
{
get;
set => field = value >= 0
? value
: throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative.");
}
public int Quantity { get; init; }
}
The behavior stays where it belongs: inside the property.
I use this mostly to validate configuration.
This is a good fit for simple validation. For complex rules, opt out and use FluentValidation.
Null-conditional assignment
This is one of my favorite additions. When a value needed an explicit null check, we wrote it like this:
if (customer is not null)
{
customer.LastLogin = DateTimeOffset.UtcNow;
}
Nothing complex, but again: ceremony.
C# 14 gets the same result in one line with null-conditional assignment. The assignment is skipped when the left side is null, and the right side is never evaluated:
endpoints.MapPost("/customers/{exists:bool}/touch",
Results<Ok<CustomerTouchResult>, NotFound> (bool exists) =>
{
Customer? customer = exists ? new Customer(Guid.NewGuid()) : null;
var now = DateTimeOffset.UtcNow;
customer?.LastLogin = now;
return customer is null
? TypedResults.NotFound()
: TypedResults.Ok(new CustomerTouchResult(customer.Id, customer.LastLogin));
});
Ninety percent of my codebase uses this now. Less bloat, easier maintenance.
Be careful not to hide situations where null actually represents an error.
Extension members go beyond extension methods
You are not a .NET developer if you have never written an extension method. I have written a thousand of them.
public static class EnumerableExtensions
{
public static bool IsEmpty<T>(this IEnumerable<T> source) => !source.Any();
}
An extension block lets you define extension properties and members that appear on the type itself, not just methods:
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
}
}
At the call site it reads like a real property:
endpoints.MapPost("/collections/status", (int[] values) => TypedResults.Ok(new
{
values.IsEmpty,
Count = values.Length
}));
Extension properties should behave like normal properties. Avoid hiding expensive work, I/O, or database queries behind something that looks like a cheap property access.
Minimal APIs get built-in validation
Minimal APIs took off when they came out. I know several developers who ported existing projects and now start every new one with them. Less ceremony, focus on endpoints, and a good fit for vertical slice architecture.
Validation was the sore spot. Minimal APIs did not run DataAnnotations for you, so the logic ended up inline in the endpoint:
app.MapPost("/products", (CreateProductRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length < 2)
{
return Results.BadRequest("Name must be at least 2 characters.");
}
if (request.Quantity is < 1 or > 1000)
{
return Results.BadRequest("Quantity must be between 1 and 1000.");
}
});
.NET 10 solves this.
Annotate the contract:
public sealed record CreateProductRequest(
[Required, MinLength(2), MaxLength(100)] string Name,
[Range(1, 1000)] int Quantity,
[Range(typeof(decimal), "0", "1000000")] decimal Price);
Then register validation once with AddValidation(), alongside IProblemDetailsService:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
The endpoint keeps only the work that matters:
endpoints.MapPost("/products", (CreateProductRequest request) =>
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = request.Name,
Price = request.Price,
Quantity = request.Quantity
};
return TypedResults.Created($"/products/{product.Id}", product);
})
.WithName("CreateProduct")
.WithSummary("Built-in Minimal API validation plus C# 14 field-backed properties");
An invalid payload never reaches your handler. It comes back as a Problem Details response instead, so the pipeline looks like this:
Request
↓
Validation
↓
ProblemDetails
↓
Consistent API response
Every Minimal API I write now uses this approach.
Server-Sent Events (SSE)
When I think about real-time data, SignalR and WebSockets come to mind first. Say you have a long-running export. Sockets do the job, but you first have to establish and manage a separate full-duplex protocol, which is overkill for one-way progress updates.
That is where Server-Sent Events fit:
- The server keeps an HTTP connection open.
- The server pushes updates to the client whenever new data becomes available, whether that’s every few seconds or every few minutes.
.NET 10 adds first-class support for SSE responses through TypedResults.ServerSentEvents, in both Minimal APIs and controller-based APIs:
endpoints.MapGet("/notifications", (CancellationToken cancellationToken) =>
TypedResults.ServerSentEvents(GetNotifications(cancellationToken)))
.WithName("StreamNotifications")
.WithSummary(".NET 10 first-class Server-Sent Events response");
The endpoint produces an asynchronous stream of values, and the framework handles the text/event-stream framing:
private static async IAsyncEnumerable<Notification> GetNotifications(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (var number = 1; number <= 3; number++)
{
yield return new Notification(number, $"Update {number}", DateTimeOffset.UtcNow);
await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken);
}
}
The important distinction is communication direction. SSE is one-way:
Server
↓
Client
SignalR and WebSockets are bidirectional:
Server
↕
Client
So far I have only used SSE for small background jobs such as file uploads, but I see it as a strong fit for notifications and live feeds.
I’ll cover this in a dedicated newsletter issue.
Stricter System.Text.Json
A response arrives from an API and, after deserialization, the payload looks like this:
{
"role": "User",
"role": "Admin"
}
Which value should win? Should the payload be rejected? With loose serializer settings, the answer is ambiguous, and ambiguity turns into an unwanted response.
.NET 10 adds JsonSerializerOptions.Strict, a preset that tightens the input rules:
public static class StrictJsonServiceCollectionExtensions
{
public static IServiceCollection AddStrictHttpJson(this IServiceCollection services)
{
return services.ConfigureHttpJsonOptions(options =>
{
var source = JsonSerializerOptions.Strict;
var target = options.SerializerOptions;
target.AllowDuplicateProperties = source.AllowDuplicateProperties;
target.UnmappedMemberHandling = source.UnmappedMemberHandling;
target.RespectNullableAnnotations = source.RespectNullableAnnotations;
target.RespectRequiredConstructorParameters = source.RespectRequiredConstructorParameters;
});
}
}
Pair it with a contract that declares what is actually required:
public sealed record RoleContract(
[property: JsonRequired] string Role);
That combination catches:
- Unexpected members
- Missing required data
- Nullable contract violations
- Duplicate JSON properties
What you get without changing code
Some of the upgrade pays off the moment you retarget:
- OpenAPI 3.1 by default.
builder.Services.AddOpenApi()plusapp.MapOpenApi()now emits a 3.1 document at/openapi/v1.json. - Runtime and JIT improvements. Better code generation and lower startup cost, with no source changes required.
Summary
These are the C# 14 and .NET 10 features I think earn their place in production, and where each one helps:
| Feature | Where it pays off |
|---|---|
field keyword | Property-level normalization and validation without a hand-written backing field |
| Null-conditional assignment | Optional-reference updates without a wrapping if |
| Extension members | Extension properties that read like real members on a type |
| Minimal API validation | DataAnnotations enforced before your handler runs, returned as Problem Details |
| Server-Sent Events | One-way progress and notification streams without WebSocket setup |
Strict System.Text.Json | Duplicate, unknown, missing, and null contract values rejected at the edge |