Result Filters in ASP.NET Core Web API

Result Filters in ASP.NET Core Web API

In this article, I will discuss Result Filters in ASP.NET Core Web API with Examples. Please read our previous article discussing Action Filters in ASP.NET Core Web API Applications. Result Filters in ASP.NET Core Web API provide a powerful mechanism to execute custom logic before and after the execution of the action result. Result Filters specifically operate around the result processing stage, i.e., controlling or modifying the HTTP response returned by the action.

What is Result Execution in ASP.NET Core?

Result Execution is the process where ASP.NET Core takes the output returned by your action method (called the action result) and actually converts it into an HTTP response to send back to the client. When your action method finishes running, it typically returns some kind of result like JSON data, a status code, or a view.

Result Execution is the step where the framework processes this result, serializes data (e.g., converts your object to JSON), sets HTTP headers, and writes the response body to the network stream. This step happens after the action method runs but before the client receives the final response. In short:

  • Action method: Prepares the data or result to be sent.
  • Result Execution: The framework transforms that result into the actual HTTP response (body, headers, status code) that the client receives.
What is a Result Filter in ASP.NET Core?

A Result Filter is a filter in ASP.NET Core that allows us to intercept and manipulate the result of an action method just before it is written to the response and immediately after the result execution completes. The following are the two stages where the Result Filters are executed in the ASP.NET Core Request Processing Pipeline:

  • Pre-Result Processing: Run logic before the action result is executed (for example, modifying the result object, changing the response format, setting custom headers, etc.).
  • Post-Result Processing: Run logic after the action result has been executed (for example, logging the response, auditing, or applying custom output transformations).

Result Filters are ideal for handling scenarios that require manipulating the response after the action method runs but before the result is sent to the client.

Example: Restaurant Chef Cooking, Waiter Plating

Result Filters in ASP.NET Core Web API with Examples

In a restaurant:

  • Action Method: The chef cooks the food (creates the dish).
  • Result Execution: The waiter plates the food and adds garnishing.
  • Result Filter: The waiter inspects the plate before serving to ensure presentation is perfect and adds a final touch.

In API terms, the Result Filter runs right after the action prepares the response and just before it’s sent back, giving a chance to enhance or check the final output.

Example: Packing a Gift Box After Wrapping the Package

Result Filters in ASP.NET Core Web API with Examples

Imagine you are sending a Gift to a friend:

  • Action Method: You prepare the item to be sent (e.g., a book, toy, or clothes).
  • Result Execution: You wrap the item inside a beautiful box with a ribbon.
  • Result Filter: The person who does the wrapping and packing just before the package is shipped, making sure it looks perfect and adding a greeting card.

In API terms, the Result Filter runs after your action creates the response data but before the data goes out to the client. It can modify or decorate the response, like wrapping it nicely or adding extra info.

How Do Result Filters Work in ASP.NET Core Web API?

ASP.NET Core provides two main interfaces for creating Result Filters:

IResultFilter: For synchronous operations, with two methods:

  • OnResultExecuting(ResultExecutingContext context)
  • OnResultExecuted(ResultExecutedContext context)

IAsyncResultFilter: For asynchronous operations, with one method:

  • Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)

You can implement either interface directly or use the ResultFilterAttribute base class, which supports attribute-based usage and provides virtual methods for overriding. Result Filters execute at two points in the response pipeline:

  • OnResultExecuting (before result execution): Allows inspection and modification of the result before it is sent to the client.
  • OnResultExecuted (after result execution): Allows post-processing, such as logging or auditing, after the result is sent.
Real-time Examples to Understand Result Filters in ASP.NET Core Web API

We need to use Result Filters when we want to handle tasks that specifically relate to how the response is created, modified, or handled before it is sent to the client. These tasks usually affect the output processing stage rather than the action method’s internal business logic. The following are the Common Scenarios for Using Result Filters in ASP.NET Core Web API Applications.

First, create a new ASP.NET Core Web API Project named ResultFiltersDemo and then a folder named Filters in the project root directory, where we will create all our Result Filters.

Global Response Wrapping

When building APIs, every endpoint should return data in a predictable, standardized format, usually with fields like Status, Message, and Data. This uniformity is especially critical in large projects, public APIs, or environments with multiple frontend clients, as it ensures all consumers can parse, handle, and display responses.

How Result Filter Helps:

Instead of repeating wrapping logic in every controller or action method, you implement the response structure just once in a Result Filter. The filter automatically wraps every response (whether success or error) in your chosen format. This reduces code duplication and ensures every client receives a consistent response structure, making client development simpler and less error-prone.

Example: Instead of returning plain data: { “Id”: 1, “Name”: “John” }

You use a Result Filter to always return:

{
  "Status": 200,
  "Message": "Success",
  "Data": { "Id": 1, "Name": "John"  }
}
Creating Response Wrapper Result Filter

So, create a new class file named ApiResponseWrapperResultFilter.cs within the Filters folder and then copy and paste the following code. The OnResultExecuting intercepts the outgoing result before it’s sent. It checks the type of result and wraps ObjectResult or EmptyResult into a consistent JSON format. The wrapped response always contains Status, Message, and Data fields, which help clients parse responses uniformly. The OnResultExecuted is available for any cleanup or logging after response delivery, but is not used here.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace ResultFiltersDemo.Filters
{
    // Custom Result Filter to wrap API responses in a standard format
    public class ApiResponseWrapperResultFilter : IResultFilter
    {
        // This method runs just before the action result executes (before response is written)
        public void OnResultExecuting(ResultExecutingContext context)
        {
            // Check if the result returned by the action is an ObjectResult (typically JSON data)
            if (context.Result is ObjectResult objectResult)
            {
                // Create a new anonymous object to wrap the original response
                // Status: HTTP status code (default 200 if not set)
                // Message: "Success" for 200 status, otherwise "Error"
                // Data: The original value returned by the action method
                var wrappedResponse = new
                {
                    Status = objectResult.StatusCode ?? 200,
                    Message = objectResult.StatusCode == 200 ? "Success" : "Error",
                    Data = objectResult.Value
                };

                // Replace the original action result with a new JsonResult wrapping the above structure
                // Maintain the original status code on the new JsonResult
                context.Result = new JsonResult(wrappedResponse)
                {
                    StatusCode = objectResult.StatusCode
                };
            }
            // If the result is EmptyResult (no content returned)
            else if (context.Result is EmptyResult)
            {
                // Wrap it with a standard response indicating no content
                var wrappedResponse = new
                {
                    Status = 204,               // HTTP 204 No Content status code
                    Message = "No Content",    // Informative message
                    Data = (object?)null       // Data is null
                };

                // Replace EmptyResult with a JsonResult to maintain consistent response format
                context.Result = new JsonResult(wrappedResponse)
                {
                    StatusCode = 204           // Set HTTP status to 204 No Content
                };
            }
            // Additional result types (e.g., ContentResult, FileResult) could be handled similarly if needed
        }

        // This method runs after the result has been executed (after response is sent)
        public void OnResultExecuted(ResultExecutedContext context)
        {
            // This is optional for post-result processing and currently left empty
        }
    }
}
Modifying HTTP Response Headers Based on Action Results

HTTP headers are crucial for conveying metadata like security instructions, caching policies, API versioning, or custom tracking to clients and intermediate servers. Sometimes you may need to dynamically set, add, or update these headers based on the result or type of response being sent.

How Result Filter Helps:

A Result Filter allows us to manage headers centrally, just before the response is sent. You can inspect the outgoing result and add or modify headers as needed, such as appending security headers, setting cache controls for specific results, or injecting version information. This approach avoids spreading header logic throughout your controllers and applies headers uniformly.

Example: Automatically add a version header or cache control to all or selected responses, without extra code in every controller:

  • X-API-Version: 1.0
  • Cache-Control: public, max-age=3600
Creating Custom Headers Result Filter

So, create a new class file named AddCustomHeadersResultFilter.cs within the Filters folder and then copy and paste the following code. This filter adds custom HTTP headers right before the response is sent. It ensures versioning info is added once. It applies cache-control headers only to safe, cacheable GET responses with status 200. Using ResultFilterAttribute makes it easy to override and extend default behavior.

using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;

namespace ResultFiltersDemo.Filters
{
    // Custom Result Filter that adds or modifies HTTP headers in the response
    public class AddCustomHeadersResultFilter : ResultFilterAttribute
    {
        // This method runs just before the action result executes (before response is sent)
        public override void OnResultExecuting(ResultExecutingContext context)
        {
            // Get the HttpResponse object from the current HTTP context
            var response = context.HttpContext.Response;

            // Add a custom header "X-API-Version" with value "1.0" if it is not already present
            if (!response.Headers.ContainsKey("X-API-Version"))
            {
                response.Headers["X-API-Version"] = "1.0";
            }

            // Conditionally add Cache-Control header only for:
            // 1. HTTP GET requests (safe to cache)
            // 2. Responses where the result is an ObjectResult (typical JSON result)
            // 3. The response status code is 200 OK (success)
            if (context.HttpContext.Request.Method == "GET" &&
                context.Result is ObjectResult objectResult &&
                (objectResult.StatusCode ?? 200) == 200)
            {
                // Add Cache-Control header to indicate public caching with max age of 3600 seconds (1 hour)
                response.Headers["Cache-Control"] = "public, max-age=3600";
            }

            // Call base method to ensure any base class logic is executed
            base.OnResultExecuting(context);
        }
    }
}
Customizing Response Content (Compression, Encryption, Transformation)

Sometimes the response content needs further processing before delivery, such as compressing large payloads to save bandwidth, encrypting sensitive data for security, or transforming JSON responses into another format (like XML) for specific clients.

How Result Filter Helps:

By handling these operations in a Result Filter, we ensure all necessary processing happens in a single, centralized place rather than repeating logic in every action. This makes maintenance easier and ensures that response transformations are consistently and correctly applied.

Example: Compress any JSON response larger than 100KB, or encrypt all outputs from specific endpoints for extra security.

Creating Response Compression Result Filter

So, create a new class file named ResponseCompressionResultFilter.cs within the Filters folder and then copy and paste the following code. This filter buffer the response by swapping response.Body with a MemoryStream. Execute the action/filter chain that writes the result into the buffer. Check buffer size and content type to decide if compression is needed. If yes, compress the buffer content into another memory stream. Write the compressed data back to the original response.Body. If no, just copy the buffer content back as-is. Set necessary headers like Content-Encoding and Content-Length.

using Microsoft.AspNetCore.Mvc.Filters;
using System.IO.Compression;

namespace ResultFiltersDemo.Filters
{
    // Custom asynchronous Result Filter that compresses JSON responses larger than 100 KB
    public class ResponseCompressionResultFilter : IAsyncResultFilter
    {
        // This method runs around the result execution asynchronously
        public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
        {
            // Get the current HTTP response from the context
            var response = context.HttpContext.Response;

            // Backup the original response body stream (network stream)
            var originalBodyStream = response.Body;

            // Create a MemoryStream to temporarily hold the response content (buffer it)
            using var bufferStream = new MemoryStream();

            // Replace the response body stream with the buffer stream so that
            // the action result writes to this buffer instead of directly to the client
            response.Body = bufferStream;

            // Proceed with the next filter or action execution; this writes to the bufferStream
            var executedContext = await next();

            // Reset the buffer position to the beginning so we can read the buffered response content
            bufferStream.Seek(0, SeekOrigin.Begin);

            // Check if the response should be compressed:
            // - Content-Type header exists and contains "application/json" (case-insensitive)
            // - Buffered response size is greater than 100 KB (100 * 1024 bytes)
            if (response.ContentType != null &&
                response.ContentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) &&
                bufferStream.Length > 100 * 1024)
            {
                // Add the Content-Encoding header to tell the client the response is gzip compressed
                response.Headers["Content-Encoding"] = "gzip";

                // Create a new MemoryStream to hold compressed data
                using var compressedStream = new MemoryStream();

                // Use GZipStream to compress the buffered response content
                // The third argument "true" leaves the compressedStream open after disposing gzipStream
                using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, true))
                {
                    // Copy all data from bufferStream into gzipStream to compress it
                    await bufferStream.CopyToAsync(gzipStream);
                }

                // Reset the position of compressedStream to the beginning for reading
                compressedStream.Seek(0, SeekOrigin.Begin);

                // Restore the original response body stream so we can write to the client
                response.Body = originalBodyStream;

                // Set Content-Length header to the size of the compressed data (optional but recommended)
                response.ContentLength = compressedStream.Length;

                // Write the compressed data to the original response body stream (network stream)
                await compressedStream.CopyToAsync(response.Body);
            }
            else
            {
                // If no compression needed:
                // Restore the original response body stream
                response.Body = originalBodyStream;

                // Copy the buffered original content as-is to the original stream (uncompressed)
                await bufferStream.CopyToAsync(response.Body);
            }
        }
    }
}
What the User Gets (Client Side)

The client (e.g., browser, mobile app, API consumer) receives the original, uncompressed JSON response after the browser or HTTP client automatically decompresses the data. For example, if your API sends:

{
  "Content": "AAAAA... (150KB of 'A's)"
}

The client’s HTTP library or browser decompresses the received gzip data transparently and provides the user with the exact JSON as above.

What Gets Sent Over the Network

Over the network, the response payload is compressed (e.g., gzip-compressed) to reduce data size. This means the actual bytes transferred on the wire are smaller, sometimes by 70% to 90% depending on content redundancy. For large JSON responses, gzip compression is highly effective, drastically reducing bandwidth usage and improving transfer speed.

Audit Logging of Responses Sent to Clients

Many organizations, especially those handling sensitive or regulated data, should keep a detailed audit log of all API responses for compliance, troubleshooting, or security reviews.

How Result Filter Helps:

A Result Filter provides a single point to log outgoing responses, including details such as status code, payload size, user information, and timestamp. This ensures that every response, regardless of which controller or action generated it, is logged uniformly and keeps logging separate from core business logic.

Example: Log each outgoing response’s HTTP status code (e.g., 200, 404, 500), user ID or client info, and payload size for auditing or debugging in financial or enterprise applications.

Creating Audit Logging Result Filter

So, create a new class file named AuditLoggingResultFilter.cs within the Filters folder and then copy and paste the following code. The filter runs after the result has been executed, meaning the response has been generated. It extracts useful information about the request and response for audit purposes. It logs all these details uniformly using the injected ILogger. This keeps audit logging concerns separated from business logic and controller code.

using Microsoft.AspNetCore.Mvc.Filters;
using System.Security.Claims;

namespace ResultFiltersDemo.Filters
{
    // Custom Result Filter for auditing/logging details about each response sent to clients
    public class AuditLoggingResultFilter : ResultFilterAttribute
    {
        private readonly ILogger<AuditLoggingResultFilter> _logger;

        // Constructor with dependency injection of ILogger for logging capabilities
        public AuditLoggingResultFilter(ILogger<AuditLoggingResultFilter> logger)
        {
            _logger = logger;
        }

        // This method runs after the action result has been executed and the response is ready
        public override void OnResultExecuted(ResultExecutedContext context)
        {
            // Get the current HTTP context (contains request and response info)
            var httpContext = context.HttpContext;

            // Get the HTTP response object for status code, headers, etc.
            var response = httpContext.Response;

            // Get the authenticated user principal associated with the request
            var user = httpContext.User;

            // Attempt to extract the user's ID from their claims (NameIdentifier claim)
            // If not found (unauthenticated), fallback to "Anonymous"
            string userId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "Anonymous";

            // Get the approximate size of the response payload in bytes if available
            long? payloadSize = response.ContentLength;

            // Log the audit details: status code, user ID, payload size, request path, and timestamp (UTC)
            _logger.LogInformation("Audit Log - Response Sent: " +
                                   $"StatusCode={response.StatusCode}, UserId={userId}, PayloadSize={payloadSize} bytes, " +
                                   $"Path={httpContext.Request.Path}, Timestamp={DateTime.UtcNow}");

            // Call the base class implementation (if any additional behavior is defined)
            base.OnResultExecuted(context);
        }
    }
}
Modify the Program class:

Next, modify the Program class as follows:

namespace ResultFiltersDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddControllers()
           .AddJsonOptions(options =>
            {
               // Disable camelCase in JSON output, preserve property names as defined in C# classes
               options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
API Controller Demonstrating Result Filters

Now, create an API Empty Controller named DemoController within the Controllers folder and then copy and paste the following code. The following controller code is self-explained, so please read the comment lines for a better understanding.

using Microsoft.AspNetCore.Mvc;
using ResultFiltersDemo.Filters;

namespace ResultFiltersDemo.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class DemoController : ControllerBase
    {
        // Action method to demonstrate Global Response Wrapping filter usage
        // Route: GET api/demo/wrapped-response
        // This applies the ApiResponseWrapperResultFilter to wrap responses in a standard format
        [HttpGet("wrapped-response")]
        [TypeFilter(typeof(ApiResponseWrapperResultFilter))]
        public IActionResult GetWrappedResponse()
        {
            // Prepare sample data to return
            var data = new { Id = 1, Name = "John Doe" };

            // Return HTTP 200 OK with the data; the filter will wrap this response
            return Ok(data);
        }

        // Action method to demonstrate adding/modifying HTTP response headers
        // Route: GET api/demo/headers
        // This applies AddCustomHeadersResultFilter to add headers like X-API-Version and Cache-Control
        [HttpGet("headers")]
        [TypeFilter(typeof(AddCustomHeadersResultFilter))]
        public IActionResult GetWithCustomHeaders()
        {
            // Prepare sample message to return
            var data = new { Message = "Headers should be modified in the response." };

            // Return HTTP 200 OK with the message; the filter adds custom headers to this response
            return Ok(data);
        }

        // Action method to simulate a large response to test compression filter
        // Route: GET api/demo/large-response
        // Applies ResponseCompressionResultFilter to compress JSON responses larger than 100 KB
        [HttpGet("large-response")]
        [TypeFilter(typeof(ResponseCompressionResultFilter))]
        public IActionResult GetLargeResponse()
        {
            // Generate a large string of 150 KB size (150 * 1024 characters)
            var largeData = new string('A', 150 * 1024);

            // Return the large data wrapped in an object; filter will compress this response
            return Ok(new { Content = largeData });
        }

        // Action method to demonstrate audit logging of responses
        // Route: GET api/demo/audit-logging
        // Applies AuditLoggingResultFilter to log response details after execution
        [HttpGet("audit-logging")]
        [TypeFilter(typeof(AuditLoggingResultFilter))]
        public IActionResult GetForAuditLogging()
        {
            // Prepare a list of sample entries
            var info = new List<string> { "Entry1", "Entry2", "Entry3" };

            // Return HTTP 200 OK with the list; filter logs this response's details
            return Ok(info);
        }

        // Action method demonstrating combining multiple filters on a single action
        // Route: GET api/demo/combined
        // Applies response wrapping, header modification, and audit logging filters together
        [HttpGet("combined")]
        [TypeFilter(typeof(ApiResponseWrapperResultFilter))]
        [TypeFilter(typeof(AddCustomHeadersResultFilter))]
        [TypeFilter(typeof(AuditLoggingResultFilter))]
        public IActionResult GetCombined()
        {
            // Prepare sample status data
            var data = new { Status = "Multiple filters applied" };

            // Return HTTP 200 OK with data; all specified filters run in order on this response
            return Ok(data);
        }
    }
}
Code Explanations:
  • Each action method demonstrates the use of one or more custom result filters applied via [TypeFilter] attributes.
  • Filters handle cross-cutting concerns like response formatting, headers, compression, and auditing separately from controller logic.
  • The combined action shows how multiple filters can be stacked on a single endpoint for complex scenarios.
Benefits of Result Filters in ASP.NET Core:

The following are the Benefits of using Result Filters in an ASP.NET Core Application:

  • Separation of Concerns: Keeps response-related logic out of controllers, which should focus only on business rules and action execution.
  • Code Reusability: Implement common response processing once and apply it globally or selectively using attributes.
  • Consistency: Guarantees that all API responses follow the same rules for headers, format, caching, or logging.
  • Maintainability: Easier to update response behaviour by changing the filter logic instead of modifying multiple actions or controllers.

Result Filters in ASP.NET Core Web API provide a clean, powerful mechanism to handle cross-cutting output concerns such as response shaping, global output formatting, and header customization. By using Result Filters alongside Action Filters, we achieve a clean separation of responsibilities, keeping controllers focused on business logic and ensuring uniformity and maintainability across your API.

In the next article, I will discuss Exception Filters in ASP.NET Core Web API Applications. In this article, I explain Result Filters in ASP.NET Core Web API Application with Examples. I hope you enjoy this Result Filters in ASP.NET Core Web API article.

Leave a Reply

Your email address will not be published. Required fields are marked *