.NET 8 Series Starting Soon! Join the Waitlist

14 min read

Response Caching with MediatR in ASP.NET Core - Powerful Pipeline Behavior

#dotnet

In this article, we are going to implement Response Caching with MediatR in ASP.NET Core using its awesome Pipeline Behaviours. I have written a couple of articles about MediatR and why itā€™s one of the MUST USE libraries for .NET devs. This library simplifies your Application to an extent yet gives you complete control.

Before getting started with this article, I assume that you are aware of MediatR and its pipeline behaviors. If not, itā€™s recommended that you go through the following articles. Cheers!

Whatā€™s Response Caching with MediatR?

In a previous article, we discussed the Pipeline Behavior of MediatR and how Logging and Validation of Requests can be implemented. In this post, letā€™s go one step further and implement Caching in the MediatR pipeline. This means that every response that is sent out of your application (API) will be cached by your application with a very simple implementation.

Caching is quite a lifesaver at times for any application. It helps you reduce the round-trip time from the client to server to the database, and probably saves you $$$$ by minimizing the database calls. When it comes to caching in ASP.NET Core, you are presented with a whole lot of options. Few of them are as follows -

However the decision on where the caching happens is quite vital. There are several other questions that can arise when you deal with caching.

  • How long should the cache stay?
  • Which part of the application flow should be responsible for caching?
  • How to bypass cache if needed?
  • Do you need to write cache code all throughout the application? or is there a simpler way?
  • How to invalidate cache if needed?

MediatR Pipeline Behavior is the answer for all these questions. Given that you are already using CQRS with MediatR, why not use it to its complete potential.

Whenever you send a query to MediatR Handlers, your request and response have to go through an HTTP Pipeline aka MediatR Pipeline. This means, when you send a query request to the application, your request goes into the application scope through the MediatR pipeline. This would be an ideal spot to place your validation logic, right?

Similarly, once the application sends the response back to the client, the response has to cross the MediatR Middleware. Ainā€™t this the ideal spot to place a code that can cache the response? As in, once the response is cached at the Pipeline, any similar request coming into the application will check If the required response is available in the cache-store and try to fetch it. Only if the cache data is not present, the request makes it to your MediatR handlers and then the database. Get it? Oh, the response will have cache-keys attached to it, so that they are unique to requests. For example, cache-keys can be like below -

  • customer-1
  • customer-99
  • customerList

The concept that we saw in the above stanzas could -

  • drastically reduce your application response time.
  • improve user experience
  • less load on the API Server / Database Server
  • save you $$$$

What we will build?

To demonstrate Response Caching with MediatR in ASP.NET Core, we will build a .NET 6 WebAPI following a clean architecture. The use case of this application - a simple API with a customers endpoint that can return a list of customers and a single customer detail as well. To keep the implementation shorter, we will not be connecting this implementation to a DataSource, since our main focus is on the caching behavior of MediatR. Instead we will have a static list of customers and try to mimic the delay that can occur with the database response.

Letā€™s get started.

Getting started with Caching with MediatR in ASP.NET Core

As mentioned earlier, we will first set up our required projects and library following a clean separation of concerns. I am using Visual Studio 2019 Community for this implementation. You can find the complete source code here.

Setting up the Project

So, we create a new solution with 3 projects in it.

  • API - contains the controllers and service registrations
  • Core - contains the entities (customer) , features(mediatr handlers) , abstractions(customer service interface) and actual MediatR Response Caching Behavior Implementation.
  • Infrastructure - In this case, Infrastructure consists of the implementation of ICustomerService and provides dummy customer data to the application.

caching-with-mediatr-in-aspnet-core

Want to learn step-by-step how to create ASP.NET Core applications with clean and scalable architecture? Check out this guide - Onion Architecture In ASP.NET Core With CQRS ā€“ Detailed

Installing the Required Packages

Install the following nuget packages to the Core Project

Install-Package MediatR
Install-Package MediatR.Extensions.Microsoft.DependencyInjection
Install-Package Microsoft.Extensions.Caching.Abstractions
Install-Package Microsoft.Extensions.Configuration.Abstractions
Install-Package Microsoft.Extensions.Logging.Abstractions
Install-Package Microsoft.Extensions.Options
Install-Package Newtonsoft.Json

Or you can install the required package whenever the code complains about a missing reference.

Adding the Customer Entity

In the core project, create a new folder entities. Here, add a new class and name it Customer.cs

public class Customer
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Contact { get; set; }
public string Email { get; set; }
}

Adding the ICacheableMediatrQuery interface

Now, in the Core project create a new folder named Abstractions. Here is where all the interfaces of the application will live. Here, create a new interface and name it ICacheableMediatrQuery.cs

public interface ICacheableMediatrQuery
{
bool BypassCache { get; }
string CacheKey { get; }
TimeSpan? SlidingExpiration { get; }
}

This interface includes certain properties that come handy while writing caching logics. Remember that this interface will be inherited by all our MediatR Handlers.

  • BypassCache - determines if you want to skip caching and go directly to the database/datastore.
  • CacheKey - specifies a unique cache key for each similar request
  • SlidingExpiration - time in hours till which the cache should be held in the memory.

Cache Setting - appSettings

To add some extra configurability, letā€™s add a settings class for caching that we will, later on, consume via the IOptions pattern in ASP.NET Core. This allows us to configure the SlidingExpiration via appSettings.json

Create a new class in the Core project, under the Settings folder and name it, CacheSettings.cs

public class CacheSettings
{
public int SlidingExpiration { get; set; }
}

Now, navigate to the API project and open up the appsettings.json. Add in the following.

"CacheSettings": {
"SlidingExpiration": 2
}

Remember that we have not yet registered the CacheSetting class in the ASP.NET Container. We will be doing this later in the article.

Implementing the Caching Behavior - Explained

This piece of code is going to the star of the entire implementation.

public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : ICacheableMediatrQuery
{
private readonly IDistributedCache _cache;
private readonly ILogger _logger;
private readonly CacheSettings _settings;
public CachingBehavior(IDistributedCache cache, ILogger<TResponse> logger, IOptions<CacheSettings> settings)
{
_cache = cache;
_logger = logger;
_settings = settings.Value;
}
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
{
TResponse response;
if (request.BypassCache) return await next();
async Task<TResponse> GetResponseAndAddToCache()
{
response = await next();
var slidingExpiration = request.SlidingExpiration == null ? TimeSpan.FromHours(_settings.SlidingExpiration) : request.SlidingExpiration;
var options = new DistributedCacheEntryOptions { SlidingExpiration = slidingExpiration };
var serializedData = Encoding.Default.GetBytes(JsonConvert.SerializeObject(response));
await _cache.SetAsync((string)request.CacheKey, serializedData, options, cancellationToken);
return response;
}
var cachedResponse = await _cache.GetAsync((string)request.CacheKey, cancellationToken);
if (cachedResponse != null)
{
response = JsonConvert.DeserializeObject<TResponse>(Encoding.Default.GetString(cachedResponse));
_logger.LogInformation($"Fetched from Cache -> '{request.CacheKey}'.");
}
else
{
response = await GetResponseAndAddToCache();
_logger.LogInformation($"Added to Cache -> '{request.CacheKey}'.");
}
return response;
}
}
  • Line 1 - CacheBehavior class implements IPipelineBehavior<TRequest, TResponse\> where the request is of type ICacheableMediatrQuery. This means that caching will be applicable only to the MediatR handlers that are of type ICacheableMediatrQuery. Pretty handy.

  • Line 3 to 11 - Constructor injection of Distributed caching, Logger, and Cache Settings.

  • Line 15 - If the BypassCache property is set to true, the behavior goes to the next request/response and avoids caching.

  • Line 25 - Fetches from IDistributedCache instance and checks if any data with the passed cache key exists.

  • Line 26 to 30 - if the cache exists, then returns from the cache-store and logs ā€˜Fetched from cacheā€™

  • Line 31 to 35 0 if there is no cache found, the pipeline waits for the response from the server, sets up the cache setting from appsetting.json, and finally adds the response to the cache-store.

  • Line 18 - waits for the response from the server/database.

  • Line 19 - creates an expiration timespan in hours, either from the appsettings or the value passed from the mediatR handler. Now, this is the best part. Each mediatR handler can decide how long a cache has to be available in the cache store.

  • Line 21 - serializes the response to a byte array.

  • Line 22 - adds the serialized data against the cache key in the cache-store along with the cache options that we built.

Interesting, yeah?

Customer Service

Now, letā€™s move to the data part of the application. In the Core project, under the abstractions folder, add a new interface and name it ICustomerService.cs

public interface ICustomerService
{
IEnumerable<Customer> GetCustomerList();
Customer GetCustomer(int id);
}

As mentioned earlier, we will be just having two methods that can return a list of customers and specific customer respectively.

Next, go to the Infrastructure Project (which is quite empty till now), and add in a new class, CustomerService.cs. Make sure you implement the ICustomerService interface in this class.

public class CustomerService : ICustomerService
{
public static IEnumerable<Customer> Customers => new List<Customer>
{
new Customer{ Id = 1, Contact = "123456789", Email="[email protected]", FirstName="John", LastName = "Doe"},
new Customer{ Id = 2, Contact = "564514501", Email="[email protected]", FirstName="Ray", LastName = "Doe"},
new Customer{ Id = 3, Contact = "141510217", Email="[email protected]", FirstName="Smith", LastName = "Doe"},
new Customer{ Id = 4, Contact = "254112152", Email="[email protected]", FirstName="Mukesh", LastName = "Murugan"},
new Customer{ Id = 5, Contact = "125452338", Email="[email protected]", FirstName="Helen", LastName = "Doe"},
new Customer{ Id = 6, Contact = "985171215", Email="[email protected]", FirstName="Jack", LastName = "Doe"},
new Customer{ Id = 7, Contact = "653107410", Email="[email protected]", FirstName="Marc", LastName = "Doe"},
new Customer{ Id = 8, Contact = "165357410", Email="[email protected]", FirstName="Tim", LastName = "Doe"},
new Customer{ Id = 9, Contact = "012543413", Email="[email protected]", FirstName="Jimmy", LastName = "Doe"},
new Customer{ Id = 10, Contact = "124633892", Email="[email protected]", FirstName="Dany", LastName = "Doe"},
};
public Customer GetCustomer(int id)
{
//Assume Database Response takes 1000 ms
Thread.Sleep(1000);
return Customers.Where(c => c.Id == id).FirstOrDefault();
}
public IEnumerable<Customer> GetCustomerList()
{
//Assume Database Response takes 3000 ms
Thread.Sleep(3000);
return Customers;
}
}
  • Line 3 to 15 - A List of Sample Customers
  • Line 16 to 21 - Service method to return a specific customer by Id.
  • Line 19 - Since we are not connecting to an actual database, itā€™s important to add a delay so as to mimic the response time of a Realtime database server that may contains tens of thousands of records. In this method, the control waits for 1 second. This helps us to demonstrate caching.
  • Line 20 - returns the requested customer detail.
  • Line 22 to 27 - Similarly, this method waits for 3 seconds given that it fetches a huge amount of data.

Building the CQRS Handlers

Now that we have everything in place, letā€™s start adding our MediatR handlers. You will need to add them in the Core Project under the following path - Features/Customer/Queries/{name of the handler}

We will be having two handlers in consideration here.

1. GetCustomerQuery

This Query/Handler will be responsible for returning a particular customer detail for the requested customer id via the service layers. Add a new class, Features/Customer/Queries/GetCustomerQuery.cs

public class GetCustomerQuery : IRequest<Customer>, ICacheableMediatrQuery
{
public int Id { get; set; }
public bool BypassCache { get; set; }
public string CacheKey => $"Customer-{Id}";
public TimeSpan? SlidingExpiration { get; set; }
}
internal class GetCustomerQueryHandler : IRequestHandler<GetCustomerQuery, Customer>
{
private readonly ICustomerService customerService;
public GetCustomerQueryHandler(ICustomerService customerService)
{
this.customerService = customerService;
}
public async Task<Customer> Handle(GetCustomerQuery request, CancellationToken cancellationToken)
{
var customer = customerService.GetCustomer(request.Id);
return customer;
}
}
  • Line 1 - Note that we have used the ICacheableMediatrQuery interface.
  • Line 5 - Sets the CacheKeys that is logically always unique for different customer id request.
  • The remainder of the code is very straightforward, getting the data from the service implementation.

2. GetCustomerListQuery

This Query/Handler will be responsible for returning all customers via the service layers. Add a new class, Features/Customer/Queries/GetCustomerListQuery.cs

public class GetCustomerListQuery : IRequest<List<Customer>>, ICacheableMediatrQuery
{
public int Id { get; set; }
public bool BypassCache { get; set; }
public string CacheKey => $"CustomerList";
public TimeSpan? SlidingExpiration { get; set; }
}
internal class GetCustomerListQueryHandler : IRequestHandler<GetCustomerListQuery, List<Customer>>
{
private readonly ICustomerService customerService;
public GetCustomerListQueryHandler(ICustomerService customerService)
{
this.customerService = customerService;
}
public async Task<List<Customer>> Handle(GetCustomerListQuery request, CancellationToken cancellationToken)
{
var cutomers = customerService.GetCustomerList();
return cutomers.ToList();
}
}

Exactly similar to the previous handler, but with a different cacheKey, obviously.

Registering the Required Services

With everything done, some final touches are required to register all the services into the service container of the ASP.NET Core application. We will create a extensions class in the Core project.

Create a new folder, Extensions in the Core Project and add a new static class, ServiceCollectionExtensions.cs

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreLayer(this IServiceCollection services, IConfiguration config)
{
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
return services;
}
}

Here, we register MediatR and the CachingBehavior. Note that it is important to register MediatR in every projects that contain the Handler implementations.

Finally, navigate to the API projectā€™s Startup class/ ConfigureServices and make changes as below.

public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedMemoryCache();
services.AddCoreLayer(config);
services.AddTransient<ICustomerService, CustomerService>();
services.Configure<CacheSettings>(config.GetSection("CacheSettings"));
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "MediatRReponseCaching", Version = "v1" });
});
}
  • Line 3 - Very important! You can either use Distributed Memory or In-Memory Caches. I prefer Distributed caches as they can be extended to use external infrastructure like Redis Caching down the lifetime of projects.
  • Line 4 - Adds the Core Layer registration. You may need to add a reference to the Core project from the API project to resolve this.
  • Line 5 - Registers Customer Service.
  • Line 6 - Makes CacheSettings available in IOptions from appsetting.json

One improvement you can make is to move line 5 and 6 to the Core Layerā€™s Service Registration Extension. Make more sense to be there. But however, letā€™s finish up the controller.

Wiring up the Controllers

Finally, letā€™s add a new empty API Controller in the API project under the Controllers folder and name it CustomersController.

[Route("api/[controller]")]
[ApiController]
public class CustomersController : ControllerBase
{
private readonly IMediator _mediator;
public CustomersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetCustomer(int id)
{
var customer = await _mediator.Send(new GetCustomerQuery { Id = id, BypassCache = false });
return Ok(customer);
}
[HttpGet]
public async Task<IActionResult> GetCustomerList()
{
var customers = await _mediator.Send(new GetCustomerListQuery { BypassCache = false });
return Ok(customers);
}
}
  • Line 5 to 9 - Constructor Injection for IMediator instance.
  • Line 13 - sends the requested customer id and BypassCache set to false to the GetCustomerQuery / Handler.
  • Line 19 - similarly, sends a request to the mediatR pipeline.

Testing with Swagger

That should be everything needed for our implementation. Letā€™s do some quick tests with Swagger to validate our implementation.

Quick Hack: As response time is an important parameter to our test, you can easily enable the Response Duration in Swagger UI by the following code change in the API/Startup/Configure method.

app.UseSwaggerUI(c => { c.DisplayRequestDuration(); c.SwaggerEndpoint("/swagger/v1/swagger.json", "MediatRReponseCaching v1"); });

Build and run the application.

caching-with-mediatr-in-aspnet-core

Letā€™s test the GetById endpoint.

caching-with-mediatr-in-aspnet-core

You can note the first request took about 1.2 seconds. Theoretically, as soon as this response comes back to Swagger UI, in the background, our ASP.NET Core applicationsā€™s MediatR pipeline would have cached this request. Letā€™s request for the same customer id.

caching-with-mediatr-in-aspnet-core

There you go, just 55 milli-seconds. Thatā€™s quite a noticeable speed improvement, yeah? You would be getting similar results while connecting to a database with loads of data as well.

Other Improvements include:

  • You can allow the client/API consumer to decide whether they need cached data or not, by exposing the BypassCache parameter to the API endpoint. This would be more practical.
  • Cache invalidation should happen whenever there is Write operation to the associated data store. This avoids the data inconsistency issue with caching.

Thatā€™s all for now. Letā€™s wrap up the article ;)

Summary

In this article, we have learned an awesome way to implement Caching with MediatR in ASP.NET Core application in a powerful yet simple way. We took advantage of MediatRā€™s Pipeline behavior to implement this. There are many more use-cases for the MediatR pipeline. Whatā€™s your favorite? You can also find theĀ complete source code on my Github here. Have any suggestions or questions? Feel free to leave them in the comments section below. Thanks and Happy Coding! šŸ˜€

Source Code āœŒļø
Grab the source code of the entire implementation by clicking here. Do Follow me on GitHub .
Support ā¤ļø
If you have enjoyed my content and code, do support me by buying a couple of coffees. This will enable me to dedicate more time to research and create new content. Cheers!
Share this Article
Share this article with your network to help others!
What's your Feedback?
Do let me know your thoughts around this article.

Boost your .NET Skills

I am starting a .NET 8 Zero to Hero Series soon! Join the waitlist.

Join Now

No spam ever, we are care about the protection of your data. Read our Privacy Policy