Endpoint Filters

Wrap minimal API handlers with IEndpointFilter to check bound arguments, short-circuit with a result and attach filters cheaply with factories.

Syntaxorders.MapPost("/", CreateOrder) .AddEndpointFilter(async (ctx, next) => ctx.GetArgument<NewOrder>(0).Lines.Count == 0 ? TypedResults.BadRequest("An order needs at least one line.") : await next(ctx));

An endpoint filter runs inside the endpoint, around the handler call. By the time it runs, routing has chosen the endpoint and every parameter has been bound, so the filter sees typed arguments, which middleware never does. A filter implements one method:

ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)

context.HttpContext is the request, context.Arguments holds the bound values in parameter order, and context.GetArgument<T>(index) reads one. Code before await next(context) runs on the way in, code after it on the way out. next returns the handler's raw return value (an Order, a string, an IResult), which the framework writes after the whole chain finishes.

Order and short-circuiting

Filters run in the order they are added, outermost first, and group filters run before endpoint filters. Returning a value without calling next short-circuits: return TypedResults.Problem(statusCode: 403) stops the handler from running and that result is written instead.

Three ways to add one

FormCreatedUse for
AddEndpointFilter(async (ctx, next) => ...)Once, a delegateShort, local checks
AddEndpointFilter<TFilter>()Per invocation from RequestServices, so constructor injection of scoped services worksReusable filters with dependencies
AddEndpointFilterFactory((factoryContext, next) => ...)Factory runs once per endpoint when endpoints are builtFilters that depend on the handler's signature

A factory receives factoryContext.MethodInfo, so it can find the customerId parameter's index once, or return next unchanged when the handler has nothing to check. Attached to a whole group, it costs nothing on endpoints it does not apply to.

Filters or middleware

NeedChoose
Typed access to bound arguments, per-endpoint or per-group behaviourEndpoint filter
Every request, including 404s, static files, controllers and requests that fail bindingMiddleware

Filters run only for minimal API route handlers. If binding fails (a 400 or 415), or AddValidation() rejects the arguments, your filters never run: built-in validation executes before them. Request logging and correlation IDs therefore belong in middleware.

Failure modes

  • GetArgument<int>(0) hard-codes a position. Someone adds a parameter in front and the filter throws InvalidCastException on every call. Resolve indexes by name or type in a factory.
  • A filter that catches exceptions from next and returns 500 hides them from the exception handler and its logging. Let them propagate.
  • An AddEndpointFilter<TFilter>() filter is a new instance on every call, so a counter or cache kept in its fields resets each request. Shared state belongs in a singleton service the filter receives.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Reject POST /customers/42/orders unless the caller's customer claim is 42, with one filter factory attached to the customers route group.
  • Require a GUID Idempotency-Key header on every order-creating endpoint and return 400 without running the handler when it is missing.
  • Run FluentValidation validators for request bodies through a generic ValidationFilter<T> registered per endpoint.

More examples

A generic validation filter for FluentValidation

The filter is created per request from <code>RequestServices</code>, so it can take a scoped <code>IValidator&lt;T&gt;</code> in its constructor. Finding the argument with <code>OfType&lt;T&gt;()</code> avoids a hard-coded index. Use this when a team already uses FluentValidation for rules DataAnnotations cannot express well; for attribute-based rules, the built-in <code>AddValidation()</code> needs no filter at all.

Example · csharp
using FluentValidation;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IValidator<CreateInvoice>, CreateInvoiceValidator>();
var app = builder.Build();

app.MapPost("/invoices", (CreateInvoice invoice) => TypedResults.Created($"/invoices/{invoice.Number}", invoice))
    .AddEndpointFilter<ValidationFilter<CreateInvoice>>();

app.Run();

public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var argument = context.Arguments.OfType<T>().FirstOrDefault();
        if (argument is null)
            return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: $"Missing {typeof(T).Name}.");

        var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
        return result.IsValid
            ? await next(context)
            : TypedResults.ValidationProblem(result.ToDictionary());
    }
}

public record CreateInvoice(string Number, string CustomerId, decimal Amount, DateOnly DueDate);

public sealed class CreateInvoiceValidator : AbstractValidator<CreateInvoice>
{
    public CreateInvoiceValidator()
    {
        RuleFor(x => x.Number).NotEmpty().Matches(@"^INV-\d{4}-\d{4}$");
        RuleFor(x => x.Amount).GreaterThan(0);
        RuleFor(x => x.DueDate).GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow));
    }
}

Unit testing a filter without a server

<code>DefaultEndpointFilterInvocationContext</code> is public, so a filter can be tested by building a context from a <code>DefaultHttpContext</code> and the arguments, then passing a lambda as <code>next</code>. The tests check both directions: the short-circuit returns the problem result and never calls the handler, and the happy path returns exactly what <code>next</code> returned.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;

public class MaxQuantityFilterTests
{
    [Fact]
    public async Task Short_circuits_when_quantity_exceeds_the_limit()
    {
        var context = new DefaultEndpointFilterInvocationContext(new DefaultHttpContext(), new OrderLine("KET-001", 500));
        var handlerCalled = false;

        var result = await new MaxQuantityFilter(100).InvokeAsync(context, _ =>
        {
            handlerCalled = true;
            return ValueTask.FromResult<object?>(TypedResults.Ok());
        });

        Assert.False(handlerCalled);
        var problem = Assert.IsType<ProblemHttpResult>(result);
        Assert.Equal(422, problem.StatusCode);
    }

    [Fact]
    public async Task Calls_the_handler_for_a_normal_quantity()
    {
        var context = new DefaultEndpointFilterInvocationContext(new DefaultHttpContext(), new OrderLine("KET-001", 3));

        var result = await new MaxQuantityFilter(100).InvokeAsync(context, _ => ValueTask.FromResult<object?>("handled"));

        Assert.Equal("handled", result);
    }
}

public sealed class MaxQuantityFilter(int max) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var line = context.Arguments.OfType<OrderLine>().Single();
        return line.Quantity > max
            ? TypedResults.Problem(statusCode: 422, title: $"Quantity is limited to {max} per line.")
            : await next(context);
    }
}

public record OrderLine(string Sku, int Quantity);

Pitfall: filters never see requests that fail binding

The request delegate binds parameters before it invokes the filter chain, so a bad <code>page</code> value produces a 400 that no filter observes. If request logging lived in a filter, exactly the malformed requests you most want to see would be missing from the logs. The middleware version logs every request in a <code>finally</code> block, because in Development <code>ThrowOnBadRequest</code> makes a binding failure throw <code>BadHttpRequestException</code> through it; the <code>catch</code> copies its status code so the log shows 400. The filter stays responsible only for logic that needs the bound <code>id</code>.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Middleware sees every request: 404s, 405s, binding failures and filter short-circuits.
// In Development ThrowOnBadRequest turns a binding failure into an exception thrown through
// here, so the log line sits in finally to run for those requests too.
app.Use(async (context, next) =>
{
    var started = TimeProvider.System.GetTimestamp();
    try
    {
        await next(context);
    }
    catch (BadHttpRequestException ex)
    {
        context.Response.StatusCode = ex.StatusCode; // so the log line shows 400, not the default 200
        throw;
    }
    finally
    {
        var elapsed = TimeProvider.System.GetElapsedTime(started);
        app.Logger.LogInformation("{Method} {Path} -> {StatusCode} in {ElapsedMs:0.0} ms",
            context.Request.Method, context.Request.Path, context.Response.StatusCode, elapsed.TotalMilliseconds);
    }
});

app.MapGet("/orders/{id:int}", (int id, int page) => new { id, page })
    // Never runs for GET /orders/5 (missing page) or GET /orders/5?page=x: binding fails first with 400.
    .AddEndpointFilter(async (context, next) =>
    {
        app.Logger.LogInformation("Filter sees order {OrderId}", context.GetArgument<int>(0));
        return await next(context);
    });

app.Run();

Discussion

  • Be the first to comment on this lesson.