How do you write a custom action filter, and how is it different from middleware?
Quick Answer
Implement IActionFilter (or IAsyncActionFilter) and override OnActionExecuting/OnActionExecuted — you get the action context, bound arguments and ModelState. Apply it with [ServiceFilter(typeof(MyFilter))] (DI-resolved), per controller, or globally. Unlike middleware, a filter runs inside MVC and knows about the action.
I implement IAsyncActionFilter and put my logic around await next() — before it runs before the action, after it runs after. I get the action context including the bound arguments and ModelState, so I can short-circuit by setting context.Result. Since a plain attribute can't take constructor dependencies, I register the filter in DI and apply it with [ServiceFilter]. The difference from middleware is context: middleware runs for every request and knows nothing about MVC, whereas a filter runs inside MVC and can see the action, its parameters and model state — so validation and action-specific logging are filters, and something like HTTPS redirection is middleware.
- Attribute + DI: a plain attribute can't have injected dependencies. Use
[ServiceFilter]or[TypeFilter], or implementIFilterFactory. - What you can do: validate/short-circuit (
context.Result = new BadRequestResult()), log with the bound model, start a stopwatch, add response headers inOnActionExecuted. - Filter vs middleware: middleware is framework-agnostic and runs for every request without knowing about controllers or model state; a filter is MVC-aware — it sees the action, its parameters, the selected result and
ModelState. Choose middleware for truly global concerns, a filter when you need MVC context.
public class AuditFilter : IAsyncActionFilter
{
private readonly IAuditLog _log;
public AuditFilter(IAuditLog log) => _log = log;
public async Task OnActionExecutionAsync(
ActionExecutingContext ctx, ActionExecutionDelegate next)
{
var sw = Stopwatch.StartNew();
var result = await next(); // runs the action
await _log.WriteAsync(ctx.HttpContext.Request.Path,
ctx.ActionArguments, result.Exception, sw.ElapsedMilliseconds);
}
}
// controller
[ServiceFilter(typeof(AuditFilter))]
public IActionResult Approve(int id) { /* ... */ }