Endpoint Filters & Route Group Conventions
Key Points
- Endpoint filters (
IEndpointFilter) wrap minimal-API endpoint execution — like MVC action filters but lighter, with no DI scoping baggage. - Route groups (
MapGroup("/api/v1")) bundle endpoints under a common prefix and apply filters/conventions/auth to all of them at once. - Order: middleware → routing → endpoint filters → endpoint handler. Filters run closest to the handler in a wrapping pattern (like middleware).
- Use filters for: validation, custom auth checks, response shaping, audit logging on a subset of endpoints.
- Use conventions for: metadata that applies to all endpoints in a group —
[Authorize], OpenAPI tags, rate-limit policies.
Concepts (deep dive)
Endpoint filter — basic
app.MapGet("/orders/{id}", (Guid id) => GetOrder(id))
.AddEndpointFilter(async (context, next) =>
{
var sw = Stopwatch.StartNew();
var result = await next(context);
_logger.LogInformation("{Endpoint} took {Ms} ms",
context.HttpContext.GetEndpoint()?.DisplayName, sw.ElapsedMilliseconds);
return result;
});
Or as a class:
public class TimingFilter : IEndpointFilter
{
private readonly ILogger<TimingFilter> _logger;
public TimingFilter(ILogger<TimingFilter> logger) => _logger = logger;
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var sw = Stopwatch.StartNew();
var result = await next(ctx);
_logger.LogInformation("{Path} {Ms} ms", ctx.HttpContext.Request.Path, sw.ElapsedMilliseconds);
return result;
}
}
app.MapGet("/orders", ...).AddEndpointFilter<TimingFilter>();
Route groups
var v1 = app.MapGroup("/api/v1")
.RequireAuthorization() // applies to all endpoints in group
.WithOpenApi() // OpenAPI metadata
.AddEndpointFilter<ValidationFilter>(); // filter for all
v1.MapGet("/orders", () => /* ... */);
v1.MapPost("/orders", (Order o) => /* ... */);
var orders = v1.MapGroup("/orders");
orders.MapGet("/{id}", (Guid id) => /* ... */);
orders.MapDelete("/{id}", (Guid id) => /* ... */).RequireAuthorization("Admin");
Groups nest. Conventions inherit but can be overridden per endpoint.
EndpointFilterInvocationContext
ctx.HttpContext // the HttpContext
ctx.Arguments // typed args passed to the handler
ctx.GetArgument<T>(0) // typed access to the Nth argument
You can mutate args before calling next:
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
if (ctx.GetArgument<int>(0) < 0)
return ValueTask.FromResult<object?>(Results.BadRequest("page must be ≥ 0"));
return next(ctx);
}
Order of filters
Filters wrap outside-in:
.AddEndpointFilter(A)
.AddEndpointFilter(B)
// Execution order:
// A.before → B.before → handler → B.after → A.after
Last .AddEndpointFilter runs closest to the handler. Equivalent to middleware ordering.
Filter vs middleware vs action filter
| Middleware | Endpoint Filter | MVC Action Filter | |
|---|---|---|---|
| Scope | Whole app or branch | Per endpoint or group | Per action/controller |
| API style | app.Use(...) | IEndpointFilter | IActionFilter |
| Knows arguments? | No (raw HTTP) | Yes (typed) | Yes (model-bound) |
| Used in MVC? | Yes | No | Yes |
| Used in Minimal? | Yes | Yes | No |
| Per-request DI scope | Yes | Yes | Yes |
Use middleware for cross-cutting (auth, compression). Use endpoint filters for per-endpoint logic with typed args. Use action filters in MVC.
Convention conventions
v1.WithName("V1Endpoints") // OpenAPI groups
.WithOpenApi() // sets summary/description from XML doc comments
.CacheOutput("PolicyName") // output caching
.RequireRateLimiting("api-policy") // rate limit
.DisableAntiforgery() // when applicable
.RequireCors("AllowedOrigins");
All applied to every endpoint in the group.
WithMetadata for custom data
Read in middleware/filter:
Powers custom routing decisions (e.g., "is this a public endpoint?").
Validation pattern with filter
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is null) return await next(ctx);
var result = await validator.ValidateAsync(arg);
if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());
return await next(ctx);
}
}
v1.MapPost("/orders", (Order o) => CreateOrder(o))
.AddEndpointFilter<ValidationFilter<Order>>();
FluentValidation-style without invoking it inside every handler.
Tenant-scoping filter
public class TenantFilter(ITenantResolver tenant) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var t = await tenant.ResolveAsync(ctx.HttpContext);
if (t is null) return Results.Forbid();
ctx.HttpContext.Items["Tenant"] = t;
return await next(ctx);
}
}
var apiV1 = app.MapGroup("/api/v1").AddEndpointFilter<TenantFilter>();
MVC parity
MVC controllers use [ServiceFilter], [TypeFilter], IFilterFactory, IAsyncActionFilter. Conceptually similar to endpoint filters but a different API surface — the underlying pipeline is the same EndpointMiddleware in routing.
Code: correct vs wrong
❌ Wrong: filter mutating context wrong
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
ctx.Arguments[0] = "modified"; // arguments collection is read-only at runtime; throws
return next(ctx);
}
✅ Correct: use HttpContext.Items
ctx.HttpContext.Items["Override"] = "modified";
// handler reads from HttpContext.Items, not from arg directly
❌ Wrong: heavy work in middleware that's only needed for some endpoints
app.Use(async (ctx, next) =>
{
/* expensive auth check for all routes — including /health */
await next();
});
✅ Correct: scope to a group
var api = app.MapGroup("/api").AddEndpointFilter<HeavyAuthFilter>();
app.MapGet("/health", () => "ok"); // not subject to filter
api.MapGet("/orders", () => /* ... */);
❌ Wrong: routing-dependent middleware before UseRouting
✅ Correct
app.UseRouting();
app.Use(/* now GetEndpoint() returns the matched endpoint */);
app.UseEndpoints(...);
Design patterns for this topic
Pattern 1 — Versioned route groups
var v1 = app.MapGroup("/api/v1").WithOpenApi();
var v2 = app.MapGroup("/api/v2").WithOpenApi().AddEndpointFilter<NewSchemaFilter>();
V1 stays stable; V2 evolves.
Pattern 2 — Reusable filter pack
public static class FilterPacks
{
public static RouteHandlerBuilder AsAuditedAdmin(this RouteHandlerBuilder b) =>
b.RequireAuthorization("Admin")
.AddEndpointFilter<AuditLogFilter>()
.WithTags("admin");
}
app.MapPost("/promote", () => /* ... */).AsAuditedAdmin();
Pattern 3 — Group-scoped DI keys
var tenants = app.MapGroup("/{tenant}/api").AddEndpointFilter<TenantFilter>();
tenants.MapGet("/users", (HttpContext ctx) => GetUsers(ctx.Items["Tenant"]));
Filter resolves once per request; handler reads pre-resolved value.
Pattern 4 — Telemetry filter
public class TelemetryFilter(IMeterFactory mf) : IEndpointFilter
{
private static readonly Counter<long> _calls = mf.Create("api").CreateCounter<long>("endpoint.calls");
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var sw = Stopwatch.StartNew();
try { return await next(ctx); }
finally { _calls.Add(1, new("path", ctx.HttpContext.Request.Path.Value!)); }
}
}
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Middleware | Universal, fast | No typed args, runs even on health checks unless branched |
| Endpoint filter | Per-endpoint, typed args | Minimal-API only |
| Route group convention | DRY, fluent | All endpoints in group affected |
| Action filter (MVC) | Familiar, model-bound args | MVC-only |
| Filter as class with DI | Testable, reusable | More ceremony than inline |
When to use / when to avoid
- Reach for endpoint filters when logic depends on typed args or applies only to a slice of routes.
- Reach for groups when several endpoints share auth/rate-limit/OpenAPI/prefix.
- Avoid complex business logic in filters — push it to handlers / services.
- Avoid middleware where a filter would scope better — health checks shouldn't pay the filter's tax.
Interview Q&A
Q1. Difference between middleware and endpoint filter? A. Middleware runs in the global pipeline regardless of which endpoint matched. Endpoint filters wrap a specific endpoint's invocation, with access to its typed arguments.
Q2. Order of filter execution? A. Filters wrap outside-in. The first added runs furthest from the handler; the last runs closest. Same model as middleware.
Q3. What's a route group? A. A logical bundle of endpoints under a common URL prefix where you can apply filters, conventions, auth policies, OpenAPI metadata to all members at once.
Q4. Why prefer endpoint filters over MVC action filters? A. They're for minimal APIs (no MVC pipeline). They're typed (you can read handler args directly). They're testable as plain classes.
Q5. Can a filter short-circuit? A. Yes — return an IResult from InvokeAsync without calling next(ctx). Common for validation/auth failures.
Q6. Where do filters fit relative to authentication/authorization middleware? A. After UseAuthentication / UseAuthorization middleware (which run once globally). Filters wrap the endpoint's typed handler invocation, after routing has matched the endpoint.
Q7. Can groups nest? A. Yes — app.MapGroup("/api").MapGroup("/v1") is /api/v1. Conventions cascade.
Q8. How does RequireAuthorization on a group differ from middleware-based auth? A. The middleware (UseAuthorization) enforces; the convention attaches an authorization policy to each endpoint's metadata so the middleware sees it. Without the convention, the middleware allows anonymous on those endpoints.
Gotchas / common mistakes
- Adding a filter that throws — exception flows out of
nextlike any other; wrap if you need to translate. - Mutating
ctx.Arguments— read-only. - Reading endpoint metadata before
UseRouting—GetEndpoint()returns null. - Filter applied at group level but expecting per-endpoint config — filter is shared.
- DI scope confusion — filters resolved per request; injected services follow normal lifetimes.
- Forgetting
.WithOpenApi()on a group — endpoints missing from Swagger. - Heavy filter on
/health— group filters apply unless health is mapped outside the group.