Groups and filters keep larger minimal API apps structured without repeating auth, prefixes and checks.
Route groups share a prefix and conventions among endpoints:
var todos = app.MapGroup("/api/todos").WithTags("Todos").RequireAuthorization();
todos.MapGet("/", GetAll);
todos.MapGet("/{id:int}", GetById);Anything you can apply to one endpoint (authorization, rate limiting, caching, OpenAPI metadata, filters) can be applied to a group, and groups can be nested. A common structure is one static class per feature with a MapTodoEndpoints(this IEndpointRouteBuilder app) extension method that creates the group, keeping Program.cs tiny.
Endpoint filters run around a minimal API handler, after binding, much like MVC action filters:
.AddEndpointFilter(async (ctx, next) => { /* before */ var result = await next(ctx); /* after */ return result; })ctx.Arguments / ctx.GetArgument<T>(index) give access to the bound parameters, and ctx.HttpContext to everything else. Return a result early to short-circuit (for example a validation problem), or call next and transform its result.
Filters vs middleware: middleware sees every request but knows nothing about the endpoint's parameters; filters run only for the endpoints they're attached to and see typed arguments. Use filters for argument validation, auditing specific operations or wrapping responses; use middleware for global concerns.
Filters added to a group run for all its endpoints, in the order they're added (outer first). Reusable filters implement IEndpointFilter.
Example
var app = WebApplication.Create(args);
app.MapTodoEndpoints(); app.Run();
// GET /api/todos/0 -> 400 {"title":"Bad Request","status":400,"detail":"id must be positive"}
// GET /api/todos/5 -> 200 "todo 5", header X-Audit: GET /api/todos/5
static class TodoEndpoints
{
public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todos")
.WithTags("Todos")
.AddEndpointFilter(async (ctx, next) => // runs for every endpoint
{
var req = ctx.HttpContext.Request;
ctx.HttpContext.Response.Headers["X-Audit"] = $"{req.Method} {req.Path}";
return await next(ctx);
});
group.MapGet("/{id:int}", (int id) => $"todo {id}")
.AddEndpointFilter(async (ctx, next) => // validates one argument
ctx.GetArgument<int>(0) <= 0
? TypedResults.Problem("id must be positive", statusCode: 400)
: await next(ctx));
}
}Remember
- MapGroup shares prefixes and conventions (auth, tags, filters)
- Organize features as extension methods that map their own group
- Endpoint filters see bound arguments and can short-circuit
- Use middleware for global concerns, filters for endpoint-specific ones