How to use Middleware and Filters in a .NET Core Pipeline

Written by
Written by

When developing software, we often need to apply specific actions across different parts of our application, such as standardizing responses or managing exceptions. Following the "Don't Repeat Yourself" or DRY principle, it's also important not to copy the same code everywhere. This becomes more challenging when certain actions are needed only for specific parts, like checking user sessions or modifying headers. 

That's where middleware and filters come in handy. Middleware allows us to apply certain actions to all requests, making our code cleaner and more efficient. On the other hand, Filters let us target specific situations, ensuring that we only apply actions where they're truly needed. Together, they help us manage our code effectively without unnecessary repetition.

Let’s take a closer look at both and their role in the .NET Core request pipeline. 

What is Middleware?

As its name suggests, middleware are software components in a web application's request pipeline that can be executed at various stages throughout the handling of a request, from its initiation to its completion. Therefore, we can think of the request pipeline as a sequence of middleware components, where each component performs operations before and/or after the next component. 

In .NET Core, middleware is configured in the Startup class's Configure method, where the .NET developer can define the order in which middleware components should run. The order is of course incredibly significant, as it will determine how requests are processed and how responses are constructed. For example, if you place authentication middleware before your static file middleware, the authentication process will occur before a static file is served to the user.

Middleware components can handle a wide range of tasks, such as authentication, logging, exception handling, and static file serving. Their highly customizable and extensible way to handle requests and responses enables developers to craft precise request handling schemes for their applications.

What are Filters?

Filters represent a way to run code before or after specific stages in the request processing pipeline, but they are distinct from middleware. Most notably, filters are specifically associated with MVC (Model-View-Controller) and Razor Pages actions. Engineers use filters to encapsulate cross-cutting concerns within your application, such as error handling, authorization, or logging, but they do so in a way that's targeted to the execution of action methods within controllers or Razor Pages.

Filters can be applied globally, to specific controllers, or to specific action methods, providing flexibility in how they are used. Unlike middleware, which is applied globally to all HTTP requests, filters provide a more granular level of control over the request processing pipeline, particularly around MVC actions and results.

There are several types of filters in ASP.NET Core, each designed to run at different points in the MVC request processing pipeline — ActionFilter, ExceptionFilter, ResultFilter, FormatFilter, ServiceFilter, and TypeFilter. Further down we will explore how a few of them are used in context. 

When to Use Middleware?

As we've discussed, middleware executes around the core logic of a request managed by controllers, which means they affect all requests in a REST API. This positioning makes middleware an optimal spot for both request and response modifications. Exception handling, for example, benefits greatly from middleware; developers can create specific middleware for this purpose. This centralizes error management and facilitates detailed error logging, improving traceability and reliability in the API and ensuring systematic handling and logging of errors.

.NET developers often need all endpoints in an API to return a standardized response. This is especially important when the API will be used by other developers, mainly because consistently returning a single type of object simplifies their integration process. Middleware is great for this, as it effectively transforms results into the desired standard object format. However, there's also a specific filter that’s perfect for this task, but we'll explore it later.

Validating tokens or session information for every request, such as checking token expiration, is another critical use case. Middleware allows us to intercept and cancel requests with expired tokens even before they reach the controller, thus conserving processing resources by preventing unnecessary workload on our systems.

Finally, we can use middleware to log comprehensive details about incoming requests and outgoing responses. That said, it's crucial to avoid logging sensitive client information to maintain security. While it's acceptable to log the type of response object, the actual content of these objects should never be logged, adhering to best practices for data protection and privacy.

Here’s an example of an ExceptionHandlerMiddleware

public class ExceptionHandlerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlerMiddleware> _logger;

    public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext context)
    {
        Console.WriteLine("Before ExceptionHandlerMiddleware...");
        try
        {
            await _next(context);
        }
        catch (MyException e)
        {
            CustomResponse customResponse = PrepareErrorResponse(ref context, e.Message, e.StatusCode);
            LogError(ref e);
            await context.Response.WriteAsync(JsonSerializer.Serialize(customResponse));
        }
        catch (Exception e)
        {
            CustomResponse customResponse = PrepareErrorResponse(ref context, e.Message, HttpStatusCode.InternalServerError);
            LogError(ref e);
            await context.Response.WriteAsync(JsonSerializer.Serialize(customResponse));
        }

        Console.WriteLine("After ExceptionHandlerMiddleware...");
    }

    private CustomResponse PrepareErrorResponse(ref HttpContext context, string message, HttpStatusCode statusCode)
    {
        CustomResponse customResponse = new()
        {
            StatusCode = statusCode,
            Data = null,
            Message = message,
            Sucess = false
        };
        context.Response.StatusCode = (int)statusCode;
        context.Response.ContentType = "application/json";
        return customResponse;
    }

    private void LogError<T>(ref T exception) where T : Exception 
    {
        _logger.LogError(exception.Message, exception);
    }
}

It's important to understand that if an ExceptionFilter is active, it will intercept any errors before they reach the middleware, essentially making the error invisible to the middleware component unless an error is explicitly thrown from within the ExceptionFilter itself, which is uncommon. However, employing both an ExceptionFilter and middleware gives you comprehensive control over error handling in your application.

When to Use Filters?

Unlike middleware, which applies globally, filters can be customized to target a single specific endpoint or a controller (which may encompass multiple endpoints). This flexibility allows for the addition of new functionalities to specific parts of an application without impacting others. There are several types of filters available, so let’s explore a few use cases for each.

ResultFilter

As its name suggests, the Result Filter is ideal for modifying the model of the response. This is because the filter provides access to the ResultExecutingContext object, which includes the ObjectResult, allowing for straightforward modifications. Yes, we could technically achieve similar results with middleware, but doing so would require more code to accomplish the same outcome.

This is the code to change response in the middleware

public class CustomResponseMiddleware
    {
        private readonly RequestDelegate _next;

        public CustomResponseMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            Console.WriteLine("Start CustomResponseMiddleware...");
            //Intercept response body with memory stream
            var originalResponseBody = context.Response.Body;
            using MemoryStream memoryStream = new MemoryStream();
            context.Response.Body = memoryStream;
            await _next(context);
            memoryStream.Seek(0, SeekOrigin.Begin);
            string responseBody = new StreamReader(memoryStream).ReadToEnd();
            object? response = JsonSerializer.Deserialize<object>(responseBody);
            //Create custom response here  
            CustomResponse customResponse = new()
            {
                StatusCode = HttpStatusCode.OK,
                Data = response,
                Message = string.Empty,
                Sucess = true
            };
            // Serialize the custom response and write it to the response body
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsync(JsonSerializer.Serialize(customResponse));
            memoryStream.Seek(0, SeekOrigin.Begin);
            await memoryStream.CopyToAsync(originalResponseBody);
            context.Response.Body = originalResponseBody;
            Console.WriteLine("End CustomResponseMiddleware...");
        }

    }

This is the code to change response using AsyncResultFilter

public class CustomResultFilter : Attribute, IAsyncResultFilter
    {

        //Here I can rewrite the result
        public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
        {
            Console.WriteLine("Start ResultFilter...");
            CustomResponse response = new CustomResponse
            {
                StatusCode = System.Net.HttpStatusCode.OK,
                Sucess = true
            };

            if (context.Result is ObjectResult result)
            {
                response.Data = result.Value;
            }

            context.Result = new ObjectResult(response);
            await next();
            Console.WriteLine("End ResultFilter...");
        }
    }

As you can see, it is easier to change the response using AsyncResultFilter compared to creating middleware. This simplicity is due to the ResultExecutingContext, which provides direct access to the result. 

While filters are typically used for specific endpoints, it's possible to configure them as global filters. This configuration would allow them to apply to all requests across the application, as shown here:

Program.cs file

builder.Services.AddControllers(opt =>
{
    opt.Filters.Add<CustomResultFilter>();
});

ExceptionFilter

This filter is triggered when an exception is thrown by the controller, effectively serving as an exception handler. However, it does not pass exceptions to the Result Filter, which poses a challenge, as the primary goal of an exception handler is to manage all errors comprehensively. 

Despite this limitation, this filter can still be valuable if the aim is to introduce specific behaviors in response to exceptions originating from the controller. Additionally, it can be utilized to enhance the error traceability within our application.

public class CustomExceptionFilter : Attribute, IAsyncExceptionFilter
    {
        private readonly ILogger<CustomExceptionFilter> _logger;
        public CustomExceptionFilter(ILogger<CustomExceptionFilter> logger)
        {
            _logger = logger;
        }
        public async Task OnExceptionAsync(ExceptionContext context)
        {
            Console.WriteLine("One exception has happened");
            _logger.LogInformation(context.Exception.Message);
            _logger.LogError(context.Exception.Message, context.Exception);
            HttpStatusCode statusCode = HttpStatusCode.InternalServerError;
            if(context.Exception is MyException exception)
            {
                statusCode = exception.StatusCode;
            }

            context.Result = new ObjectResult(new CustomResponse
            {
                Message = context.Exception.Message,
                StatusCode =  statusCode,
                Sucess = false
            });
            await Task.FromResult(0);
        }
    }

In this case I used this filter only for WeatherForecastController. I used TypeFilter to call the attribute because it contains arguments in its constructor.

[TypeFilter(typeof(CustomExceptionFilter))]
 public class WeatherForecastController : ControllerBase

ActionFilter

This filter is one of the first to run, and we can use it to access the arguments and body of the request, to validate the model, or to change the response. While a lot of this is easier to do with the Result Filter, as we mentioned before, the Action filter does have access to the ActionExecutingContext object, which can be leveraged to enhance application traceability.

We can use the Action filter validate tokens, headers, cache, or even cookies and throw an exception if it is necessary to cancel the request. The following example demonstrates how to append a token to the Authorization header, showcasing the practical application of this filter in managing request authentication.

public class AddTokenHeaderActionFilter : Attribute, IAsyncActionFilter
{
    private readonly ITokenService _tokenService;
    private readonly ILogInService _logInService;
    public AddTokenHeaderActionFilter(ITokenService tokenService, ILogInService logInService)
    {
        _tokenService = tokenService;
        _logInService = logInService;
    }

    //Here I can validate or add headers, I dont have the acces of the result here
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        Console.WriteLine("Start AddTokenHeaderActionFilter");
        await next();
        (string user, string roles)  = _logInService.GetLogInData();
        context.HttpContext.Response.Headers.Authorization = _tokenService.CreateToken(user, roles);
        Console.WriteLine("End AddTokenHeaderActionFilter");
    }
}

In the following scenario, the Action filter is used only in the LogIn request. We are using an HttpGet request for illustrative purposes here — in a professional setting, we would use an HttpPost request to securely transmit user credentials.

[ServiceFilter(typeof(AddTokenHeaderActionFilter))]
[HttpGet("LogIn")]
public IActionResult LogIn(string roles)
{
    _logInService.SetLogInData("User", roles);
    return Ok(new
    {
        Message = "Now you are logged"
    });
}

Findings 

  • Middleware runs for all requests.
  • The filter pipeline can be configured to target a specific endpoint, controller, or globally for all requests.
  • Exception filters don't catch errors thrown from the Result filter.
  • Using middleware for exceptions along with an exception filter is a feasible strategy.
  • Using the Result Filter to modify the responseBody is a more straightforward approach.
  • If the developer calls a request, the order of execution of middleware and filters will look something like this:
Before ExceptionHandlerMiddleware...
Start ActionFilter
End ActionFilter
Start ResultFilter...
End ResultFilter...
After ExceptionHandlerMiddleware...

Conclusion

Middleware is very common, but it is not rare for filters to offer a more streamlined approach for similar tasks. The fact is that each filter provides a different object to work with the right context and that simplifies access to information. 

Furthermore, filters allow for extensive customization, enabling precise control over the behavior of individual endpoints or entire controllers. This adherence to solid principles enhances the code's readability and understandability for future developers, facilitating easier maintenance and future updates.

Frequently Asked Questions