Filters
By the end of this lesson
Apply cross-cutting behaviour around actions without repeating code.
Some behaviour belongs to many actions but to none of them in particular. Recording who changed what. Timing an operation. Adding a header to every response from one controller. Writing that code into twenty actions means maintaining twenty copies of it.
A filter is a piece of code the framework runs around an action. It sits inside MVC, which is the part of ASP.NET Core that maps a request to a controller method, so by the time a filter runs the framework already knows which action was selected, what its arguments are, and whether the model was valid.
That knowledge is the difference between a filter and middleware, and it decides which one you should reach for.
Both wrap a request. They wrap different amounts of it, and they know different things:
| Middleware | Filter | |
|---|---|---|
| Runs for | Every request, including static files and endpoints that are not controllers | Only requests that reached a controller action |
| Knows which action was chosen | No. It sees a path and headers | Yes. It has the action descriptor and the bound arguments |
| Can read ModelState | No. Binding has not happened yet | Yes. Validation results are available |
| Can short-circuit | Yes, by not calling the next component | Yes, by setting a result instead of calling next |
| Natural fit | HTTPS redirection, compression, CORS, rate limiting, correlation ids | Auditing an action, caching a result per action, shaping a response body |
There are five kinds of filter, and they run at different points around the action:
- Authorization filters
- First to run. They decide whether the request is allowed to proceed at all. The [Authorize] attribute is one of these, and you rarely need to write your own.
- Resource filters
- Run after authorization and wrap everything else, including model binding. Useful when you want to skip binding entirely, such as returning a cached result before any work happens.
- Action filters
- Run immediately before and after the action method. They can read and even modify the bound arguments, and inspect the result the action returned. This is the kind you will write most.
- Exception filters
- Run when an action or an action filter throws. They are narrower than central exception middleware and are best reserved for turning a specific domain exception into a specific result.
- Result filters
- Run around the step that writes the response. Use them when you need to adjust the result immediately before it is serialised, such as adding a header that depends on what the action produced.
public sealed class AuditActionFilter(
IAuditLog auditLog,
ILogger<AuditActionFilter> logger) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
string action = context.ActionDescriptor.DisplayName ?? "unknown";
string? user = context.HttpContext.User.Identity?.Name;
// Bound arguments are available here. Middleware cannot see these.
object? employeeId = context.ActionArguments.GetValueOrDefault("id");
long startedAt = Stopwatch.GetTimestamp();
ActionExecutedContext executed = await next();
TimeSpan elapsed = Stopwatch.GetElapsedTime(startedAt);
bool succeeded = executed.Exception is null;
await auditLog.RecordAsync(new AuditEntry(action, user, employeeId, succeeded, elapsed));
logger.LogInformation(
"{Action} by {User} on employee {EmployeeId} finished in {ElapsedMs}ms",
action, user, employeeId, elapsed.TotalMilliseconds);
}
}- IAsyncActionFilter gives you one method that surrounds the action. Everything before await next() runs first; everything after runs once the action has finished.
- ActionArguments is the dictionary of bound parameters. Reading the employee id here is what makes the audit entry meaningful, and it is the thing middleware cannot do because binding has not happened yet.
- await next() invokes the action, or the next filter in the chain. The ActionExecutedContext it returns carries the result and any exception.
- Checking executed.Exception lets the audit record a failed attempt. Do not swallow it: leave the exception alone and let your central handler deal with the response.
- Stopwatch.GetTimestamp with GetElapsedTime measures the action without allocating a Stopwatch object per request. On a hot path that difference is worth having.
// 1. Globally, to every action in the application
builder.Services.AddControllers(options =>
{
options.Filters.Add<AuditActionFilter>();
});
// 2. To one controller or one action, when the filter needs services.
// ServiceFilter resolves it from the container, so constructor
// injection works. A plain attribute cannot do that.
builder.Services.AddScoped<AuditActionFilter>();
[ApiController]
[Route("api/employees")]
[ServiceFilter(typeof(AuditActionFilter))]
public sealed class EmployeesController(IEmployeeService employees) : ControllerBase
{
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
await employees.DeleteAsync(id, cancellationToken);
return NoContent();
}
}
// 3. An attribute filter with no dependencies, applied directly
public sealed class AddApiVersionHeaderAttribute : ActionFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.HttpContext.Response.Headers["X-Api-Version"] = "2";
}
}- Global registration is right for something that genuinely applies everywhere. If you find yourself adding exceptions to a global filter, it was not global.
- ServiceFilter exists because attributes take compile-time constant arguments, so a filter that needs an ILogger or a database context cannot be a plain attribute. ServiceFilter asks the container for the instance instead.
- Register the filter in the container when you use ServiceFilter. A scoped lifetime is usually right, since it lives for one request.
- The third form is the lightweight case: an attribute with no dependencies, inheriting ActionFilterAttribute so you override only the hook you need.
- Order matters when several filters apply. Global runs outermost, then controller, then action, unless you set the Order property to change it.
Summary
- A filter is code the framework runs around a controller action, inside MVC
- Filters know the selected action, its bound arguments and ModelState; middleware knows only the raw request
- There are five kinds — authorization, resource, action, exception and result — running at different points
- Use ServiceFilter when a filter needs injected services, because attribute arguments must be compile-time constants
- Middleware for anything request-wide, filters for anything action-aware, and prefer middleware when both would work
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
A filter that rejects an unsupported sort field
Write an action filter for the employees list endpoint. It reads a sortBy argument, and if the value is not one of surname, startDate or department, it short-circuits with a 400 naming the allowed values.
Short-circuit by setting context.Result before calling next, and do not call next at all in that case.
Show solution
Setting context.Result and returning without awaiting next() stops the action from running. The framework takes the result you supplied and writes it as the response.
The design question worth pausing on: should this be a filter at all? A validation attribute on the query model would express the same rule closer to the data, and it would appear in generated API documentation. The filter is the better choice only when the list of allowed values comes from somewhere the model cannot reach, such as configuration or a database.
That is the honest trade-off with filters. They remove repetition, and they move the rule away from the thing it constrains. Both of those are true at the same time.
public sealed class ValidateSortFieldFilter : IAsyncActionFilter
{
private static readonly string[] Allowed = ["surname", "startDate", "department"];
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("sortBy", out object? raw)
&& raw is string sortBy
&& !Allowed.Contains(sortBy))
{
context.ModelState.AddModelError(
"sortBy",
"Sort by one of: surname, startDate, department.");
context.Result = new BadRequestObjectResult(
new ValidationProblemDetails(context.ModelState));
return; // next() is never called, so the action does not run
}
await next();
}
}Think about it
Middleware or filter?
For each of these, decide whether it belongs in middleware or a filter, and say what information the choice depends on: adding a response header to every response; rejecting a request whose API key is missing; recording the id of the employee a request modified; rewriting a 404 from one controller into a custom body.
Show solution
A header on every response is middleware. It needs nothing about the action, and it should apply to health checks and static files too.
Rejecting a missing API key is middleware if the rule is uniform across the service. It becomes a filter only if which key is acceptable depends on the action, and at that point the authorization policy system is a better home for it than either.
Recording the modified employee id must be a filter. The id comes from the bound arguments, and middleware runs before binding.
Rewriting a 404 from one controller is a filter, because "from one controller" is action knowledge. Middleware could inspect the status code on the way out but has no way to know which controller produced it.
The deciding question each time is whether the behaviour needs to know about the action. If it does, it cannot be middleware.
Saved in this browser only.