What's New in .NET 9: Built-in OpenAPI, Scalar and HybridCache in ASP.NET Core
.NET 9 drops Swashbuckle from the template: AddOpenApi/MapOpenApi, transformers, Scalar and Swagger UI, stampede-safe HybridCache, TypedResults.InternalServerError, StatusCodeSelector, C# 13 and 24-month STS support.
Version note: This post covers what .NET 9 introduced; the code targets .NET 9.
Guess First
Create a new Web API project from the .NET 9 template, run it in the Development environment and browse to /swagger. Do you get the green-and-white page that showed up in every project from .NET 5 to .NET 8, or something else? I answer in the first section, but write your guess down now.
.NET 9, released on November 12, 2024, is an STS release. It leaves the Program.cs skeleton alone; the structure we built on .NET 6 stays exactly as it was. What changes is where two pieces live that we have long treated as "goes into every project anyway": the API document is now the framework's own job, and on the caching side there is a new abstraction that fills the gap between IMemoryCache and IDistributedCache.
How OpenAPI Arrived in .NET 6
In the core posts we always set up Swagger with these lines:
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
// Program.cs (.NET 6)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapGet("/products/{id:int}", (int id) => new ProductDto(id, "Mechanical Keyboard", 89.90m));
app.Run();
public record ProductDto(int Id, string Name, decimal Price);
Both the document and the UI came from Swashbuckle, a third-party package. In March 2024 the ASP.NET Core team announced that Swashbuckle was not actively maintained and had no official release for .NET 8, and they removed it from the .NET 9 template. That answers the guess: in a project from the .NET 9 template, /swagger returns 404. There is no Swagger UI because the package that set it up is gone.
AddOpenApi and MapOpenApi
In the Program.cs generated by the .NET 9 template, two lines take Swashbuckle's place:
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.22" />
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
AddOpenApi() registers the document generator in the DI container, and MapOpenApi() adds an endpoint that serves the document at /openapi/v1.json. The generator reads the same endpoint metadata for minimal APIs and controllers; everything you already know, such as WithName, Produces and [ProducesResponseType], ends up in the document. It is also compatible with trimming and Native AOT.
In Swashbuckle you wrote filters to customize the document. The built-in generator's equivalent is a transformer. There are three kinds, at the document, operation and schema level:
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info.Title = "ProductCatalog API";
document.Info.Description = "Products, categories and orders.";
return Task.CompletedTask;
});
});
When I ran the app and looked at the document, the first lines came back like this:
$ curl http://localhost:5091/openapi/v1.json
{
"openapi": "3.0.4",
"info": {
"title": "ProductCatalog API",
"description": "Products, categories and orders.",
"version": "1.0.0"
},
Note the openapi field: the document .NET 9 generates is OpenAPI 3.0. That value changes in the next release.
A UI for the Document: Scalar or Swagger UI
A JSON document is great for machines, but you want to try the endpoints from the browser. Microsoft.AspNetCore.OpenApi deliberately ships no UI; it generates the document and leaves displaying it to you. The option Microsoft's docs put forward is Scalar:
<PackageReference Include="Scalar.AspNetCore" Version="2.18.7" />
using Scalar.AspNetCore;
// ...
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
Run the app and open /scalar: your document turns into a readable reference page and you can send requests from there. If your team is used to Swagger UI, you do not have to give it up either. Add only Swashbuckle's UI package and point it at the built-in document:
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.3.1" />
app.MapOpenApi();
app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "ProductCatalog API"));
With this setup /swagger/index.html returned 200 again. The framework generates the document, and the familiar UI only displays it. In restaurant terms: the kitchen now belongs to the house, and you only choose which plate to serve on.
HybridCache: The Missing Piece of IMemoryCache
In the caching post we cached product details like this:
using Microsoft.Extensions.Caching.Memory;
// ...
app.MapGet("/legacy/products/{id:int}", async (int id, IMemoryCache cache, ProductRepository repository) =>
{
ProductDto? product = await cache.GetOrCreateAsync($"product:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return repository.GetAsync(id);
});
return product is null ? Results.NotFound() : Results.Ok(product);
});
To measure it, I put a counter inside ProductRepository; every call increments it and waits 500 ms, imitating a slow database query:
public class ProductRepository
{
private int _calls;
public int Calls => _calls;
public async Task<ProductDto?> GetAsync(int id, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
await Task.Delay(500, cancellationToken);
return id == 1 ? new ProductDto(1, "Mechanical Keyboard", 89.90m) : null;
}
}
Now send ten simultaneous requests for the same product while the cache is empty:
$ for i in $(seq 1 10); do curl -s localhost:5091/legacy/products/1 > /dev/null & done; wait
$ curl localhost:5091/stats
{"databaseCalls":10}
Ten requests, ten database calls. IMemoryCache does not know another request is already waiting on the same key; everyone says "not in the cache" and runs to the kitchen. This is called a cache stampede, and when a popular product's entry expires it really does hammer the database. And if you want a distributed cache, IDistributedCache hands you a byte[]; serialization and managing the two layers together are yours to write.
HybridCache, new in .NET 9, brings both under one API:
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.10.0" />
using Microsoft.Extensions.Caching.Hybrid;
// ...
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
};
});
// ...
app.MapGet("/products/{id:int}", async (int id, HybridCache cache, ProductRepository repository, CancellationToken cancellationToken) =>
{
ProductDto? product = await cache.GetOrCreateAsync(
$"product:{id}",
async token => await repository.GetAsync(id, token),
cancellationToken: cancellationToken);
return product is null ? Results.NotFound() : Results.Ok(product);
})
.WithName("GetProductById");
When I sent the same ten simultaneous requests to this endpoint, the counter went from 10 to 11:
$ for i in $(seq 1 10); do curl -s localhost:5091/products/1 > /dev/null & done; wait
$ curl localhost:5091/stats
{"databaseCalls":11}
A single call. HybridCache holds back requests for the same key, runs the factory once and hands the result to all of them. It has two layers: LocalCacheExpiration is the lifetime in the in-process memory layer, and Expiration is the lifetime in a registered IDistributedCache (Redis, for example) if there is one. Without Redis registered, only the memory layer runs; your code stays the same. Serialization is not your job either; it does that itself with System.Text.Json.
A note on the package: HybridCache was in preview when .NET 9 shipped; the first stable release, 9.3.0, came in March 2025.
Two Small Additions to Error Responses
In .NET 6 we returned a 500 with Results.StatusCode(500) or Results.Problem(). In the Results<T1, T2> return type we saw in .NET 7 there was no dedicated typed result for 500; we made do with ProblemHttpResult or StatusCodeHttpResult. .NET 9 adds TypedResults.InternalServerError:
using Microsoft.AspNetCore.Http.HttpResults;
// ...
app.MapGet("/products/{id:int}/stock", Results<Ok<StockDto>, InternalServerError<string>> (int id) =>
id == 1
? TypedResults.Ok(new StockDto(id, 42))
: TypedResults.InternalServerError("Stock service is unavailable."));
$ curl -i localhost:5091/products/7/stock
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
"Stock service is unavailable."
Because the return type is Results<...>, the OpenAPI document listed both the 200 and the 500 response for this endpoint on its own. Route groups can now take ProducesProblem and ProducesValidationProblem as well.
The second addition is on the global error handling side. In .NET 6 we wrote our own middleware to pick a status code by exception type. In .NET 9 that mapping lives inside ExceptionHandlerOptions:
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = exception => exception switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
ArgumentException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
}
});
app.MapGet("/orders/{id:int}", (int id) =>
{
throw new KeyNotFoundException($"Order {id} was not found.");
});
$ curl -i localhost:5091/orders/5
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-2188e4b371511df95b4a69ebde5cc14d-6949c3dddc1a52b1-00"}
On the UI side there is also MapStaticAssets(), which compresses and fingerprints static files at build time. It hardly matters for a pure API, but if you serve an SPA from the same project it is worth a look.
What C# 13 Brings to API Code
C# 13 arrived with .NET 9. In everyday API code two things help the most: params now works not only with arrays but with collections such as ReadOnlySpan<T> and IEnumerable<T>, and there is a new System.Threading.Lock type for thread synchronization:
var log = new AuditLog();
log.Add("product created");
log.Add("price changed", "stock updated", "published");
Console.WriteLine(log.Count); // 4
public class AuditLog
{
private readonly Lock _lock = new();
private readonly List<string> _entries = [];
public void Add(params ReadOnlySpan<string> messages)
{
lock (_lock)
{
foreach (string message in messages)
{
_entries.Add(message);
}
}
}
public int Count
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
}
The lock statement recognizes the Lock type and uses a lighter path than locking on an object. params ReadOnlySpan<string> also spares you the cost of allocating an array behind the scenes on every call.
Common Pitfalls
First, HybridCache caches null values too. When I asked for a missing product twice, both calls returned 404 but the counter went up only once ({"databaseCalls":12}). So "this product does not exist" is also kept for five minutes; if the product is added in the meantime, nobody sees it. If you do not want that, return null through a path that is not cached, or clear the key with RemoveAsync when the record is added.
Second, trying to carry Swashbuckle habits over. IOperationFilter, ISchemaFilter or Swashbuckle annotations do not work with the built-in generator; their counterparts are transformers. If you have a large Swashbuckle configuration, plan the move as a separate task, or stay on Swashbuckle; it is not mandatory.
Third, forgetting that you publish the document only in Development. If your CI relies on /openapi/v1.json in production to generate client code, it will not find it there. To write the document to a file at build time there is the Microsoft.Extensions.ApiDescription.Server package.
Fourth, treating the field keyword that came with C# 13 as a regular feature. In .NET 9 it is a preview. When I used it in a net9.0 project without setting LangVersion to preview, the compiler reported CS9260: "Feature 'field keyword' is not available in C# 13.0. Please use language version 14.0 or greater." We will see the stable version in the next post.
Moving to .NET 9
The code side of upgrading a .NET 8 project is short:
<TargetFramework>net9.0</TargetFramework>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.22" />
You need the .NET 9 SDK or newer; I built the examples in this post with the 10.0.402 SDK targeting net9.0 and ran them on the .NET 9.0.22 runtime. Bump your Microsoft.* packages to 9.0.x. Since EF Core 9 targets .NET 8, you can upgrade EF on its own first if you like. If you are staying on Swashbuckle, check its current version; maintenance has resumed and a 10.x line is out.
The breaking changes that matter most for API projects: BinaryFormatter now throws on every call; in Startup.cs-era Host.CreateDefaultBuilder projects ValidateOnBuild and ValidateScopes are on by default in Development, so hidden captive dependencies can blow up at startup; and the SDK warns when you target net7.0.
Support changed in an important way. In September 2025 Microsoft extended STS support from 18 to 24 months, and that applied to .NET 9 too. .NET 9 reaches end of support on 2026-11-10, the same day as .NET 8 LTS. After that date the only supported GA release is .NET 10 LTS. A project on .NET 9 today should be heading for .NET 10; knowing the .NET 9 changes means that move is already half done.
A Small Experiment
★☆☆ Set up AddOpenApi with MapScalarApiReference in your own project, change the document title with a document transformer and see it in the info field of /openapi/v1.json.
★★☆ Build the counting ProductRepository above and send ten simultaneous requests first to the IMemoryCache endpoint, then to the HybridCache one. Then repeat with a product id that does not exist and watch how the counter behaves.
★★★ Add Redis to your project with AddStackExchangeRedisCache, restart the app without touching the endpoint code and use the counter to confirm the product comes from Redis. Hint: the memory layer resets when the app shuts down, the Redis layer does not.
In this post we saw that the OpenAPI document stays at 3.0 and that the field keyword is a preview; validation in minimal APIs is still our job. In the next post we move to .NET 10 LTS, where all three change.