Endpoint filters in minimal APIs
When you move from traditional controller-based ASP.NET Core to minimal APIs, you lose something familiar: action filters. The good news is that endpoint filters in minimal APIs give you the exact same power, but in a way that feels more natural to the minimal API pattern. Instead of decorating classes and methods, you chain filters directly onto your route handlers—making your cross-cutting concerns visible right where they matter.
What are endpoint filters?
Endpoint filters are middleware-like components that run before and after your handler logic. They intercept the request, optionally modify it or short-circuit execution, then let the handler run, and finally inspect or transform the response. Unlike global middleware, filters are scoped to specific endpoints or route groups, which means you apply them only where you need them.
Think of them as a way to wrap your business logic with validation, logging, caching, or any other cross-cutting concern. Instead of scattering try-catch blocks or manual checks throughout your handlers, you extract that logic into a reusable filter that you attach via fluent API.
The core abstraction is IEndpointFilter:
public interface IEndpointFilter
{
ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context);
}
Your filter receives a context that contains the endpoint, the HTTP context, and the arguments passed to your handler. You call context.Invoke() to continue the pipeline, and you can inspect or modify the result afterward.
Run before and after the handler
The real power of endpoint filters is that you control execution flow. Before calling the next filter in the chain, you can validate input, check permissions, or set up dependencies. After the handler completes, you can log results, transform responses, or handle errors.
Here's a concrete example: imagine an order creation endpoint. You want to log when the request arrives, validate the payload, let the handler run, then log the response. All of that can live in one filter:
public class LoggingAndValidationFilter : IEndpointFilter
{
private readonly ILogger<LoggingAndValidationFilter> _logger;
public LoggingAndValidationFilter(
ILogger<LoggingAndValidationFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context)
{
var request = context.Arguments[0];
_logger.LogInformation(
"Incoming request: {Request}", request);
// Call the next filter/handler in the pipeline
var result = await context.Invoke();
_logger.LogInformation(
"Response result: {Result}", result);
return result;
}
}
Notice that context.Arguments holds the handler parameters. If your handler is IResult Create(CreateOrderRequest req), then context.Arguments[0] is that CreateOrderRequest. After you call context.Invoke(), the handler runs, and you get back the result.
This makes it dead simple to audit every request and response for a specific endpoint without touching the handler itself.
Perfect for validation, auditing, and idempotency
Three patterns emerge as the most practical use cases for endpoint filters.
Validation
Instead of bloating your handler with validation logic, extract it into a generic filter:
public class ValidationFilter<T> : IEndpointFilter
where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context)
{
var request = context.Arguments[0] as T;
if (request == null)
return Results.BadRequest("Invalid payload");
var result = await _validator.ValidateAsync(request);
if (!result.IsValid)
{
return Results.BadRequest(
result.Errors.Select(e => e.ErrorMessage));
}
return await context.Invoke();
}
}
Now every endpoint that needs validation just declares which validator to use. Your handler remains clean and focused on business logic.
Auditing
Auditing is another natural fit. You want to record who did what, when, and what changed. A filter can capture the user, endpoint, payload, and result:
public class AuditFilter : IEndpointFilter
{
private readonly IAuditService _auditService;
private readonly IHttpContextAccessor _httpContextAccessor;
public AuditFilter(
IAuditService auditService,
IHttpContextAccessor httpContextAccessor)
{
_auditService = auditService;
_httpContextAccessor = httpContextAccessor;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context)
{
var httpContext = _httpContextAccessor.HttpContext;
var userId = httpContext?.User?.FindFirst("sub")?.Value;
var endpoint = context.HttpContext.GetEndpoint();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var result = await context.Invoke();
stopwatch.Stop();
await _auditService.LogAsync(new AuditEntry
{
UserId = userId,
Endpoint = endpoint?.DisplayName,
Payload = context.Arguments[0],
Result = result,
ExecutionMs = stopwatch.ElapsedMilliseconds,
Timestamp = DateTime.UtcNow
});
return result;
}
}
This filter records every request without requiring a single line of audit code in your handlers.
Idempotency
Idempotency keys ensure that a client can safely retry a request without side effects. A filter can check if a key was already processed and return the cached result:
public class IdempotencyFilter : IEndpointFilter
{
private readonly IIdempotencyStore _store;
public IdempotencyFilter(IIdempotencyStore store)
{
_store = store;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context)
{
var httpContext = context.HttpContext;
if (!httpContext.Request.Headers
.TryGetValue("Idempotency-Key", out var key))
{
return await context.Invoke();
}
var cached = await _store.GetAsync(key);
if (cached != null)
{
return cached;
}
var result = await context.Invoke();
await _store.SaveAsync(key, result);
return result;
}
}
Now any endpoint can opt into idempotency by attaching this filter, essential for payment and order APIs where duplicate requests must never trigger duplicate charges.
Apply per endpoint or per route group
One of the strengths of endpoint filters is flexibility. You can attach them at the endpoint level for fine-grained control, or at the route group level to apply them to many endpoints at once.
Per-endpoint filters
For a single endpoint, chain the filter using AddEndpointFilter:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ValidationFilter<CreateOrderRequest>>();
builder.Services.AddScoped<IValidator<CreateOrderRequest>,
CreateOrderValidator>();
var app = builder.Build();
var orders = app.MapGroup("/orders")
.WithName("Orders")
.WithOpenApi();
orders.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>()
.AddEndpointFilter<AuditFilter>()
.WithName("CreateOrder")
.Produces<OrderResponse>();
IResult Create(CreateOrderRequest req,
IOrderService service)
=> Results.Ok(service.Create(req));
app.Run();
This is explicit and clear: anyone reading the route definition sees exactly which filters apply. You can chain multiple filters, and they execute in order.
Per-group filters
If multiple endpoints share the same filters, apply them to the route group instead:
var orders = app.MapGroup("/orders")
.WithName("Orders")
.WithOpenApi()
.AddEndpointFilter<AuditFilter>()
.AddEndpointFilter<AuthenticationFilter>();
orders.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>()
.WithName("CreateOrder");
orders.MapGet("/{id}", GetById)
.WithName("GetOrder");
orders.MapPut("/{id}", Update)
.AddEndpointFilter<ValidationFilter<UpdateOrderRequest>>()
.WithName("UpdateOrder");
Now AuditFilter and AuthenticationFilter run on all three endpoints. The CreateOrder and UpdateOrder endpoints also run their validation filters. GetById skips validation because it takes a route parameter, not a body. This gives you the perfect balance between reusability and specificity.
Common mistakes
Not registering the filter in the DI container: If you use AddEndpointFilter<MyFilter>() but forget to register MyFilter in builder.Services, you'll get a runtime error. Always add your filters to the service collection.
Assuming argument order: context.Arguments[0] refers to the first parameter of your handler. If you miscount or your handler signature changes, you'll be reading the wrong argument. Consider using argument names or creating helpers to avoid this fragility.
Not handling null results: A filter that modifies or validates arguments must account for the fact that a handler might return null. Check the result type before casting or transforming it.
Over-filtering: Filters add overhead. Don't attach a filter to every endpoint unless it provides real value. A validation filter on a GET endpoint that takes no body is wasted cycles.
Forgetting about exception handling: If your filter or handler throws an exception, it propagates up through the filter chain. If you want to catch and handle exceptions in a filter (e.g., to return a standardized error response), wrap context.Invoke() in a try-catch:
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context)
{
try
{
return await context.Invoke();
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
return Results.StatusCode(500);
}
}
Key takeaways
- Endpoint filters replace action filters in minimal APIs. They run before and after your handler, giving you a clean place for validation, auditing, logging, and other cross-cutting concerns.
- Use
context.Argumentsto access handler parameters andcontext.Invoke()to proceed down the pipeline. This makes it straightforward to intercept and transform both requests and responses. - Validation, auditing, and idempotency are the three most practical patterns. Extract them into reusable filters so your handlers stay focused on business logic.
- Apply filters per-endpoint for precision or per-group for consistency. Start with group-level filters for shared concerns, then add endpoint-specific filters only where needed.
- Always register your filters in dependency injection. Filters are scoped, so you can inject any service your application needs without worrying about lifetime mismatches.
- Remember that filters add latency. Profile your critical paths to ensure filter overhead is acceptable, and avoid stacking too many filters on hot endpoints.






Comments
Loading comments…