Filters: Code That Runs Around Every Action – Web API | Lesson 89
Introduction
Some jobs belong to many actions at once. "No changes during year-end closing" applies to every endpoint
that writes. "A date range must make sense" applies to every report with from and to.
Copying that code into each action means forgetting it somewhere. ASP.NET Core filters run code before and after actions, in one place. This lesson builds two filters the SMS needs.
By the end of this lesson, you will be able to:
- Name the filter types and the order they run in.
- Write a resource filter that stops a request before its body is read.
- Write an action filter attribute that checks an action's arguments.
- Register a filter globally, or on one action.
- Choose between middleware and a filter for a job.
Why this matters
Schools close their year: results are published, reports are printed, and nothing may change for a few days. Turning the whole API off would also stop parents reading report cards.
A read-only switch should block only changes, and turn on and off without a restart. That is exactly the kind of job a filter does well.
The concept
Think of the exam hall: the gate guard checks your hall ticket, and at the desk someone checks what you carry. In the hall, the invigilator checks your details on the answer sheet. Only then do you write.
Example: the filter pipeline
Filters run inside MVC, after middleware such as authentication and authorization (Lesson 67):
| Order | Kind | Runs | SMS example |
|---|---|---|---|
| 1 | Authorization filters | first, before anything else in MVC | [Authorize] (policies, Lessons 86–87) |
| 2 | Resource filters | before model binding, and after everything else | ReadOnlyModeFilter (this lesson) |
| 3 | Model binding and validation | reads the body, checks [Required] | Lesson 73 |
| 4 | Action filters | just before and after the action, with its arguments | [ValidDateRange] (this lesson) |
| 5 | Exception filters | when the action throws | the SMS uses IExceptionHandler instead (Lesson 80) |
| 6 | Result filters | around writing the result | — |
A filter can short-circuit: by setting context.Result, it answers the request itself, and everything
after it is skipped.
Example: middleware or filter?
| Middleware (Lesson 67) | Filter | |
|---|---|---|
| Sees | every request, including files and unknown routes | only requests that reach an MVC action |
| Knows | the URL and headers | the action, its attributes, and its bound arguments |
| SMS example | request timing, exception handling | read-only mode, date-range check |
Use a filter when the job depends on which action runs or on its arguments. Use middleware when it applies to every request, whatever happens next.
Example: before and after the action
Most filters can run code on both sides of what they wrap. The async form makes that easy to see:
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// Before: the arguments are bound; the action has not run yet.
ActionExecutedContext executed = await next(); // the action, and the filters after this one
// After: executed.Result holds what the action returned, or executed.Exception what it threw.
}
Not calling next() at all is how a filter refuses a request. The synchronous form splits the two halves into
OnActionExecuting and OnActionExecuted, as ValidDateRangeAttribute does below.
Example: three ways to apply a filter
| Where | How | Use it for |
|---|---|---|
| Every action | options.Filters.Add<ReadOnlyModeFilter>() in Program.cs | rules for the whole API |
| One controller | an attribute on the class | rules for one area, such as fees |
| One action | an attribute on the method, such as [ValidDateRange] | rules for a few endpoints |
Example: the order inside one stage
When several filters of the same kind apply, they run from the outside in: global filters first, then the controller's, then the action's. On the way back, after the action, they finish in the reverse order, like layers of an onion.
Every filter also has an Order number, 0 by default. A lower number runs earlier, whatever its scope. Most
filters never need it; reach for it only when two filters of the same kind truly depend on each other.
Example: why the SMS has no exception filters
Exception filters catch exceptions thrown by actions and other filters. They do not see exceptions from middleware, from routing, or from writing the response.
The SMS turns every exception into an ApiResponse with IExceptionHandler (Lesson 80), which sits in the
middleware pipeline and sees all of them. One place for every error is simpler than two. Exception filters
still suit one controller that needs special handling of its own errors.
An attribute filter is created by .NET, not by dependency injection, so its constructor cannot ask for
services. When an attribute filter needs a service, wrap it: [ServiceFilter(typeof(MyFilter))] takes it from
dependency injection, and [TypeFilter(typeof(MyFilter))] creates it with services filled in.
Apply it to the SMS
Step 1: the setting
SchoolOptions (Lesson 70) gets one more property, ReadOnlyMode, set to false in appsettings.json.
Keeping the switch in configuration means nobody needs to change code or redeploy to use it. In production it
can also come from an environment variable, School__ReadOnlyMode, like every other setting.
Step 2: a resource filter for read-only mode
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using SchoolApp.Api.Models;
using SchoolApp.Api.Options;
namespace SchoolApp.Api.Filters;
// Lesson 89: during year-end closing, the office switches the SMS to read-only with one setting
// ("School:ReadOnlyMode": true). Reading still works; every change answers 503 until it is switched back.
// A resource filter runs before model binding, so a blocked request is refused before its body is even read.
public class ReadOnlyModeFilter : IAsyncResourceFilter
{
private readonly IOptionsMonitor<SchoolOptions> _school;
// IOptionsMonitor, not IOptions: it sees a change to appsettings.json while the app is running.
public ReadOnlyModeFilter(IOptionsMonitor<SchoolOptions> school)
{
_school = school;
}
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
string method = context.HttpContext.Request.Method;
bool changesData = !(HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method));
bool allowed = context.ActionDescriptor.EndpointMetadata.OfType<AllowWhenReadOnlyAttribute>().Any();
if (_school.CurrentValue.ReadOnlyMode && changesData && !allowed)
{
// Setting Result short-circuits: the action, and every filter after this one, never run.
context.HttpContext.Response.Headers.RetryAfter = "3600"; // seconds: "try again in an hour"
context.Result = new ObjectResult(ApiResponse.Fail("The SMS is read-only during year-end closing. Try again later."))
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
return;
}
await next(); // carry on: model binding, the action, and the rest of the filters
}
}
// Lesson 89: actions that must work even in read-only mode, such as signing in.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class AllowWhenReadOnlyAttribute : Attribute
{
}
Three details:
IOptionsMonitor, notIOptions.IOptionsreads settings once.IOptionsMonitor.CurrentValuesees a change toappsettings.jsonwhile the API is running.- A resource filter. It runs before model binding, so a blocked request is refused before its body is read or validated.
[AllowWhenReadOnly]. Signing in is aPOSTtoo, but it changes no school data. The attribute onAuthController.Loginlets it through.
Step 3: register it for every action
// Lesson 89: a global filter runs for every action. ReadOnlyModeFilter is created by DI, per request.
builder.Services.AddControllers(options => options.Filters.Add<ReadOnlyModeFilter>())
Filters.Add<T>() asks dependency injection for the filter, so its constructor can take IOptionsMonitor.
The other form, Filters.Add(new ReadOnlyModeFilter(...)), would create one object when the app starts and
share it with every request. That only works for filters that need no services and keep no state.
Step 4: switching it on while the API runs
The API ran on port 5080, signed in as the principal. Between the second and third requests,
"ReadOnlyMode": false was changed to true in appsettings.json, with no restart:
POST /api/students {} 400 One or more fields are invalid. (normal: validation runs)
(ReadOnlyMode set to true; the same process keeps running)
POST /api/students {} 503 Retry-After: 3600
{"status":"error","message":"The SMS is read-only during year-end closing. Try again later.","traceId":"00-ca89…"}
GET /api/students/ADM-2017-0003 200 Student found.
POST /api/auth/login 200 (signing in still works)
(ReadOnlyMode set back to false)
POST /api/students {} 400 One or more fields are invalid.
The empty body got 503, not 400, because the resource filter answered before validation ever saw it. The
Retry-After header tells well-behaved clients to wait an hour before trying again.
Step 5: an action filter attribute for date ranges
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SchoolApp.Api.Models;
namespace SchoolApp.Api.Filters;
// Lesson 89: an action filter as an attribute. It checks two DateOnly parameters of the action after model
// binding: "from" must not be after "to", and the range must not be longer than MaxDays.
// [ValidDateRange("from", "to", MaxDays = 366)] on any action with such parameters.
public class ValidDateRangeAttribute : ActionFilterAttribute
{
private readonly string _fromName;
private readonly string _toName;
public ValidDateRangeAttribute(string fromName, string toName)
{
_fromName = fromName;
_toName = toName;
}
public int MaxDays { get; set; } = 366;
// Runs after model binding and validation, just before the action. ActionArguments holds the bound values.
public override void OnActionExecuting(ActionExecutingContext context)
{
if (context.ActionArguments.TryGetValue(_fromName, out object? fromValue) && fromValue is DateOnly from
&& context.ActionArguments.TryGetValue(_toName, out object? toValue) && toValue is DateOnly to)
{
string? problem = from > to ? $"'{_fromName}' ({from:yyyy-MM-dd}) is after '{_toName}' ({to:yyyy-MM-dd})."
: to.DayNumber - from.DayNumber + 1 > MaxDays ? $"The range is longer than {MaxDays} days."
: null;
if (problem is not null)
{
// The same shape as every other 400 (Lesson 80); the action never runs.
context.Result = new BadRequestObjectResult(ApiResponse.Fail("One or more fields are invalid.",
errors: new Dictionary<string, string[]> { [_fromName] = [problem] }));
}
}
}
}
An action filter runs after model binding, so ActionArguments already holds the bound DateOnly values.
It goes on the attendance summary with one line:
[HttpGet("/api/students/{admissionNumber}/attendance-summary")]
[ValidDateRange("from", "to", MaxDays = 366)] // Lesson 89: an action filter
GET /api/students/ADM-2017-0003/attendance-summary?from=2026-07-31&to=2026-07-01 400
{"status":"error","message":"One or more fields are invalid.","errors":{"from":["'from' (2026-07-31) is after 'to' (2026-07-01)."]},"traceId":"00-91b5…"}
Before this lesson, that request answered 200 with zero days, which looked like a student who never came to school.
The attribute takes the parameter names as text, so the same filter works for any action whose dates are called
something else, such as startDate and endDate. MaxDays stops a report from scanning many years of
attendance by mistake.
Step 6: tests
Show tests/SchoolApp.Tests/Api/FilterTests.cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using SchoolApp.Api.Security;
namespace SchoolApp.Tests.Api;
// Lesson 89: the read-only mode filter and the date-range action filter.
[Collection("Api")]
public class FilterTests
{
private readonly ApiFactory _factory;
public FilterTests(ApiFactory factory)
{
_factory = factory;
}
// The same API, with "School:ReadOnlyMode": true.
private async Task<HttpClient> ReadOnlyClientAsync(string role)
{
HttpClient client = _factory.WithWebHostBuilder(builder => builder.UseSetting("School:ReadOnlyMode", "true")).CreateClient();
string token = await ApiFactory.GetTokenAsync(client, ApiFactory.UserNameFor(role)); // signing in still works
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
[Fact]
public async Task ReadOnlyMode_BlocksChanges_With503_BeforeValidation()
{
HttpClient principal = await ReadOnlyClientAsync(Roles.Principal);
HttpResponseMessage response = await principal.PostAsJsonAsync("/api/students", new { }); // an empty body
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); // not 400: the body was never read
Assert.Equal(TimeSpan.FromHours(1), response.Headers.RetryAfter?.Delta);
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("error", body.GetProperty("status").GetString());
}
[Fact]
public async Task ReadOnlyMode_StillAllowsReading()
{
HttpClient principal = await ReadOnlyClientAsync(Roles.Principal);
HttpResponseMessage response = await principal.GetAsync("/api/students/ADM-2017-0003");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
// Authorization runs before every filter: a teacher is refused for her role, not for the read-only mode.
[Fact]
public async Task ReadOnlyMode_ForbiddenUser_Gets403First()
{
HttpClient teacher = await ReadOnlyClientAsync(Roles.Teacher);
HttpResponseMessage response = await teacher.PostAsJsonAsync("/api/students", new { });
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Theory]
[InlineData("2026-07-31", "2026-07-01", HttpStatusCode.BadRequest)] // from is after to
[InlineData("2025-06-01", "2026-07-31", HttpStatusCode.BadRequest)] // more than 366 days
[InlineData("2026-07-01", "2026-07-31", HttpStatusCode.OK)]
public async Task AttendanceSummary_ChecksTheDateRange(string from, string to, HttpStatusCode expected)
{
HttpClient principal = await _factory.CreateSignedInClientAsync(Roles.Principal);
HttpResponseMessage response = await principal.GetAsync(
$"/api/students/ADM-2017-0003/attendance-summary?from={from}&to={to}");
Assert.Equal(expected, response.StatusCode);
if (expected == HttpStatusCode.BadRequest)
{
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(body.GetProperty("errors").TryGetProperty("from", out _));
}
}
}
ReadOnlyMode_ForbiddenUser_Gets403First shows the order again: authorization runs before every filter, so
a teacher posting a student gets 403 even in read-only mode. The suite has 136 tests, all passing.
Example: what else filters are good for
Teams reach for filters for a few common jobs:
- Idempotency keys. A resource filter remembers the answer to a request that carries an
Idempotency-Keyheader. When a phone retries a payment, it replays that answer instead of paying twice. - Feature switches. Like read-only mode, a filter can hide a new endpoint until the school turns it on.
- Audit of sensitive actions. An action filter can log who called "remove student", with the arguments, after the action succeeds.
- Response headers. A result filter can add a header to every answer from one controller.
Each of these depends on the action or its arguments, which is why a filter fits better than middleware.
Common mistakes
- Copying a check into every action. One action forgets it. Fix: a filter, registered globally or as an attribute.
IOptionsfor a setting that must change while running. The filter keeps the old value until a restart. Fix:IOptionsMonitor.- An action filter for a job that must happen before the body is read. Validation runs first and answers 400. Fix: a resource filter.
- Blocking every
POST, including sign-in. Nobody can sign in to check anything. Fix: an exemption attribute such as[AllowWhenReadOnly]. - A filter for something every request needs, even unknown routes. Filters never see those. Fix: middleware.
In real projects
A school group closed its year by stopping the API for two days. Parents could not open report cards, the busiest pages of the year, and the help desk was flooded.
The next year we added a read-only switch as a global filter. Changes answered 503 with a clear message, and everything else kept working.
The switch lived in configuration, so the office's IT person turned it on and off without a deployment. Nobody outside the office noticed the year being closed.
Best practices
- Put rules that many actions share into filters, not into every action.
- Pick the filter kind by when it must run: resource filters before binding, action filters with arguments.
- Short-circuit with
context.Result, in the same response shape as every other error. - Read settings that change at run time with
IOptionsMonitor. - Keep middleware for jobs that apply to every request.
Recap
- Filters run inside MVC, in order: authorization, resource, binding, action, exception, result.
ReadOnlyModeFilterblocks changes with 503 during year-end closing, without a restart.[AllowWhenReadOnly]keeps sign-in working.[ValidDateRange]checksfromandtofor any action, after binding.- Authorization still runs first: 136 tests pass.
Homework
Library module task: Write an action filter attribute [ValidIsbn("isbn")] for a single-file library
API. It checks the ISBN-13 check digit before the action runs: weights 1, 3, 1, 3, and so on, and the sum must
divide by 10. Use it on GET /api/books/{isbn} and on POST /api/book-issues.
A correct result looks like:
GET /api/books/9780143039655 200 {"isbn":"9780143039655","found":true,"title":"Malgudi Days"}
GET /api/books/9780143039656 400 {"status":"error","message":"9780143039656 is not a valid ISBN-13."}
POST /api/book-issues {"isbn":"12345",...} 400 {"status":"error","message":"12345 is not a valid ISBN-13."}
POST /api/book-issues {"isbn":"9788173711466"} 200 {"message":"9788173711466 issued to student 6."}
Show the solution
// Library homework, Lesson 89: an action filter attribute. [ValidIsbn("isbn")] checks the ISBN-13 check digit
// of an action's parameter before the action runs, and answers 400 when it is wrong, so no action has to.
// Run with: dotnet run library-assignment/089-library-isbn-filter.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5089");
public record IssueRequest(string Isbn, int StudentId);
public class ValidIsbnAttribute : ActionFilterAttribute
{
private readonly string _parameter;
public ValidIsbnAttribute(string parameter)
{
_parameter = parameter;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
// The value may be a parameter of its own (a route value) or a property of a bound request object.
object? value = context.ActionArguments.TryGetValue(_parameter, out object? direct) ? direct
: context.ActionArguments.Values.FirstOrDefault(v => v is IssueRequest) is IssueRequest request ? request.Isbn
: null;
if (value is string isbn && !IsValidIsbn13(isbn))
{
context.Result = new BadRequestObjectResult(new { status = "error", message = $"{isbn} is not a valid ISBN-13." });
}
}
// ISBN-13: 13 digits; weights 1, 3, 1, 3, ...; the weighted sum must divide by 10.
public static bool IsValidIsbn13(string isbn) =>
isbn.Length == 13 && isbn.All(char.IsAsciiDigit)
&& isbn.Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum() % 10 == 0;
}
[ApiController]
[Route("api")]
public class LibraryController : ControllerBase
{
[HttpGet("books/{isbn}")]
[ValidIsbn("isbn")]
public IActionResult Get(string isbn) => Ok(new { isbn, found = isbn == "9780143039655", title = "Malgudi Days" });
[HttpPost("book-issues")]
[ValidIsbn("isbn")]
public IActionResult Issue(IssueRequest request) => Ok(new { message = $"{request.Isbn} issued to student {request.StudentId}." });
}
All three sample books have valid check digits; changing the last digit of Malgudi Days from 5 to 6 makes it invalid.
Quiz
Which runs first for a controller action?
Show answer
Answer: B. Authorization, then resource filters, then model binding, then action filters A forbidden user gets 403 even in read-only mode.
Why is ReadOnlyModeFilter a resource filter, not an action filter?
Show answer
Answer: B. It must answer before model binding reads and validates the body An empty body got 503, not 400.
What lets the filter see a changed appsettings.json without a restart?
Show answer
Answer: B. IOptionsMonitor.CurrentValue IOptions reads settings once.
How does a filter stop a request from reaching the action?
Show answer
Answer: B. It sets context.Result, which short-circuits the rest The result it sets becomes the response.
When should you use middleware instead of a filter?
Show answer
Answer: B. For a job every request needs, even ones that reach no action Filters only see requests that reach an MVC action.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is a filter in ASP.NET Core MVC? concept
Code that runs before and after controller actions, inside MVC, for jobs many actions share. Filters can read the action, its attributes, and its bound arguments, and can answer the request themselves. The SMS uses a filter for read-only mode and another for checking date ranges.
F In what order do the filter types run? concept
Authorization filters, then resource filters, then model binding and validation, then action filters around the action, then exception filters if it throws, then result filters around writing the result. Resource filters also run again at the very end, on the way back.
F When would you use middleware instead of a filter? concept
When the job applies to every request, whatever happens next, including requests for files or unknown routes, which never reach a filter. Use a filter when the job depends on which action runs or on its bound arguments. In the SMS, request timing is middleware; read-only mode and date checks are filters.
Mid (3-5 yrs)
M How does a filter stop a request from reaching the action? coding
It sets context.Result (or, in an async filter, does not call next). That result becomes the response, and the action and the later filters never run. The SMS's ReadOnlyModeFilter sets a 503 ApiResponse this way.
M Why might a filter answer 503 to a request whose body is invalid, instead of 400? scenario
It is a resource filter, which runs before model binding and validation. In the SMS read-only mode, an empty admission request got 503 because the filter refused it before validation ever read the body. An action filter would run after validation, and the client would get 400 first.
M An attribute filter needs a service from dependency injection. How do you supply it? coding
Attribute filters are created by .NET, not by dependency injection, so their constructors cannot ask for services. Use [ServiceFilter(typeof(MyFilter))] (the filter is registered and taken from DI) or [TypeFilter(typeof(MyFilter))] (created with services filled in), or register the filter globally with options.Filters.Add of the type, as the SMS does for ReadOnlyModeFilter.
Senior (6-9 yrs)
S The school must stop all changes for two days at year end, but parents must still read report cards. How do you build it? design
A global resource filter that refuses every non-GET request with 503 and Retry-After while a setting is on, with an attribute to exempt actions such as sign-in. Read the setting with IOptionsMonitor, so the office can switch it without a restart or deployment. Keep the message clear, and test both modes.
How to answer: Choose the mechanism (a resource filter), explain why it runs before binding, how the switch changes at run time, which actions are exempt, and what status and headers clients get.
S You changed a setting in appsettings.json while the API was running, but a filter still uses the old value. Why? scenario
The filter reads the setting through IOptions, which binds the value once. IOptionsMonitor.CurrentValue (or IOptionsSnapshot per request) sees reloaded configuration. Also check the file is the one the app actually reads (the content root) and that reloading is on, which is the default for appsettings.json.
How to answer: Name the three options interfaces and when each sees changes, and how you would verify the reload.
Architect / Lead (10+ yrs)
A Payments are sometimes recorded twice when a phone app retries after a timeout. How would you design idempotency for the API? design
Clients send an Idempotency-Key header with each payment. A resource filter (or middleware) stores the key with the response in a shared store (database or distributed cache) for a limited time, and when the same key arrives again it replays the stored response instead of running the action. Store keys per user, handle two identical requests arriving at once with a unique constraint or lock, and reject a reused key with a different body.
How to answer: Cover the client contract, where keys and responses are stored, concurrent duplicates, expiry, and how it works across several servers.
Resources
- Code for this lesson: git tag
lesson-89-filters(code repository coming soon) - Files:
src/SchoolApp.Api/Filters/ReadOnlyModeFilter.cs,src/SchoolApp.Api/Filters/ValidDateRangeAttribute.cs,src/SchoolApp.Api/Program.cs,tests/SchoolApp.Tests/Api/FilterTests.cs,library-assignment/089-library-isbn-filter.cs - Video: coming soon
What's next
Filters handle shared rules. Next, logging: structured log messages that tell you what happened in production, linked to the trace id every error already carries. Lesson 90: Logging and trace ids
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.