Skip to content

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

app.MapGet("/", () => "ok")
   .WithMetadata(new MyTagAttribute("public"));

Read in middleware/filter:

var tag = ctx.HttpContext.GetEndpoint()?.Metadata.GetMetadata<MyTagAttribute>();

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

app.Use(/* needs HttpContext.GetEndpoint */); // null — endpoint not resolved yet
app.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 next like any other; wrap if you need to translate.
  • Mutating ctx.Arguments — read-only.
  • Reading endpoint metadata before UseRoutingGetEndpoint() 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.

Further reading