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.

Syntaxpublic 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

FilterRunsTypical use
Authorization (IAsyncAuthorizationFilter)FirstRare in APIs: [Authorize] is enforced by the authorization middleware
Resource (IAsyncResourceFilter)Around model binding and everything afterShort-circuit before binding: idempotency replays, feature switches
Action (IAsyncActionFilter)Around the action, after binding and validationInspect or change arguments, auditing, timing
Exception (IAsyncExceptionFilter)When binding, an action filter or the action throwsMap domain exceptions to problem details
Result (IAsyncResultFilter)Around writing the resultResponse 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:

  • ModelStateInvalidFilter has order -2000, so with [ApiController] your action filters never run for an invalid request.
  • Exception filters run innermost first (action, controller, global). Setting context.Result and ExceptionHandled = true stops the outer ones.
  • Result filters do not run when an exception filter produced the result or a resource filter short-circuited. Implement IAlwaysRunResultFilter for 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 with ActivatorUtilities per request, mixing services and literal arguments, no registration needed.
  • Global: options.Filters.Add<AuditFilter>() (type-activated per request) or options.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

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

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.

Example · csharp
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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.