Action, Exception and Result Filters
Write MVC filters that run around actions, map exceptions and decorate results, register them with DI, and predict the order they run in.
public sealed class TimingFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ var executed = await next(); }
}
options.Filters.Add<TimingFilter>();Filters are MVC's own pipeline. They run after middleware has routed the request to a controller action, and they see things middleware cannot: the chosen action, its bound arguments, ModelState and the action result before it is written.
The five kinds
| Filter | Runs | Typical use |
|---|---|---|
Authorization (IAsyncAuthorizationFilter) | First | Rare in APIs: [Authorize] is enforced by the authorization middleware |
Resource (IAsyncResourceFilter) | Around model binding and everything after | Short-circuit before binding: idempotency replays, feature switches |
Action (IAsyncActionFilter) | Around the action, after binding and validation | Inspect or change arguments, auditing, timing |
Exception (IAsyncExceptionFilter) | When binding, an action filter or the action throws | Map domain exceptions to problem details |
Result (IAsyncResultFilter) | Around writing the result | Response headers, envelope wrapping |
Implement the async interface. If a class implements both the sync and async versions, only the async one is called.
Order
Filters nest like middleware. Within one kind, scope decides: global, then controller, then action on the way in, and the reverse on the way out. IOrderedFilter.Order overrides scope; lower runs first (outer). Three consequences show up in production:
ModelStateInvalidFilterhas order -2000, so with[ApiController]your action filters never run for an invalid request.- Exception filters run innermost first (action, controller, global). Setting
context.ResultandExceptionHandled = truestops the outer ones. - Result filters do not run when an exception filter produced the result or a resource filter short-circuited. Implement
IAlwaysRunResultFilterfor headers that must be on every response.
Exception filters do not see exceptions thrown by middleware, resource filters, result filters or result execution (a JSON serialisation failure, for example). Keep one global IExceptionHandler as the safety net and use exception filters only for mappings that belong to a controller.
Filters and dependency injection
An attribute is built by the runtime from its constructor arguments, so a filter attribute cannot take services in its constructor. Choose one of:
[ServiceFilter(typeof(AuditFilter))]: resolves the filter from DI. It must be registered, and its registered lifetime applies.[TypeFilter(typeof(AuditFilter), Arguments = ["orders"])]: creates it withActivatorUtilitiesper request, mixing services and literal arguments, no registration needed.- Global:
options.Filters.Add<AuditFilter>()(type-activated per request) oroptions.Filters.AddService<AuditFilter>()(from DI).
The classic bug is options.Filters.Add(new AuditFilter(...)) or a filter registered as a singleton that captures a scoped DbContext: one context shared by concurrent requests, which fails with threading errors under load.
Filters, endpoint filters or middleware
MVC filters apply only to controllers; minimal APIs have endpoint filters instead. If a concern needs no MVC context (correlation ids, security headers, request logging), write middleware so it covers every endpoint.
Example
When to use it
- Add a Server-Timing header to every controller response so slow actions are visible in browser dev tools and access logs.
- Map OutOfStockException from the inventory domain to a 409 problem details response without try/catch in each action.
- Replay the stored 201 response for a repeated POST /api/orders with the same Idempotency-Key before the body is bound.
More examples
A result filter that must run on every response
Anything done after <code>await next()</code> in a result filter is too late to change headers, because the response has started. Implementing <code>IAsyncAlwaysRunResultFilter</code> instead of <code>IAsyncResultFilter</code> matters for error responses: a 409 produced by an exception filter or a 400 produced by a short-circuit would otherwise go out without the header, which is exactly the response you want to trace.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options => options.Filters.Add<ApiVersionHeaderFilter>());
var app = builder.Build();
app.MapControllers();
app.Run();
// IAlwaysRunResultFilter also runs when a resource or exception filter produced the result,
// which a plain IAsyncResultFilter would skip.
public sealed class ApiVersionHeaderFilter(IConfiguration configuration) : IAsyncAlwaysRunResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
// Headers must be set before next(): after it the body has started and headers are read-only.
context.HttpContext.Response.Headers["X-Build"] = configuration["Build:Version"] ?? "dev";
if (context.Result is ObjectResult { Value: ProblemDetails problem })
problem.Extensions["build"] = configuration["Build:Version"] ?? "dev";
await next();
}
}
A resource filter that short-circuits before binding
This is the MVC part of idempotency only. A resource filter runs after authorization and before model binding, so setting <code>context.Result</code> answers a repeat without binding the body or running the action, and <code>ResourceExecutedContext</code> reports both the result and any unhandled exception. <code>TypeFilterAttribute</code> gives a tidy <code>[Idempotent]</code> attribute whose filter still gets the store from DI. The store follows net-idempotent-consumers: keys scoped to the caller, a body fingerprint (422 on mismatch), an in-progress lease (409 while held), 2xx and 4xx replayed, and the key released on 5xx or an exception.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
public sealed class IdempotentAttribute() : TypeFilterAttribute(typeof(IdempotencyFilter));
// Key scope, body fingerprint, lease and replay rules live behind this interface.
public interface IIdempotencyStore
{
Task<IActionResult?> TryClaimAsync(HttpContext http, string key);
Task CompleteAsync(HttpContext http, string key, IActionResult result);
Task ReleaseAsync(HttpContext http, string key);
}
public sealed class IdempotencyFilter(IIdempotencyStore store) : IAsyncResourceFilter
{
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
var http = context.HttpContext;
if (!http.Request.Headers.TryGetValue("Idempotency-Key", out var values) || values.Count != 1)
{
context.Result = new BadRequestObjectResult(new ProblemDetails { Status = 400, Title = "One Idempotency-Key header is required" });
return;
}
var key = values.ToString();
// A stored replay, a 409 or a 422: setting Result skips binding, filters and the action.
var shortCircuit = await store.TryClaimAsync(http, key);
if (shortCircuit is not null) { context.Result = shortCircuit; return; }
var executed = await next();
var status = (executed.Result as IStatusCodeActionResult)?.StatusCode ?? 200;
if ((executed.Exception is not null && !executed.ExceptionHandled) || status >= 500)
await store.ReleaseAsync(http, key); // a retry may succeed
else
await store.CompleteAsync(http, key, executed.Result!); // 2xx and 4xx are replayed
}
}
public record PlaceOrder(string Sku, int Quantity);
[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrderCommandsController : ControllerBase
{
[HttpPost]
[Idempotent]
public IActionResult Place(PlaceOrder order) =>
StatusCode(StatusCodes.Status201Created, new { orderId = 4821, order.Sku });
}
Exception filter scoped to one controller
Scoping the mapping to the controller keeps payment-specific error codes out of the global handler. Because the filter only handles <code>PaymentDeclinedException</code> and leaves everything else unhandled, a real bug still flows to the global exception handler and is logged as an error. Setting <code>ExceptionHandled</code> also stops the exception being rethrown after the filter returns.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public sealed class PaymentDeclinedException(string reason) : Exception(reason);
// An attribute filter needs no DI, so it can be a plain attribute.
public sealed class MapPaymentErrorsAttribute : ExceptionFilterAttribute
{
public override void OnException(ExceptionContext context)
{
if (context.Exception is PaymentDeclinedException declined)
{
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status402PaymentRequired,
Title = "Payment declined",
Detail = declined.Message,
Type = "https://errors.example.com/payment-declined"
}) { StatusCode = StatusCodes.Status402PaymentRequired };
context.ExceptionHandled = true; // outer (global) exception filters are skipped
}
// Anything else is left unhandled and reaches UseExceptionHandler / IExceptionHandler.
}
}
public record ChargeCard(string OrderId, decimal Amount, string CardToken);
[ApiController]
[Route("api/payments")]
[MapPaymentErrors]
public sealed class CardPaymentsController : ControllerBase
{
[HttpPost]
public IActionResult Charge(ChargeCard charge) =>
charge.Amount > 5000m
? throw new PaymentDeclinedException("Amount exceeds the card's single transaction limit.")
: Ok(new { charge.OrderId, status = "captured" });
}
Discussion