Middleware sees every request. Filters see the action about to run.
Middleware, from the last lesson, is deliberately dumb about your application. It sees a request, it sees a response — nothing more. It has no idea that "OrdersController.Create" is about to run, what arguments were bound to it, or whether those arguments passed validation. That's by design: middleware operates on the raw HTTP pipeline, agnostic of any framework built on top of it.
But sometimes you genuinely need that richer information. You want to log which specific action ran and with what arguments. You want to reject a request before the action runs if its model failed validation. You want to transform the result an action returns, right before it's serialized. None of that is visible from plain middleware — it only exists once you're inside the MVC framework's own action-invocation pipeline. That's exactly the gap filters fill.
In this lesson, you'll learn the five kinds of MVC filters, exactly where each one runs relative to your action, how they differ from middleware in scope and available context, and how the newer IEndpointFilter mechanism brings similar capability to Minimal APIs.
A filter is a piece of code that runs at a specific, well-defined point around the execution of a Controller action — before it runs, after it runs, when it throws, or around how its result gets turned into an HTTP response. Unlike middleware, a filter has direct access to MVC-specific information: which action is about to execute, what arguments were bound to it, whether the model passed validation, and the controller instance itself.
Filters are components of the ASP.NET Core MVC framework that implement one of several filter interfaces (or their attribute-based equivalents), each corresponding to a specific stage of action execution. They can be applied to a single action method, to an entire controller class, or registered globally for every controller in the app. Under the hood, filters execute inside the endpoint that routing matched — from the middleware pipeline's point of view, "run the matched Controller action" is a single terminal step, and the filter pipeline is what happens inside that step.
HttpContextProgram.csPicture trying to write "log every action call along with its bound arguments and which controller handled it" as middleware. Middleware only has HttpContext — the raw request and response. It can see the URL path, but not "this maps to OrdersController.Create(CreateOrderRequest request), and here's what was actually bound into request after model binding ran." That information doesn't exist yet at the point middleware runs — it's produced deeper inside the MVC framework, as part of matching, binding, and preparing to invoke a specific action.
MVC exposes its own internal pipeline as a set of extension points — filters — positioned at each meaningful stage of preparing, running, and finishing an action. Because filters run inside MVC's own machinery, they get first-class access to everything MVC already knows: the action descriptor, the bound arguments, the ModelState, and the controller instance. You get surgical, MVC-aware interception without reimplementing any of MVC's own bookkeeping yourself.
Five filter types, each owning one stage of the journey from "a request matched this action" to "a response body was written":
IActionResult (or a value MVC wraps into one).public class TimingFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context) { /* before */ }
public override void OnActionExecuted(ActionExecutedContext context) { /* after */ }
}
ActionFilterAttribute both implements IActionFilter and derives from Attribute, so the same class can be applied directly with [TimingFilter].[TimingFilter]
public class OrdersController : ControllerBase { ... } // applies to every action in this controller
// or globally, in Program.cs:
builder.Services.AddControllers(options =>
{
options.Filters.Add<TimingFilterAttribute>();
});
An action filter that logs the action name and its bound arguments — information plain middleware simply doesn't have:
public class LogArgumentsFilter(ILogger<LogArgumentsFilter> logger) : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
var actionName = context.ActionDescriptor.DisplayName;
var arguments = string.Join(", ", context.ActionArguments.Select(a => $"{a.Key}={a.Value}"));
logger.LogInformation("Invoking {Action} with {Arguments}", actionName, arguments);
}
public void OnActionExecuted(ActionExecutedContext context)
{
logger.LogInformation("Finished {Action}, result type: {ResultType}",
context.ActionDescriptor.DisplayName, context.Result?.GetType().Name);
}
}
Why this couldn't be plain middleware: context.ActionDescriptor and context.ActionArguments are MVC concepts that don't exist yet at the point where middleware runs — they're only populated once routing has matched a Controller action and model binding has run. Because this class implements IActionFilter and is a real service (constructor-injected with ILogger), you register it via DI and apply it with [ServiceFilter(typeof(LogArgumentsFilter))] or add it globally.
Before [ApiController] automated it (a story for lesson 257), the classic way to reject a request with invalid model state was exactly this kind of action filter — and it's still a useful pattern to understand, because it's the same shape teams use for other validation-adjacent, cross-cutting concerns today:
public class ValidateModelStateAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
// Short-circuit: set the result here, and the action method never runs at all
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
Setting context.Result inside OnActionExecuting is a filter's way of short-circuiting — much like skipping next() in middleware. Once it's set, the action invoker skips the action method entirely and moves straight to running result filters against the result you provided. This is a genuinely different, MVC-aware short-circuit than anything middleware could express, because it needs ModelState, which only exists after model binding has run for this specific action.
Minimal APIs don't use Controllers, so classic MVC filters don't apply to them at all. Instead, ASP.NET Core provides IEndpointFilter — a newer, lighter mechanism covering many of the same cross-cutting use cases (logging, short-circuiting, transforming a result), designed specifically for the delegate-based Minimal API model. Lesson 256 covers IEndpointFilter in full detail; for now, the important thing is recognizing it as "the Minimal API answer to the question classic filters answer for Controllers" — not a competing concept, but the equivalent for a different handler style.
next(), an authorization, resource, or action filter setting a result causes MVC to skip everything that would normally run after it (including the action method itself, for the earlier filter types) and jump straight to producing the response from that result.They share a similar shape (before/after, ability to short-circuit), but they operate at fundamentally different layers. Middleware runs for every request that passes through the app, before ASP.NET Core even knows which endpoint (if any) will handle it. Filters only run for requests handled by a Controller action, and only after routing, and often model binding, has already happened — so they can rely on information middleware never has access to.
UseAuthorization() middlewareThese sound similar and address related concerns, but they're distinct mechanisms at different layers: UseAuthorization() middleware (lesson 254) is the standard, broadly-applicable way to enforce authorization policies across both Controllers and Minimal APIs. MVC authorization filters are an older, MVC-specific mechanism that predates unified endpoint-based authorization and still exists for advanced, MVC-only scenarios. In modern ASP.NET Core, the middleware-based approach (via [Authorize] and policies) is the standard recommendation for most apps.
Writing an action filter for something like global request logging or CORS handling — a concern that has nothing to do with actions specifically and should apply to every request, including ones that never reach a Controller (like static files or Minimal API endpoints).
If the concern doesn't genuinely need action-specific context, middleware is simpler, runs for all endpoint types, and doesn't tie the logic to MVC.
Applying an ActionFilterAttribute-based filter and expecting it to run for a Minimal API route mapped with app.MapGet(...). It won't — classic MVC filters are part of the Controller/action-invocation machinery, which Minimal APIs don't use.
For Minimal APIs, use IEndpointFilter instead (lesson 256).
Implementing expensive short-circuit logic (like a cache lookup) as an action filter, which runs after model binding has already done its work — wasting that effort on requests you're about to serve from cache anyway.
A resource filter runs earlier, wrapping model binding itself, so a cache short-circuit implemented there avoids the unnecessary binding work entirely.
An honest way to choose between the three tools this Part covers for cross-cutting concerns:
MapGroupnext() does for middleware.IEndpointFilter instead.
IEndpointFilter is the modern, Minimal-API-compatible equivalent for apps that don't use Controllers — full treatment in the next lesson.You've seen the five filter types and how they relate to middleware. Let's check whether the distinction has stuck.
1. What is the core distinction between middleware and MVC filters?
Correct: B
Why B is correct: Middleware sees only the raw HttpContext and has no concept of "actions." Filters run inside MVC's own action-invocation pipeline and have access to the matched action, its bound arguments, model validation state, and the controller instance.
Why A is incorrect: HTTP verb has nothing to do with whether middleware or filters apply.
Why C is incorrect: Filters still run after routing has matched an endpoint — they don't skip it.
Why D is incorrect: They're related in shape (both can run before/after and short-circuit) but operate at genuinely different layers with different available context.
Reinforcement: Scope and available context are the real distinction — not speed or HTTP verb.
2. An action filter's OnActionExecuting method sets context.Result to a BadRequestObjectResult. What happens next?
Correct: B
Why B is correct: Setting context.Result in OnActionExecuting is the standard way an action filter short-circuits — the action method itself never runs, and the pipeline moves directly to result filters and producing the response.
Why A is incorrect: Once the result is set this way, the action method is bypassed — it doesn't run at all, so nothing overwrites your result.
Why C is incorrect: This is a completely standard, supported, and common pattern (it's exactly how pre-[ApiController] model-state validation filters worked).
Why D is incorrect: A response is still produced — from the result you explicitly set, via the result-filter stage.
Reinforcement: Setting a result inside a filter is the MVC-aware equivalent of skipping next() in middleware.
3. Why can't a resource filter's caching short-circuit be implemented at the action-filter stage instead, without losing any benefit?
Correct: B
Why B is correct: Resource filters wrap model binding itself, so a cache-hit short-circuit there skips binding entirely. An action filter runs after binding has already completed, so you'd pay the binding cost on every request even when serving from cache.
Why A is incorrect: Action filters can absolutely set a result too — the difference is about pipeline position and what work has already happened by that point, not capability.
Why C is incorrect: The stages genuinely differ in what has run before them, which is precisely why choosing the right filter type matters.
Why D is incorrect: Action filters run for any HTTP verb reaching a matched action — this isn't verb-specific.
Reinforcement: Picking the earliest filter type that can do the job avoids wasted work later in the pipeline.
4. You've applied a classic ActionFilterAttribute-based filter to log every action call, but it never fires for one particular endpoint. That endpoint is registered with app.MapGet("/health", () => "ok"). Why?
Correct: B
Why B is correct: Classic MVC filters are part of the Controller/action-invocation machinery. A Minimal API endpoint mapped with MapGet never goes through that machinery at all, so ActionFilterAttribute-based filters simply don't apply to it — IEndpointFilter is the equivalent for that style (lesson 256).
Why A is incorrect: There's nothing special about the route name /health — the issue is the endpoint style, not the path.
Why C is incorrect: HTTP verb is irrelevant here — the issue is Minimal API vs. Controller, not GET vs. POST.
Why D is incorrect: [ApiController] is a Controller-class attribute; it has no bearing on Minimal API endpoints, which don't use Controller classes at all.
Reinforcement: Filters are Controller-pipeline-specific — Minimal APIs need IEndpointFilter for equivalent behavior.
5. You need a cross-cutting concern applied to every single request in your app — including static file requests and Minimal API endpoints, not just Controller actions. Which is the correct tool?
Correct: B
Why B is correct: Middleware is the only one of these that applies uniformly to every request regardless of how — or whether — it's eventually routed to a Controller action or a Minimal API endpoint, because it runs before that distinction is even resolved.
Why A is incorrect: Even a global filter only applies to requests that reach a Controller action — static files and Minimal API endpoints never enter the MVC filter pipeline at all.
Why C, D are incorrect: Both are scoped to specific endpoint styles (Minimal APIs, or Controllers respectively) — neither uniformly covers every request across both styles and static files the way middleware does.
Reinforcement: When a concern must be universal across every request type, middleware is the tool built for exactly that scope.
You now know exactly where filters fit relative to middleware, and how to pick the right interception point for a given concern.
dotnetmadeeasy.com — Learn C# and .NET, the right way.