What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching
MapGroup, TypedResults and Results<T1,T2>, endpoint filters, AddProblemDetails, rate limiting and output caching in .NET 7, compared with .NET 6, plus C# 11 required members, raw string literals and migration notes.
Version note: This post covers what .NET 7 introduced; the code targets .NET 7.
.NET 7: The Release Where Minimal APIs Grew Up
.NET 6 introduced minimal APIs, but the first version was a bit bare. We repeated the same route prefix on every line, put validation by hand at the top of every handler, and went looking for a third-party package for rate limiting. .NET 7, released on November 8, 2022, closed most of those gaps inside the framework. C# 11 shipped in the same package.
In this post we'll look at route groups, typed results, endpoint filters, the problem details service, and the built-in rate limiting and output caching middleware. For each one we'll first look at how we wrote it in .NET 6, then at how it's written in .NET 7. At the end there's a short note on moving from 6 to 7.
Guess First
You put a rate limiter on an endpoint that allows at most 2 requests per 10 seconds, and you configure nothing else. When you send the third request within the same second, which status code comes back? Most people's first answer is 429 Too Many Requests. The answer is in the rate limiting section.
How We Wrote It in .NET 6
Here's the minimal API version of our ProductCatalog API in .NET 6:
using ProductCatalog.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductService, InMemoryProductService>();
var app = builder.Build();
app.MapGet("/api/products", (IProductService service) => Results.Ok(service.GetAll()));
app.MapGet("/api/products/{id:int}", (int id, IProductService service) =>
{
var product = service.GetById(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
app.MapPost("/api/products", (CreateProductRequest request, IProductService service) =>
{
// Validation inside every handler, by hand
if (string.IsNullOrWhiteSpace(request.Name) || request.Price <= 0)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Product"] = new[] { "Name is required and Price must be greater than zero." }
});
}
var product = service.Add(request.Name, request.Price);
return Results.Created($"/api/products/{product.Id}", product);
});
app.Run();
There are three problems. The /api/products prefix repeats on every line; if you want to move to /api/v2/products tomorrow, you'll change each one by hand. The handlers return IResult, so neither the compiler nor Swagger knows whether this endpoint returns 200 or 404. And validation is buried inside the handler; when you write a second POST endpoint, you'll copy it.
Route Groups with MapGroup
In .NET 7 you define the common prefix once:
var products = app.MapGroup("/api/products");
products.MapGet("/", (IProductService service) => TypedResults.Ok(service.GetAll()));
products.MapGet("/{id:int}", ProductEndpoints.GetById);
products.MapPost("/", (CreateProductRequest request, IProductService service) =>
{
var product = service.Add(request.Name, request.Price);
return TypedResults.Created($"/api/products/{product.Id}", product);
})
.AddEndpointFilter<ValidateProductFilter>();
MapGroup returns a RouteGroupBuilder. Every endpoint you define on it gets the group's prefix. The real power isn't the prefix, though; it's that the group itself can be configured like an endpoint: call products.RequireAuthorization() and every endpoint in the group is protected, call products.RequireRateLimiting("fixed") and the same limit applies to all of them. What a class-level [Route] and [Authorize] are for controllers, a group is for minimal APIs. Groups can be nested too, so you can open /products and /categories groups under an /api group.
TypedResults and Results<T1, T2>
You probably noticed we wrote TypedResults.Ok instead of Results.Ok above. Both produce the same response, but their return types differ: Results.Ok returns an IResult, while TypedResults.Ok returns an Ok<T>. For handlers that can return more than one result, .NET 7 also brought a union type:
using Microsoft.AspNetCore.Http.HttpResults;
namespace ProductCatalog.Api;
public static class ProductEndpoints
{
// The return type itself states which responses this endpoint can produce
public static Results<Ok<Product>, NotFound> GetById(int id, IProductService service)
{
var product = service.GetById(id);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
}
}
Results<Ok<Product>, NotFound> means "this method returns either a 200 with a Product or a 404". If you try to return a result that isn't in the list, the code doesn't compile. The framework also writes this information into the endpoint metadata, so you don't need a separate Produces<Product>(200) for the OpenAPI document. The biggest win, though, is on the testing side. In .NET 6, figuring out that an IResult was a 404 meant inspecting its inner type with reflection, because those types were internal. In .NET 7 they're all public:
[Fact]
public void GetById_UnknownId_ReturnsNotFound()
{
var result = ProductEndpoints.GetById(99, new InMemoryProductService());
Assert.IsType<NotFound>(result.Result);
}
[Fact]
public void CreateProductRequest_MissingName_FailsToDeserialize()
{
// C# 11 raw string literal: write the JSON without escape characters
var json = """
{ "price": 10 }
""";
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<CreateProductRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
The second test uses two C# 11 features at once. A raw string literal lets you write the JSON as is between triple quotes. And CreateProductRequest is now defined with required properties:
public class CreateProductRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
}
System.Text.Json 7 respects this keyword. When a body arrives without a name field, deserialization fails; when I ran the app and sent such a POST, I got a 400, and the log had this line: JSON deserialization for type 'ProductCatalog.Api.CreateProductRequest' was missing required properties, including the following: name. All three tests passed.
Endpoint Filters and AddProblemDetails
Let's move validation out of the handler into a reusable piece. The minimal API counterpart of an action filter in controllers is the endpoint filter:
namespace ProductCatalog.Api;
public class ValidateProductFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// The handler's first argument is CreateProductRequest
var request = context.GetArgument<CreateProductRequest>(0);
if (string.IsNullOrWhiteSpace(request.Name) || request.Price <= 0)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["Product"] = new[] { "Name is required and Price must be greater than zero." }
});
}
// All good: move on to the next filter or the handler
return await next(context);
}
}
The filter runs before the handler and can access its arguments. If it returns a result, the handler never runs; if it calls next, the chain continues. It's the same idea as the middleware we saw earlier, just wrapped around a single endpoint. When I sent {"name":"","price":0}, the response was a 400 without ever entering the handler.
.NET 7 has one more move for making error responses consistent. Let's add these to Program.cs:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
AddProblemDetails() registers IProblemDetailsService. The exception handler, status code pages and developer exception page middleware now write the response body as ProblemDetails through this service. To see the difference, let's ask for an unknown product. In .NET 6, Results.NotFound() returned a 404 with no body:
HTTP/1.1 404 Not Found
Content-Length: 0
In .NET 7, with these three lines, the same request turns into this:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404}
A thrown exception arrives in the same shape as a 500: {"type":"https://tools.ietf.org/html/rfc7231#section-6.6.1","title":"An error occurred while processing your request.","status":500}. Parameterless UseExceptionHandler() also depends on AddProblemDetails(). In .NET 6 this line threw an InvalidOperationException on startup, because you had to give it an error page path or your own handler. In .NET 7 I removed AddProblemDetails() and tried: the same exception appears, with "or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'" added to the end of the message.
Rate Limiting Middleware
In the CORS and security post we noted that rate limiting arrived with .NET 7. Here's that middleware:
using Microsoft.AspNetCore.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 2;
limiter.Window = TimeSpan.FromSeconds(10);
// No queue: once the limit is reached, requests are rejected immediately
limiter.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
products.MapGet("/limited", () => TypedResults.Ok("ok"))
.RequireRateLimiting("fixed");
Fixed window is the simplest algorithm: 2 permits in a 10-second window, and the counter resets when the window ends. There are also sliding window, token bucket and concurrency limiters. Now the answer to the guess. I sent three requests in a row:
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 503 Service Unavailable
The default rejection code isn't 429, it's 503. That tells the client "the server is down" instead of "you sent too many requests", so a client with retry logic may behave the wrong way. The right move is to set it explicitly:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 2;
limiter.Window = TimeSpan.FromSeconds(10);
limiter.QueueLimit = 0;
});
});
// The third request now gets:
// HTTP/1.1 429 Too Many Requests
// Content-Type: application/problem+json
// {"status":429}
The body comes back as ProblemDetails thanks to the UseStatusCodePages() we just added. If you want a more detailed response or a Retry-After header, use the OnRejected callback. One note: these counters live in the memory of each app instance. If you run three instances, the effective limit is three times higher.
Output Caching
In the caching post we saw the response caching middleware. That middleware works off HTTP cache headers and can be bypassed by the client sending Cache-Control: no-cache. .NET 7's output caching leaves the decision to the server:
builder.Services.AddOutputCache();
var app = builder.Build();
app.UseOutputCache();
products.MapGet("/featured", () => TypedResults.Ok(new { GeneratedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") }))
.CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(30)));
The endpoint returns the current time every time it runs. I sent two requests one second apart:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"generatedAt":"21:20:01.844"}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Age: 1
{"generatedAt":"21:20:01.844"}
The second response carries the same time, and the Age: 1 header says the answer has been sitting in the cache for a second. The handler didn't run a second time. The default policy is deliberately cautious: only GET and HEAD requests and only 200 responses are cached; responses that set cookies and authenticated requests stay out of the cache. You can also evict a group of entries at once with tags (EvictByTagAsync) and keep separate entries per query parameter with SetVaryByQuery.
Moving to .NET 7
In the csproj, the TargetFramework value goes from net6.0 to net7.0, and the Microsoft.AspNetCore.*, Microsoft.Extensions.* and EF Core packages move to the 7.0 line. The last patch in the 7.0 line is 7.0.20. You need SDK 7.0.100 or later; if you use global.json, update it too. Since the Program.cs skeleton stays the same, this move is much calmer than going from 3.1 to 6.
The item in the ASP.NET Core 7 breaking change list that matters most for a Web API is that controller action parameters are now also inferred from DI. Look at this action:
[ApiController]
[Route("api/catalog")]
public class CatalogController : ControllerBase
{
// No [FromServices]
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll(IProductService service)
{
return Ok(service.GetAll());
}
}
I ran the same code on both versions. In .NET 6, [ApiController] tries to bind this complex type parameter from the body, and a GET request with no body comes back with 415 Unsupported Media Type. In .NET 7, the framework sees that IProductService is registered in the container and resolves the parameter from there; the response is a 200 with the product list. Minimal APIs already worked this way, and controllers caught up. If you have a rare action that also accepts a container-registered type from the body, you can go back to the old behavior with ApiBehaviorOptions.DisableImplicitFromServicesParameters.
There's one more item in the list worth knowing: Kestrel no longer binds to https://localhost:5001 by default, even when a development certificate is present. You need to give the HTTPS address explicitly through launchSettings.json, ASPNETCORE_URLS or --urls.
On the support side, there's an interesting twist. .NET 7 was an STS release and, under the rule at the time, got 18 months of support: it went out of support on May 14, 2024. That's about six months before .NET 6 LTS, which came out before it, on November 12, 2024. That's why many teams never used 7 in production and went straight from 6 to 8. But knowing what .NET 7 brought is essential, because every API in this post lives on unchanged in .NET 8 and later.
Common Pitfalls
The rate limiter returning 503 is the most common one; don't forget to set RejectionStatusCode. The second is middleware order. If you use endpoint-specific RequireRateLimiting or [EnableRateLimiting], UseRateLimiter must come after UseRouting. Since WebApplication adds routing at the start, it's usually not an issue, but if you call UseRouting() yourself, watch the order.
Order is critical for output cache too. In an app that uses authentication, UseOutputCache must come after UseAuthentication and UseAuthorization; otherwise content from the cache can be served to an unauthorized user. If you use CORS, it also goes after UseCors.
The third is the order of AddEndpointFilter. When you add more than one filter to an endpoint, the first one added runs outermost. If you add the validation filter before a logging filter, invalid requests never get logged. Also, the index in context.GetArgument<T>(0) depends on the handler's parameter order; when you change the handler's signature, check the filter too.
A Small Experiment
★☆☆ Move the three .NET 6 endpoints under MapGroup("/api/products") and replace every Results.* call with TypedResults.*. Then add RequireRateLimiting to the group and see the limit applied to every endpoint.
★★☆ Run the rate limiter first without settings to see the 503, then with RejectionStatusCode to see the 429. After that, add a Retry-After header inside OnRejected.
★★★ Add Tag("featured") to the /featured endpoint and write a POST /api/cache/featured/purge endpoint that calls IOutputCacheStore.EvictByTagAsync. Verify that the generatedAt value changes on the first request after you clear the cache.
.NET 7 matured minimal APIs, but it was still an STS release. In the next post we move to .NET 8, an LTS: keyed DI, IExceptionHandler, Identity API endpoints, Native AOT, TimeProvider and C# 12's primary constructors.