Filters in ASP.NET Core Web API: Action Filters, Result Filters and ServiceFilter
We take apart MVC's filter pipeline with real logs: the order of global, controller and action filters, ServiceFilter for an async filter that uses DI, short-circuiting with TypeFilter, result filters, and middleware versus filters.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
Imagine you register the same filter in three places: once in Program.cs for every controller, once on top of the ProductsController class, and once on top of the GetAll action. The filter writes its name to the log before and after the action runs. When a GET /api/products request comes in, in what order do the six log lines appear? Does "Action" write first, or "Global"? Are the three lines after the action in the same order as the first three? The answer is in the section where we write our first filter.
The Filter Pipeline: A Second Pipeline Inside MVC
Middleware meets the request at the application's front door. After routing selects a controller action, MVC runs its own small pipeline; the links in that pipeline are called filters. Think of an airport: the security check at the building entrance is middleware, the boarding pass check at the gate is a filter. The agent at the gate knows which flight and which seat you are headed for; the one at the entrance doesn't.
There are five kinds of filters and they run in this order. Authorization filters run first and decide whether the user may reach this action; the [Authorize] attribute turns into a filter of this kind in MVC. Resource filters run before model binding and after the result has been written; they suit jobs like returning a response from a cache. Action filters run after model binding, right around the action, and can see and change the action's arguments. Exception filters deal with unhandled exceptions thrown in the action and in action filters. Result filters run right around writing the action's result to the response.
In this post we will see the three most used ones in code: action filters, result filters and an action filter that short-circuits. We will cover authorization filters together with [Authorize] in the authorization post.
Our First Action Filter
public class TraceFilterAttribute : ActionFilterAttribute
{
private readonly string _name;
public TraceFilterAttribute(string name)
{
_name = name;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
GetLogger(context.HttpContext).LogInformation("{Name} OnActionExecuting", _name);
}
public override void OnActionExecuted(ActionExecutedContext context)
{
GetLogger(context.HttpContext).LogInformation("{Name} OnActionExecuted", _name);
}
// attributes can't use constructor injection; we pull the logger from the request
private static ILogger GetLogger(HttpContext httpContext) =>
httpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Trace");
}
ActionFilterAttribute is a ready-made base class that combines IActionFilter with being an attribute in one class. OnActionExecuting runs before the action, OnActionExecuted after it. We'll come back to why we don't take the logger through the constructor. Now let's register it in three places:
builder.Services.AddControllers(options =>
{
options.Filters.Add(new TraceFilterAttribute("Global"));
options.Filters.Add<CatalogVersionHeaderFilter>();
});
[ApiController]
[Route("api/[controller]")]
[TraceFilter("Controller")]
public class ProductsController : ControllerBase
{
[HttpGet]
[TraceFilter("Action")]
[ServiceFilter(typeof(ExecutionTimeFilter))]
public async Task<ActionResult<List<ProductDto>>> GetAll(CancellationToken cancellationToken)
{
_logger.LogInformation("GetAll action is running");
return await _productService.GetAllAsync(cancellationToken);
}
}
Ignore the other two lines (CatalogVersionHeaderFilter and ServiceFilter) for now; their turn will come. The real log of a GET /api/products request:
info: Trace[0]
Global OnActionExecuting
info: Trace[0]
Controller OnActionExecuting
info: Trace[0]
Action OnActionExecuting
info: ProductCatalog.Controllers.ProductsController[0]
GetAll action is running
info: ProductCatalog.Filters.ExecutionTimeFilter[0]
ProductCatalog.Controllers.ProductsController.GetAll (ProductCatalog) took 80 ms (exception: False)
info: Trace[0]
Action OnActionExecuted
info: Trace[0]
Controller OnActionExecuted
info: Trace[0]
Global OnActionExecuted
Was your guess right? Filters work like nested rings: you go in from the outside, and come back out from the inside. Global is the outermost ring, then controller, and the action level is innermost. On the way out the order reverses. It's the same model we saw in the middleware pipeline. If you need to change the order, filters have an Order property: the default is 0, a smaller value runs further out, and it takes precedence over scope.
Async Filters and Dependency Injection: ServiceFilter
In TraceFilterAttribute we pulled the logger from RequestServices, because an attribute's constructor arguments must be compile-time constants; you can't pass it an ILogger or an AppDbContext. Filters that use DI are written as ordinary classes instead of attributes:
public class ExecutionTimeFilter : IAsyncActionFilter
{
private readonly ILogger<ExecutionTimeFilter> _logger;
public ExecutionTimeFilter(ILogger<ExecutionTimeFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
Stopwatch stopwatch = Stopwatch.StartNew();
// calling next() runs the action (and the filters after this one)
ActionExecutedContext executed = await next();
stopwatch.Stop();
_logger.LogInformation("{Action} took {Elapsed} ms (exception: {HasException})",
context.ActionDescriptor.DisplayName, stopwatch.ElapsedMilliseconds, executed.Exception is not null);
}
}
IAsyncActionFilter doesn't have two separate methods; it has one, and you trigger the action yourself. Everything before await next() is "executing", everything after is "executed". Exactly the same idea as await _next(context) in middleware.
To use the filter, we first register it with DI and then attach it to the action with [ServiceFilter]:
builder.Services.AddScoped<ExecutionTimeFilter>();
[ServiceFilter(typeof(ExecutionTimeFilter))] asks the container for the filter instance. The took 80 ms line in the log came from this filter.
TypeFilter and Short-Circuiting
Sometimes you want a filter to keep the action from running at all. A filter that checks whether the product exists before a delete request reaches the action:
public class ProductExistsFilter : IAsyncActionFilter
{
private readonly AppDbContext _db;
public ProductExistsFilter(AppDbContext db)
{
_db = db;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("id", out object? value) && value is int id)
{
bool exists = await _db.Products.AnyAsync(p => p.Id == id, context.HttpContext.RequestAborted);
if (!exists)
{
// setting Result short-circuits the pipeline; the action never runs
context.Result = new NotFoundObjectResult(new { message = $"Product {id} does not exist." });
return;
}
}
await next();
}
}
[HttpDelete("{id:int}")]
[TypeFilter(typeof(ProductExistsFilter))]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
_logger.LogInformation("Delete action is running for {Id}", id);
await _productService.DeleteAsync(id, cancellationToken);
return NoContent();
}
context.ActionArguments holds the arguments model binding produced; this is where an action filter's power lies. Assigning a value to context.Result and returning without calling next() short-circuits the pipeline. The real response to DELETE /api/products/99:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
X-Catalog-Version: 1
{"message":"Product 99 does not exist."}
There is no "Delete action is running" line in the log for this request; the action never ran.
The difference between [TypeFilter] and [ServiceFilter]: TypeFilter doesn't need the filter registered with DI; it creates the filter itself every time, resolving the constructor dependencies from the container. ServiceFilter asks the container for the filter itself, so registration is required, but you choose the lifetime. If you use [ServiceFilter] without registering, you get this exception at request time:
System.InvalidOperationException: No service for type 'ProductCatalog.Filters.ExecutionTimeFilter' has been registered.
A Header on Every Response with a Result Filter
Where did the X-Catalog-Version: 1 header on the 404 response come from? From the result filter we registered globally in Program.cs:
public class CatalogVersionHeaderFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
context.HttpContext.Response.Headers["X-Catalog-Version"] = "1";
}
public void OnResultExecuted(ResultExecutedContext context)
{
}
}
We add the header in OnResultExecuting, because the headers go out to the client as soon as the result starts being written and can no longer be changed in OnResultExecuted. options.Filters.Add<CatalogVersionHeaderFilter>() registers by type; if the filter has dependencies, those get resolved too.
A notable detail: the result filter ran even when the action filter short-circuited. That's because the short-circuit produced a result, and that result was still written to the response the normal way.
Middleware or Filter?
Both exist to "run code around a request"; the choice depends on what you need. Middleware sees every request: ones that never reach a controller, static files, health checks, 404s. But it doesn't know which action will run, what model binding produced, or what result the action returned. A filter only runs for MVC actions, and in return it sees the action arguments, ModelState, the action's attributes and the IActionResult.
The practical rule: if the job is at the HTTP level (logging every request, global error handling, CORS, compression), use middleware; if the job depends on the meaning of the action (checks based on an argument, behavior specific to certain actions, changing the result), use a filter.
Exception filters are a good example of that split. IExceptionFilter only catches exceptions thrown in controller creation, model binding, action filters and the action itself; it doesn't see exceptions coming from middleware or from writing the result. That's why we built global error handling with middleware in the previous post; consider an exception filter when a particular controller needs a mapping of its own.
One more note: these filters don't run for minimal API endpoints. Minimal APIs get their own filter mechanism with .NET 7; we will see it in the version posts.
Common Pitfalls
Implementing both IActionFilter and IAsyncActionFilter in the same class. We tried it: the framework only called the async one, the sync methods never ran. Pick one.
Forgetting await next(). With a filter that neither called next() nor set context.Result, the request came back as 200 OK with an empty body; the action never ran. No error, no warning, just a silent void.
Trying to give an attribute dependencies through its constructor. You can't write [MyFilter(logger)]. If you need DI, use ServiceFilter, TypeFilter or IFilterFactory.
Putting a scoped dependency into a global instance. An instance you add with options.Filters.Add(new MyFilter(...)) is a single object for the lifetime of the app; put an AppDbContext in it and you've built the captive dependency from the dependency injection post with your own hands. Add a global filter that has dependencies by type, with Filters.Add<T>().
An unnecessary database round trip in a filter. For a product that exists, ProductExistsFilter first runs AnyAsync and then the service runs FindAsync; two queries. The NotFoundException approach from the previous post gets the same outcome with one query. A short-circuit filter makes sense where the check repeats across many actions and costs little.
A Small Experiment
★☆☆ Change the attribute on the action to [TraceFilter("Action", Order = -1)] and see how the log order changes. Then undo that, register the global filter as new TraceFilterAttribute("Global") { Order = 10 } and try again.
★★☆ Write a ReadOnlyModeFilter that implements IResourceFilter: if the configuration value Catalog:ReadOnly is true, return 503 for every request other than GET. Prove with a log line that model binding never runs in that case.
★★★ Combine ExecutionTimeFilter with a result filter to measure the action time and the result-writing time separately; add both to the response as a Server-Timing header.
We've learned to wrap code around an action with filters. Now it's time to go back to the action itself: GET /api/products currently returns every product in the database in one go. In the next post we'll return large lists page by page with pagination, filtering and sorting, including safe dynamic sorting.