Custom Exception and Result Filters, DI and Filter Order
In the previous lesson we built authorization, resource and action filters. In this final lesson we complete the set with exception and result filters, then learn three things every professional project needs: using dependency injection in filters, controlling the order of filters, and best practices.
1. Exception filters
An exception filter runs when an unhandled exception is thrown in the MVC part of the request. It can turn the exception into a proper HTTP response, so clients never see a raw error or stack trace.
Exception filters catch exceptions thrown in:
- Controller creation (for example, a constructor dependency fails)
- Model binding
- Action filters
- The action method itself
They do not catch exceptions from middleware, resource filters, result filters, or while the result is being written (for example, inside a Razor view).
1a. A global API exception filter with ProblemDetails
Scenario: a Web API used by a React front end and a mobile app. Every error must come back in the same JSON shape, with the right status code: 404 for "not found", 400 for business-rule errors, 500 for bugs. In production, internal error details must be hidden.
First, two custom exceptions our services throw:
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class BusinessRuleException : Exception
{
public BusinessRuleException(string message) : base(message) { }
}
// Used in a service
public async Task<Order> GetOrderAsync(int id) =>
await _db.Orders.FindAsync(id) ?? throw new NotFoundException($"Order {id} was not found");
Now the filter. It uses the standard ProblemDetails format (RFC 7807), which ASP.NET Core already uses for its own errors:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public class ApiExceptionFilter : IExceptionFilter
{
private readonly ILogger<ApiExceptionFilter> _logger;
private readonly IHostEnvironment _env;
public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger, IHostEnvironment env)
{
_logger = logger;
_env = env;
}
public void OnException(ExceptionContext context)
{
if (context.ExceptionHandled)
return; // another filter already handled it
Exception ex = context.Exception;
(int status, string title) = ex switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
BusinessRuleException => (StatusCodes.Status400BadRequest, "Business rule violated"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Access denied"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
if (status == StatusCodes.Status500InternalServerError)
_logger.LogError(ex, "Unhandled exception in {Action}", context.ActionDescriptor.DisplayName);
else
_logger.LogWarning("{Title}: {Message}", title, ex.Message);
var problem = new ProblemDetails
{
Status = status,
Title = title,
// never show internal error messages to clients in production
Detail = status == 500 && !_env.IsDevelopment() ? null : ex.Message,
Instance = context.HttpContext.Request.Path
};
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.Result = new ObjectResult(problem)
{
StatusCode = status,
ContentTypes = { "application/problem+json" }
};
context.ExceptionHandled = true; // tell MVC the exception is handled
}
}
// Program.cs: apply to every controller action
builder.Services.AddControllers(options =>
{
options.Filters.Add<ApiExceptionFilter>();
});
Now controllers don't need any try/catch. They just call the service; if it throws, the filter produces the right response.
Two key properties
context.Result: the response to send instead of the error.context.ExceptionHandled = true: marks the exception as handled. If you don't set it (or don't set a result), the exception keeps travelling up and reaches the exception-handling middleware.
1b. A focused exception attribute for one controller
Scenario: two admins edit the same product at the same time. Entity Framework Core throws DbUpdateConcurrencyException for the second save. For the product controller we want a friendly 409 Conflict instead of a 500.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
public class HandleConcurrencyAttribute : ExceptionFilterAttribute
{
public override void OnException(ExceptionContext context)
{
if (context.Exception is DbUpdateConcurrencyException)
{
context.Result = new ConflictObjectResult(new
{
message = "This record was changed by another user. Please reload and try again."
});
context.ExceptionHandled = true;
}
// any other exception is left for the global filter / middleware
}
}
[HandleConcurrency]
public class AdminProductsController : ControllerBase { ... }
Exception filter or exception middleware? Exception filters only see MVC errors. For errors anywhere in the app (middleware, minimal APIs, views), also configure the exception-handling middleware: app.UseExceptionHandler(), or IExceptionHandler in .NET 8+. A common setup is: middleware as the safety net for everything, and filters for MVC-specific responses. See Global Exception Handling with Middleware and Global Exception Filter.
2. Result filters
A result filter runs immediately before and after the action result executes, which is when the view is rendered or the JSON is written. It is the right place to change the response.
Result filters run only when the action (or an action filter) produced a result. They are skipped when an authorization or resource filter short-circuits the request, or when an exception filter creates the result. If you need a result filter that always runs, implement IAlwaysRunResultFilter.
2a. Adding a response header
using Microsoft.AspNetCore.Mvc.Filters;
public class AddHeaderAttribute : ResultFilterAttribute
{
private readonly string _name;
private readonly string _value;
public AddHeaderAttribute(string name, string value)
{
_name = name;
_value = value;
}
public override void OnResultExecuting(ResultExecutingContext context)
{
context.HttpContext.Response.Headers[_name] = _value;
}
}
[AddHeader("X-API-Version", "2.0")]
public class ProductsV2Controller : ControllerBase { ... }
Change headers in OnResultExecuting (before). In OnResultExecuted (after), the response body has usually started, and headers can't be changed any more (Response.HasStarted is true).
2b. Wrapping every successful response in a standard format
Scenario: the mobile team wants every successful response in one shape: { "success": true, "data": ... }. Instead of changing 80 actions, one global result filter does it.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public record ApiResponse(bool Success, object? Data);
public class ApiResponseWrapperFilter : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
// wrap only successful object results, and never wrap error ProblemDetails
if (context.Result is ObjectResult { Value: not ProblemDetails } result
&& (result.StatusCode ?? 200) is >= 200 and < 300)
{
context.Result = new ObjectResult(new ApiResponse(true, result.Value))
{
StatusCode = result.StatusCode ?? 200
};
}
await next(); // execute the (possibly replaced) result
}
}
builder.Services.AddControllers(options => options.Filters.Add<ApiResponseWrapperFilter>());
3. Dependency injection in filters
Filters often need services: a logger, a DbContext, a cache. But attributes are created by the .NET runtime, so their constructors can only take constant values (strings, numbers, typeof), not services. ASP.NET Core gives four ways around this:
3a. ServiceFilter
builder.Services.AddScoped<AuditFilter>(); // 1. register the filter itself
[ServiceFilter(typeof(AuditFilter))] // 2. apply it
public IActionResult DeleteProduct(int id) { ... }
The filter is resolved from the DI container, so it uses the lifetime you registered (scoped, singleton...).
3b. TypeFilter
public class AuditFilter : IAsyncActionFilter
{
private readonly AppDbContext _db;
private readonly string _operation;
// services come from DI, "operation" comes from the attribute
public AuditFilter(AppDbContext db, string operation)
{
_db = db;
_operation = operation;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
ActionExecutedContext executed = await next();
if (executed.Exception == null)
{
_db.AuditLogs.Add(new AuditLog
{
User = context.HttpContext.User.Identity?.Name ?? "anonymous",
Operation = _operation,
Time = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
}
}
// No registration needed. Extra constructor values are passed with Arguments.
[TypeFilter(typeof(AuditFilter), Arguments = new object[] { "DeleteProduct" })]
public IActionResult DeleteProduct(int id) { ... }
3c. Global registration
options.Filters.Add<LogExecutionTimeFilter>(); // constructor services injected automatically
3d. IFilterFactory: a clean attribute that uses DI
[TypeFilter(typeof(AuditFilter), Arguments = ...)] is long. An attribute that implements IFilterFactory gives you a neat [Audit("DeleteProduct")] and still uses DI:
public class AuditAttribute : Attribute, IFilterFactory
{
private readonly string _operation;
public AuditAttribute(string operation) => _operation = operation;
public bool IsReusable => false; // create a new filter for every request (it uses a scoped DbContext)
public IFilterMetadata CreateInstance(IServiceProvider services) =>
new AuditFilter(services.GetRequiredService<AppDbContext>(), _operation);
}
[Audit("DeleteProduct")]
public IActionResult DeleteProduct(int id) { ... }
| Option | Register filter in DI? | Extra arguments? | Best for |
|---|---|---|---|
[ServiceFilter] | Yes | No | Filters whose lifetime you want to control |
[TypeFilter] | No | Yes (Arguments) | Quick use of DI plus a few values |
Filters.Add<T>() | No | No | Global filters |
IFilterFactory attribute | No | Yes (constructor) | Reusable, readable custom attributes |
Watch the lifetimes. A filter instance created once and reused (a singleton registration with [ServiceFilter], or IsReusable = true) must not hold a scoped service such as DbContext, or every request would share one DbContext. Register such filters as scoped, or set IsReusable = false.
4. Controlling filter order
By default, filters of the same type run by scope: global, then controller, then action (for the "before" part; the "after" part is reversed). To change this, set the Order property (default 0). Lower Order runs its "before" code first, and its "after" code last.
// Global filter that must wrap everything else
options.Filters.Add<LogExecutionTimeFilter>(order: int.MinValue);
// Attribute filters: set Order where you apply them
[ValidateId("id", Order = 1)]
[ServiceFilter(typeof(AuditFilter), Order = 2)]
public IActionResult Update(int id, ProductDto dto) { ... }
Order is compared first, and scope is used only to break ties. So an action-level filter with Order = -1 runs its "before" code earlier than a global filter with the default Order = 0.
The Order property comes from the IOrderedFilter interface. ActionFilterAttribute, ResultFilterAttribute, ExceptionFilterAttribute, ServiceFilter and TypeFilter all implement it.
5. Best practices
- Keep filters small and single-purpose: one filter, one concern (Single Responsibility).
- Don't put business logic in filters. Filters are for cross-cutting concerns; business rules belong in services.
- Use the async interfaces when you call a database, cache or HTTP API, and always
await next()exactly once (unless you are short-circuiting). - Prefer built-in features when they exist:
[Authorize]policies for user authorization,[OutputCache]for caching,[ApiController]for automatic model validation. - Combine filters with middleware for exceptions: filters for MVC-specific responses, middleware as the global safety net.
- Mind DI lifetimes for reused filter instances.
- Unit test filters by creating the context objects (
ActionExecutingContext,ExceptionContext) with aDefaultHttpContext, and asserting oncontext.Result.
Quick interview questions
What are the types of filters in ASP.NET Core, in order?
Authorization, Resource, Action, Exception and Result. Exception filters run only when an exception occurs; result filters run around result execution.
What is the difference between an action filter and a result filter?
Action filters run around the action method and can change its arguments or replace its result. Result filters run around result execution (writing the view or JSON) and are used to change the response.
What is the difference between ServiceFilter and TypeFilter?
[ServiceFilter] resolves the filter from DI, so the filter must be registered and uses its registered lifetime. [TypeFilter] creates the filter without registration, injects constructor services, and can pass extra Arguments.
How do you stop the pipeline from a filter?
Set context.Result in a "before" method (or don't call next() in an async filter). The rest of the pipeline, including the action, is skipped.
Which exceptions are not caught by exception filters?
Exceptions in middleware, resource filters, result filters and during result execution (for example, inside a view). Use exception-handling middleware for those.
What is the difference between filters and middleware?
Middleware runs for every request and only knows HttpContext. Filters run only for MVC actions, know MVC details (action, arguments, ModelState, results), and can be applied per action or controller.
Summary
- Exception filters turn MVC exceptions into consistent responses; set
context.ResultandExceptionHandled = true. - Result filters change the response around result execution; change headers before the result executes.
- Use
[ServiceFilter],[TypeFilter],Filters.Add<T>()orIFilterFactoryto use DI in filters. - Control order with
Order: lower values run their "before" code first. - Keep filters small, use async when awaiting, and combine them with middleware for full error coverage.