How do you write a custom action filter, and how is it different from middleware?

ASP.NET MVC Filters Intermediate 2–3 Yrs3–5 Yrs Coding 1 min read

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 implement IFilterFactory.
  • What you can do: validate/short-circuit (context.Result = new BadRequestResult()), log with the bound model, start a stopwatch, add response headers in OnActionExecuted.
  • 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.

C# — async action filter
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) { /* ... */ }