Minimal API Route Groups and Filters in ASP.NET Core
As minimal API applications grow, Program.cs can become unwieldy. Route groups solve the organisational problem by letting you group related endpoints with shared prefixes, metadata, and filters. Endpoint filters add the cross-cutting behaviour that controller action filters have provided for years.
Route Groups
A route group applies a common prefix and configuration to a set of endpoints:
var app = builder.Build();
var products = app.MapGroup("/api/products");
products.MapGet("/", async (AppDbContext db) =>
await db.Products.ToListAsync());
products.MapGet("/{id}", async (int id, AppDbContext db) =>
await db.Products.FindAsync(id) is Product p
? Results.Ok(p)
: Results.NotFound());
products.MapPost("/", async (CreateProductRequest request, AppDbContext db) =>
{
var product = new Product { Name = request.Name, Price = request.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/{product.Id}", product);
});
All three endpoints share the /api/products prefix without repeating it.
Nested Groups
Groups can be nested for more complex URL structures:
var api = app.MapGroup("/api");
var products = api.MapGroup("/products");
var orders = api.MapGroup("/orders");
products.MapGet("/", GetProducts);
orders.MapGet("/", GetOrders);
// Results in /api/products and /api/orders
Applying Metadata to Groups
Groups can carry metadata that applies to all endpoints within them — tags for OpenAPI, authorisation policies, rate limiting:
var admin = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly")
.WithTags("Administration")
.AddEndpointFilter<AuditLogFilter>();
admin.MapGet("/users", GetUsers);
admin.MapDelete("/users/{id}", DeleteUser);
Every endpoint in the admin group requires the AdminOnly policy, appears under the "Administration" tag in Swagger, and runs through the audit log filter.
Endpoint Filters
Endpoint filters are the minimal API equivalent of MVC action filters. They run before and after the endpoint handler, enabling validation, logging, transformation, and short-circuiting.
Basic Filter
app.MapPost("/api/products", async (CreateProductRequest request, AppDbContext db) =>
{
var product = new Product { Name = request.Name, Price = request.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/{product.Id}", product);
})
.AddEndpointFilter(async (context, next) =>
{
var request = context.GetArgument<CreateProductRequest>(0);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Name"] = ["Name is required."]
});
}
return await next(context);
});
Typed Filters
For reusable filters, implement IEndpointFilter:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is null)
{
return Results.BadRequest("Request body is required.");
}
var validationContext = new ValidationContext(argument);
var errors = new List<ValidationResult>();
if (!Validator.TryValidateObject(argument, validationContext, errors, validateAllProperties: true))
{
var problemErrors = errors
.Where(e => e.MemberNames.Any())
.GroupBy(e => e.MemberNames.First())
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage ?? "Invalid value.").ToArray());
return Results.ValidationProblem(problemErrors);
}
return await next(context);
}
}
Apply it:
products.MapPost("/", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Logging Filter
A filter that logs request and response details:
public class RequestLoggingFilter : IEndpointFilter
{
private readonly ILogger<RequestLoggingFilter> _logger;
public RequestLoggingFilter(ILogger<RequestLoggingFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
_logger.LogInformation("Handling {Method} {Path}",
httpContext.Request.Method, httpContext.Request.Path);
var stopwatch = Stopwatch.StartNew();
var result = await next(context);
stopwatch.Stop();
_logger.LogInformation("Completed {Method} {Path} in {Elapsed}ms",
httpContext.Request.Method, httpContext.Request.Path, stopwatch.ElapsedMilliseconds);
return result;
}
}
Filter Ordering
Filters execute in the order they're added. The first filter added is the outermost — it runs first on the way in and last on the way out:
app.MapGet("/api/data", GetData)
.AddEndpointFilter<LoggingFilter>() // Runs first (outermost)
.AddEndpointFilter<AuthorisationFilter>() // Runs second
.AddEndpointFilter<CachingFilter>(); // Runs third (innermost)
Organising Endpoints in Static Classes
To keep Program.cs clean, move endpoint definitions to dedicated classes:
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
group.MapPut("/{id}", Update);
group.MapDelete("/{id}", Delete);
return group;
}
private static async Task<IResult> GetAll(AppDbContext db) =>
Results.Ok(await db.Products.ToListAsync());
private static async Task<IResult> GetById(int id, AppDbContext db) =>
await db.Products.FindAsync(id) is Product p
? Results.Ok(p)
: Results.NotFound();
// ... other handlers
}
app.MapProductEndpoints();
app.MapOrderEndpoints();
Route groups and filters bring the organisational benefits of controllers to minimal APIs without the ceremony. Groups handle URL structure and shared configuration; filters handle cross-cutting behaviour. Together, they make minimal APIs viable for applications of any size.