Result Filters and Exception Filters

Result filters run around the execution of the action result — after the action method returns its IActionResult, but before and after it is executed (rendered to the response). They are less commonly needed than action filters but useful for modifying results cross-cutting: adding standard response headers, wrapping results in an envelope, or logging response metadata. Exception filters catch unhandled exceptions thrown by action methods and action filters — they provide a per-controller or per-action exception handling layer that is more specific than global middleware exception handling.

Result Filter

// ── Add security response headers to all action results ───────────────────
public class SecurityHeadersResultFilter : IResultFilter
{
    public void OnResultExecuting(ResultExecutingContext context)
    {
        // Runs just before the result (view, JSON, etc.) is written to response
        var response = context.HttpContext.Response;
        response.Headers["X-Content-Type-Options"] = "nosniff";
        response.Headers["X-Frame-Options"]        = "DENY";
        response.Headers["Referrer-Policy"]        = "strict-origin-when-cross-origin";
    }

    public void OnResultExecuted(ResultExecutedContext context)
    {
        // Runs after the result has been written to the response
        // Cannot modify headers here — they are already sent
        // Can log result metadata
        var resultType = context.Result?.GetType().Name ?? "null";
        // Log: action completed, result type
    }
}

// ── Register globally ─────────────────────────────────────────────────────
builder.Services.AddControllersWithViews(opts =>
{
    opts.Filters.Add<SecurityHeadersResultFilter>();
});
Note: Result filters run only when the action successfully produces a result. If an exception filter or authorization filter short-circuits the pipeline before the result phase, result filters do not run. For security headers that must appear on every response (including error responses), use middleware (app.UseMiddleware<SecurityHeadersMiddleware>) instead of a result filter — middleware runs for all responses, filters only for successful action results.
Tip: Exception filters are ideal for mapping domain exceptions to HTTP responses at the controller level, keeping this logic out of action methods. Register one exception filter on a base controller class and all controllers inherit the mapping: NotFoundException → 404, ConflictException → 409, UnauthorisedAccessException → 403. This eliminates repetitive try/catch blocks from every action method and ensures consistent error responses across all actions.
Warning: Exception filters do NOT catch exceptions thrown by result execution (view rendering), resource filters, or the exception filter itself. They only catch exceptions from action methods and action filters. For comprehensive exception handling that covers view rendering errors and middleware exceptions, use app.UseExceptionHandler() or a global IExceptionHandler in addition to exception filters. Both layers complement each other.

Exception Filter

// ── Exception filter — maps domain exceptions to HTTP responses ────────────
public class DomainExceptionFilter(ILogger<DomainExceptionFilter> logger) : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        // Map domain exceptions to HTTP status codes
        context.Result = context.Exception switch
        {
            NotFoundException ex =>
                new NotFoundObjectResult(new { error = ex.Message }),

            ConflictException ex =>
                new ConflictObjectResult(new { error = ex.Message }),

            ForbiddenException =>
                new ForbidResult(),

            ValidationException ex =>
                new BadRequestObjectResult(new
                {
                    error  = "Validation failed.",
                    errors = ex.Errors,
                }),

            _ => null   // null = not handled — let it propagate
        };

        if (context.Result is not null)
        {
            // Mark as handled so it does not propagate further
            context.ExceptionHandled = true;

            logger.LogWarning(context.Exception,
                "Domain exception handled by filter: {ExceptionType}",
                context.Exception.GetType().Name);
        }
        else
        {
            // Unhandled exception — let global handler deal with it
            logger.LogError(context.Exception, "Unhandled exception in action.");
        }
    }
}

// ── Apply to all controllers via base controller ──────────────────────────
[ServiceFilter(typeof(DomainExceptionFilter))]
public abstract class BaseController : Controller { }

builder.Services.AddScoped<DomainExceptionFilter>();

Common Mistakes

Mistake 1 — Not setting context.ExceptionHandled = true (exception re-thrown)

❌ Wrong — setting context.Result without context.ExceptionHandled; exception propagates to global handler as well:

context.Result = new NotFoundResult();
// forgot: context.ExceptionHandled = true — exception still propagates!

✅ Correct — always set both context.Result and context.ExceptionHandled = true when handling an exception.

Mistake 2 — Using exception filters for exceptions from view rendering

❌ Wrong — exception filter cannot catch exceptions thrown during Razor view compilation or rendering.

✅ Correct — use UseExceptionHandler middleware for comprehensive coverage including view rendering errors.

🧠 Test Yourself

An exception filter sets context.Result = new NotFoundResult() but does NOT set context.ExceptionHandled = true. What happens?