Learnixo
Back to blog
Backend Systemsintermediate

What's New in .NET 9: Key Features Every Backend Developer Should Know

The most important .NET 9 features for backend developers: LINQ improvements, HybridCache, OpenAPI built-in, native AOT improvements, Task.WhenEach, collection expressions, and performance gains.

LearnixoJune 3, 20267 min read
.NETC#.NET 9PerformanceASP.NET CoreNew Features
Share:š•

Overview

.NET 9 (November 2024) is a Standard Term Support release (18 months). It brings meaningful improvements to performance, developer ergonomics, and the built-in tooling — especially for backend APIs.

This guide focuses on what matters for day-to-day backend development, not every minor runtime change.


1. Built-in OpenAPI Support

Previously you needed Swashbuckle or NSwag. .NET 9 ships OpenAPI document generation natively.

Bash
dotnet add package Microsoft.AspNetCore.OpenApi
C#
// Program.cs
builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();          // serves /openapi/v1.json
    app.UseSwaggerUI(options =>
        options.SwaggerEndpoint("/openapi/v1.json", "v1"));
}

Describe Endpoints

C#
app.MapGet("/orders/{id}", async (Guid id, AppDbContext db) =>
{
    var order = await db.Orders.FindAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
})
.WithName("GetOrder")
.WithDescription("Get an order by ID")
.WithTags("Orders")
.Produces<Order>()
.Produces(404);

Document Transformers

C#
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((document, context, ct) =>
    {
        document.Info.Title   = "OrderFlow API";
        document.Info.Version = "v1";
        document.Info.Contact = new() { Email = "api@orderflow.com" };
        return Task.CompletedTask;
    });
});

2. HybridCache

A new caching abstraction (Microsoft.Extensions.Caching.Hybrid) that combines in-process and distributed caching with stampede protection.

Bash
dotnet add package Microsoft.Extensions.Caching.Hybrid
C#
// Registration
builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration         = TimeSpan.FromMinutes(5),
        LocalCacheExpiration = TimeSpan.FromMinutes(1)  // L1 expires sooner
    };
});

// Optional: back with Redis for L2
builder.Services.AddStackExchangeRedisCache(opt =>
    opt.Configuration = builder.Configuration.GetConnectionString("Redis"));
C#
// Usage
public class ProductService
{
    private readonly HybridCache _cache;
    private readonly AppDbContext _db;

    public async Task<Product?> GetProductAsync(int id, CancellationToken ct)
    {
        return await _cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await _db.Products.FindAsync(id, token),
            cancellationToken: ct);
    }

    public async Task InvalidateAsync(int id)
    {
        await _cache.RemoveAsync($"product:{id}");
    }
}

Key improvements over IMemoryCache + IDistributedCache:

  • Single API for L1 (in-process) + L2 (Redis)
  • Stampede protection — only one factory call runs for concurrent cache misses
  • Tag-based invalidation: await _cache.RemoveByTagAsync("products")

3. Task.WhenEach (.NET 9)

Process tasks as they complete, not waiting for all to finish:

C#
// Before .NET 9 — you had to use WhenAny in a loop
var tasks = new[] { FetchAAsync(), FetchBAsync(), FetchCAsync() };
while (tasks.Length > 0)
{
    var completed = await Task.WhenAny(tasks);
    ProcessResult(await completed);
    tasks = tasks.Where(t => t != completed).ToArray();
}

// .NET 9 — clean and readable
await foreach (var task in Task.WhenEach(FetchAAsync(), FetchBAsync(), FetchCAsync()))
{
    ProcessResult(await task);
}

4. LINQ Improvements

CountBy

C#
// Count elements by key — without materialising a full GroupBy
var countByStatus = orders.CountBy(o => o.Status);
// Returns IEnumerable<KeyValuePair<string, int>>

foreach (var (status, count) in countByStatus)
    Console.WriteLine($"{status}: {count}");

AggregateBy

C#
// Aggregate by key without GroupBy + Select
var totalByCustomer = orders.AggregateBy(
    keySelector: o => o.CustomerId,
    seed: 0m,
    func: (total, order) => total + order.Amount);

Index

C#
// Get element with its index without a counter variable
foreach (var (index, order) in orders.Index())
    Console.WriteLine($"{index}: {order.Id}");

5. Collection Expressions (C# 13)

Unified syntax for creating collections:

C#
// Before
int[] array  = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };

// C# 12 collection expressions (also in .NET 8)
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];

// Spread operator
int[] first  = [1, 2, 3];
int[] second = [4, 5, 6];
int[] all    = [..first, ..second, 7]; // [1,2,3,4,5,6,7]

// Works with custom collections too
ImmutableArray<string> names = ["Alice", "Bob", "Charlie"];

6. params Collections

params now works with any IEnumerable<T>, not just arrays:

C#
// Now accepts span, list, or array directly
public void Log(params ReadOnlySpan<string> messages) { }
public void Process(params IEnumerable<int> ids) { }

// Call with a list without .ToArray()
var ids = new List<int> { 1, 2, 3 };
Process(ids);  // works in .NET 9

7. ASP.NET Core Performance

Request Delegate Generator

Minimal API endpoints now generate source-code binding at compile time, eliminating runtime reflection:

C#
// This generates efficient binding code at compile time
app.MapGet("/orders/{id}", async (Guid id, AppDbContext db, CancellationToken ct) =>
    await db.Orders.FindAsync(id, ct) is { } order
        ? Results.Ok(order)
        : Results.NotFound());

TypedResults

Improves OpenAPI inference — use TypedResults instead of Results for better schema generation:

C#
app.MapGet("/orders/{id}", async Task<Results<Ok<Order>, NotFound>> (Guid id, AppDbContext db) =>
{
    var order = await db.Orders.FindAsync(id);
    return order is null ? TypedResults.NotFound() : TypedResults.Ok(order);
});

8. Native AOT Improvements

Native AOT compiles .NET to a self-contained native binary. .NET 9 improves compatibility:

XML
<!-- .csproj -->
<PublishAot>true</PublishAot>
Bash
dotnet publish -r linux-x64 -c Release
# Output: ~15MB self-contained native binary
# Start time: <50ms (vs 200ms+ for JIT)

Benefits: fast startup (great for serverless/Lambda), small binary, no JIT warmup.

Limitations: no runtime code generation (dynamic proxies, reflection emit), limited third-party library support. EF Core has AOT support in .NET 9; many libraries are still catching up.


9. Improved Diagnostics

Debug Display for Common Types

Debugger now shows meaningful values for Span<T>, Memory<T>, and various collections without custom display attributes.

UnsafeAccessor

Access private members without reflection — zero overhead:

C#
// Access a private field with no overhead (replaces reflection for testing/AOT)
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_balance")]
extern static ref decimal GetBalance(BankAccount account);

var account = new BankAccount();
ref decimal balance = ref GetBalance(account);
balance = 1000; // direct access, no reflection

10. Keyed Services Improvements

Keyed DI (introduced in .NET 8) gets additional helpers in .NET 9:

C#
// Register multiple implementations with a key
builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");
builder.Services.AddKeyedScoped<INotificationSender, PushSender>("push");

// Resolve by key
public class NotificationService([FromKeyedServices("email")] INotificationSender email,
                                  [FromKeyedServices("sms")]   INotificationSender sms)
{
    // use specific implementations
}

Performance Numbers (.NET 9 vs .NET 8)

| Benchmark | Improvement | |---|---| | JSON serialization | ~15% faster | | Regex (source-generated) | ~30% faster | | LINQ Order / OrderBy | ~20% faster | | Dictionary<K,V> lookups | ~10% faster | | HttpClient throughput | ~10% higher | | Startup time (AOT) | ~30% faster vs .NET 8 AOT |


Migration from .NET 8

XML
<!-- .csproj — update TargetFramework -->
<TargetFramework>net9.0</TargetFramework>
Bash
# Update NuGet packages to 9.x
dotnet outdated --upgrade

Most .NET 8 code compiles unchanged on .NET 9. Check for:

  • Deprecated APIs (compiler warnings guide you)
  • NuGet packages that haven't released .NET 9 builds yet
  • Any custom reflection code if adopting AOT

Interview Questions

Q: What is HybridCache and how does it improve on IMemoryCache + IDistributedCache? HybridCache provides a single API for two-level caching (L1 in-process, L2 Redis) with stampede protection — only one factory call executes for concurrent misses, preventing thundering herd. It also supports tag-based invalidation, which neither IMemoryCache nor IDistributedCache offer natively.

Q: What is the benefit of the built-in OpenAPI support in .NET 9? No additional packages needed — the runtime generates the OpenAPI document from your endpoint metadata. TypedResults improve schema inference. Document transformers allow programmatic customisation. Reduces dependency on third-party packages that often lag behind .NET releases.

Q: What is Native AOT and when would you use it for an ASP.NET Core API? AOT compiles your app to a native binary — no JIT, fast startup, small footprint. Ideal for serverless functions (Lambda, Azure Functions) where cold start time matters. Not suitable when you rely on dynamic reflection, runtime code generation, or libraries that haven't been AOT-proofed.

Q: What does Task.WhenEach solve that Task.WhenAll doesn't? WhenAll waits for all tasks and returns all results at once. WhenEach lets you process results as they arrive — you don't wait for the slowest task before processing the fast ones. Useful when results are independent and you want to pipeline processing.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.