Filters & Action Pipeline
Key Points
- Five filter types wrap MVC action execution: Authorization, Resource, Action, Result, Exception. Run in this order.
IAsyncActionFilteris the modern shape — singleOnActionExecutionAsync(ctx, next).ServiceFilter/TypeFilterapply DI-resolved filters via attributes.- For Minimal APIs, equivalent is endpoint filters (
IEndpointFilter). - Global filters apply to all controllers/actions; combine with attributes for per-action overrides.
Ordercontrols execution within the same scope; lower runs first.
Concepts (deep dive)
Filter pipeline diagram
Request
│
▼
[Authorization filter] — short-circuit on 401/403
│
▼
[Resource filter] — wraps the rest; can short-circuit before model binding
│
▼
Model binding
│
▼
[Action filter] — before/after action method
│
▼
Action method runs
│
▼
[Action filter] — after
│
▼
[Result filter] — before/after result execution
│
▼
Result executes (writes response)
│
▼
[Result filter] — after
│
▼
[Resource filter] — after (cleanup)
[Exception filter] — runs on exception anywhere above
Authorization filter
public class CustomAuth : IAsyncAuthorizationFilter
{
public Task OnAuthorizationAsync(AuthorizationFilterContext ctx)
{
if (!ctx.HttpContext.User.Identity?.IsAuthenticated == true)
ctx.Result = new UnauthorizedResult();
return Task.CompletedTask;
}
}
💡 Most apps don't write authorization filters — use the built-in policy system (
[Authorize(Policy = "X")]).
Resource filter
Wraps everything (binding, action, result):
public class CacheFilter : IAsyncResourceFilter
{
public async Task OnResourceExecutionAsync(ResourceExecutingContext ctx, ResourceExecutionDelegate next)
{
var key = ctx.HttpContext.Request.Path;
if (Cache.TryGet(key, out var cached))
{
ctx.Result = new ContentResult { Content = cached };
return; // short-circuit
}
var executed = await next();
if (executed.Result is ContentResult c) Cache.Set(key, c.Content);
}
}
Action filter
public class TimingFilter(ILogger<TimingFilter> log) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
var sw = Stopwatch.StartNew();
var executed = await next();
log.LogInformation("{Action} took {Ms}ms",
ctx.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds);
}
}
Exception filter
public class ProblemFilter : IExceptionFilter
{
public void OnException(ExceptionContext ctx)
{
if (ctx.Exception is NotFoundException)
{
ctx.Result = new NotFoundResult();
ctx.ExceptionHandled = true;
}
}
}
💡 Prefer
IExceptionHandler(.NET 8+) middleware over exception filters for app-wide error handling. Exception filters are scoped to MVC actions.
Result filter
public class HeaderFilter : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext ctx, ResultExecutionDelegate next)
{
ctx.HttpContext.Response.Headers.Append("X-Custom", "value");
await next();
}
}
Applying filters
// Globally
builder.Services.AddControllers(opts =>
{
opts.Filters.Add<TimingFilter>();
});
// Per controller / action
[ServiceFilter(typeof(TimingFilter))]
public class OrdersController : ControllerBase { ... }
[TypeFilter(typeof(TimingFilter), Order = -1)]
public IActionResult Get() { ... }
ServiceFilter— filter is resolved from DI (registered withAddSingleton/AddScoped).TypeFilter— instance per call; constructor args from DI.Order— execute order (lower runs first; defaults to 0).
Filter scopes
- Global — applies to all actions.
- Controller — applied via
[Filter]on the controller. - Action — applied via
[Filter]on the action.
Execution order: Global → Controller → Action (entering); reversed (exiting).
Endpoint filters (Minimal APIs)
app.MapPost("/orders", Create)
.AddEndpointFilter<ValidationFilter>()
.AddEndpointFilter<TimingFilter>();
Equivalent for Minimal APIs. Class-based filters implement IEndpointFilter. See Minimal APIs.
Filter vs middleware vs IExceptionHandler
| Concern | Use |
|---|---|
| App-wide unhandled exceptions | IExceptionHandler (recommended in .NET 8+) |
| Cross-cutting at HTTP level | Middleware |
| MVC action wrapping | Action / result filter |
| Authentication / authorization | UseAuthentication / UseAuthorization middleware |
| Per-endpoint cross-cutting in Minimal APIs | Endpoint filter |
Code: correct vs wrong
❌ Wrong: heavy work in a global action filter
✅ Correct: scope to controllers/actions that need it
❌ Wrong: filter that throws
public Task OnActionExecutionAsync(...)
{
throw new Exception(); // ❌ propagates; may not be caught by filter chain
}
✅ Correct: handle in filter
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
try { await next(); }
catch (Exception ex) { /* log; rethrow if appropriate */ throw; }
}
❌ Wrong: mixing exception filter and IExceptionHandler
// IExceptionHandler in middleware AND filter for same exception types
// Order is: filters run first; if ExceptionHandled = false, middleware runs
✅ Correct: pick one layer
App-wide: IExceptionHandler. MVC-specific: filter. Don't double-handle.
Design patterns for this topic
Pattern 1 — "Filter for MVC-action concerns; middleware for HTTP concerns"
- Intent: layer-appropriate.
Pattern 2 — "ServiceFilter for DI-friendly filters"
- Intent: test-friendly; reuse instances.
Pattern 3 — "IExceptionHandler for app-wide errors (.NET 8+)"
- Intent: centralized error → ProblemDetails.
Pattern 4 — "Endpoint filter for Minimal APIs"
- Intent: equivalent to MVC action filters.
Pros & cons / trade-offs
| Filter | Pros | Cons |
|---|---|---|
| Authorization | Standard | Built-in policies usually suffice |
| Resource | Wraps everything | Confusing scope |
| Action | Common cross-cutting | Per-action only |
| Exception | Catch / transform | Limited to MVC |
| Result | Modify response | Runs late |
When to use / when to avoid
- Use action filters for MVC-specific cross-cutting (auditing, timing).
- Use middleware for HTTP-level concerns.
- Use
IExceptionHandlerfor app-wide error handling. - Avoid global filters with heavy logic — slow every action.
- Avoid duplicating between middleware and filter.
Interview Q&A
Q1. Order of MVC filter types? Authorization → Resource → Action → Result → Exception (the last runs at any point on exception).
Q2. Difference between ServiceFilter and TypeFilter? ServiceFilter uses a DI-registered filter instance. TypeFilter creates a new instance per call (with DI for constructor args).
Q3. When prefer IExceptionHandler over exception filter? For app-wide, framework-agnostic error handling. Exception filters are MVC-specific.
Q4. Difference between filter and middleware? Middleware is HTTP-pipeline-level; filter is MVC-action-level. Filters know the matched action; middleware doesn't (unless after UseRouting and inspecting endpoint metadata).
Q5. How does Order affect filters? Filters run in Order ascending (entering); reversed (exiting). Default 0.
Q6. What's an endpoint filter? Minimal APIs equivalent of action filter. Implements IEndpointFilter.
Q7. Can a filter short-circuit? Yes — set ctx.Result (in resource/action filter) before calling next (or don't call it). ctx.ExceptionHandled = true for exception filters.
Q8. Example of resource filter use case? Caching: check cache before model binding; return cached response if hit.
Q9. Why might a filter not run? Wrong scope (action attribute applied to controller doesn't propagate down by default — check [ServiceFilter] placement). Or short-circuited by an earlier filter.
Q10. How do you test a filter? Construct it directly, fake ActionExecutingContext / ActionExecutionDelegate, assert on side effects (header set, log called).
Gotchas / common mistakes
- ⚠️ Filter without
[ServiceFilter]/[TypeFilter]— may not have access to scoped DI. - ⚠️ Filter throwing — may bypass downstream filters.
- ⚠️ Heavy global filter — slows every action.
- ⚠️ Duplicating logic in filter and middleware — double execution.
- ⚠️
OnActionExecutingwriting to response — happens before action runs; can short-circuit unintentionally.