Adding a distributed cache
The biggest problem with caching data in a distributed application is that using an in-memory cache is impossible since we will be running multiple versions of our application at the same time.
In fact let's see what that looks like.
Scaling the API
We can scale our API horizontally by introducing replicas. To do that we simply call the
WithReplicas
method for the API resource on the AppHost Program.cs
builder.AddProject<Projects.Dometrain_Monolith_Api>("dometrain-api")
.WithReplicas(5)
.WithReference(mainDb)
.WithReference(cartDb);
If we now run the project we should be able to see all the replicas:
If we start calling the API you will see that requests can go to any of these replicas.
To ensure that the cache can stay consistent between applications and restarts, we will need to move it to its own process. Thankfully we have the perfect solution.
Introducing Redis Cache
Redis is a source-available, in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.
Even though it can be used as many things, we will focus on the distributed cache aspect of it.
What can we use Redis for?
We can use Redis in multiple places in our application, but for the purposes of this workshop, we will limit the usage in these following scenarios:
- Cache course details
- Cache carts
Cache course details
Course details rarely change and the model is pretty thin. This makes it a perfect candidate for caching in Redis. There are multiple ways we can cache the response but for this example we will cache it on the layer between the Service and the Repository.
Cache carts
Carts get read and mutated very often. Cosmos DB will scale as demand increases, but it is not free. Cosmos DB is charging using a unit course RU. Any Cosmos DB operation costs RUs and if RUs get exhausted, Cosmos DB will throw 429 exceptions. Introducing a Redis Cache for carts will ensure that Cosmos DB is called directly fewer times, leading to a cheaper and also faster solution.
Adding Redis in .NET Aspire
We can add Redis in .NET Aspire by installing the integration in the AppHost:
Aspire.Hosting.Redis
Once that's installed we can add the resource in the Program.cs and refer to it in the API resource.
var redis = builder.AddRedis("redis");
builder.AddProject<Projects.Dometrain_Monolith_Api>("dometrain-api")
.WithReplicas(5)
.WithReference(redis)
.WithReference(mainDb)
.WithReference(basketDb);
We can now install the Aspire Redis Nuget package to the API:
Aspire.StackExchange.Redis
We can now register the IConnectionMultiplexer, which is the interface we will inject to connect to Redis,
using the builder.AddRedisClient("redis");
method in the Program.cs.
Let's implement our caching layer!
Visualizing the cache
Now that we have another integration to manage, we need to somehow visualize the data in Redis.
We can do that by using the built in integration with Redis Commander by added .WithRedisCommander();
at the end of AddRedis()
.
The caching layer
Like we've already mentioned, the caching layer will be between the service and the repository. To achieve this we will implement a decorator.
A decorator will be a cached version of our repository that uses dependency redirection.
First lets created the CachedCourseRepository:
public class CachedCourseRepository : ICourseRepository
{
public Task<Course?> CreateAsync(Course course)
{
throw new NotImplementedException();
}
public Task<Course?> GetByIdAsync(Guid id)
{
throw new NotImplementedException();
}
public Task<Course?> GetBySlugAsync(string slug)
{
throw new NotImplementedException();
}
public Task<IEnumerable<Course>> GetAllAsync(string nameFilter, int pageNumber, int pageSize)
{
throw new NotImplementedException();
}
public Task<Course?> UpdateAsync(Course course)
{
throw new NotImplementedException();
}
public Task<bool> DeleteAsync(Guid id)
{
throw new NotImplementedException();
}
}
Then simply inject an ICourseRepository
in the CachedCourseRepository
and use the appropriate
method on each implemented method.
At this stage, we are simply forwarding the calls. The tricky part is the dependency injection registration.
To redirect the registration we need to do the following:
builder.Services.AddSingleton<CourseRepository>();
builder.Services.AddSingleton<ICourseRepository>(x =>
new CachedCourseRepository(x.GetRequiredService<CourseRepository>()));
Then we can also inject the IConnectionMultiplexer
that gives us access to Redis and register it in DI.
builder.Services.AddSingleton<ICourseRepository>(x =>
new CachedCourseRepository(x.GetRequiredService<CourseRepository>(), x.GetRequiredService<IConnectionMultiplexer>()));
Adding cache for CreateAsync
var created = await _courseRepository.CreateAsync(course);
if (created is null)
{
return created;
}
var db = _connectionMultiplexer.GetDatabase();
var serializedCourse = JsonSerializer.Serialize(course);
await db.StringSetAsync($"course_id_{course.Id}", serializedCourse);
return created;
Exercise: Add cache logic for the remaining methods
We want to add Redis related logic to the rest of the endpoints. We will be ignoring GetAllAsync
as we
don't want to deal with caching pagination and search logic.
For now ignore the GetBySlugAsync
method.
GetByIdAsync
public async Task<Course?> GetByIdAsync(Guid id)
{
var db = _connectionMultiplexer.GetDatabase();
var cachedCourse = await db.StringGetAsync($"course_id_{id}");
if (!cachedCourse.IsNull)
{
return JsonSerializer.Deserialize<Course>(cachedCourse.ToString());
}
var course = await _courseRepository.GetByIdAsync(id);
if (course is null)
{
return course;
}
var serializedCourse = JsonSerializer.Serialize(course);
await db.StringSetAsync($"course_id_{course.Id}", serializedCourse);
return course;
}
UpdateAsync
public async Task<Course?> UpdateAsync(Course course)
{
var updated = await _courseRepository.UpdateAsync(course);
if (updated is null)
{
return updated;
}
var db = _connectionMultiplexer.GetDatabase();
var serializedCourse = JsonSerializer.Serialize(course);
await db.StringSetAsync($"course_id_{course.Id}", serializedCourse);
return updated;
}
DeleteAsync
public async Task<bool> DeleteAsync(Guid id)
{
var deleted = await _courseRepository.DeleteAsync(id);
if (!deleted)
{
return deleted;
}
var db = _connectionMultiplexer.GetDatabase();
var deletedCache = await db.StringGetDeleteAsync($"course_id_{id}");
return deletedCache.HasValue;
}
Dealing with multiple keys
It looks like we have a bit of a problem. Redis is a key-value storage and up until now we've been using a single key containing the course id. However, our API allows users to also use the slug of the course to retrieve its details.
But how can this work? Isn't storing the course details twice in the course a bad idea?
It is! But we have a trick up our sleeve. We can use key redirection. On top of saving the course we will also save an extra entry with the key being the course slug and the value being the key of the cache entry that contains the course!
The only tricky part is that we need these two operations to be atomic. Thankfully Redis has an MSET instruction that can do just that. Here's how our code will look like now that we will save two key entries per cache entry.
public class CachedCourseRepository : ICourseRepository
{
private readonly ICourseRepository _courseRepository;
private readonly IConnectionMultiplexer _connectionMultiplexer;
public CachedCourseRepository(ICourseRepository courseRepository, IConnectionMultiplexer connectionMultiplexer)
{
_courseRepository = courseRepository;
_connectionMultiplexer = connectionMultiplexer;
}
public async Task<Course?> CreateAsync(Course course)
{
var created = await _courseRepository.CreateAsync(course);
if (created is null)
{
return created;
}
var db = _connectionMultiplexer.GetDatabase();
var serializedCourse = JsonSerializer.Serialize(course);
var batch = new KeyValuePair<RedisKey, RedisValue>[]
{
new($"course_id_{course.Id}", serializedCourse),
new($"course_slug_{course.Slug}", course.Id.ToString())
};
await db.StringSetAsync(batch);
return created;
}
public async Task<Course?> GetByIdAsync(Guid id)
{
var db = _connectionMultiplexer.GetDatabase();
var cachedCourse = await db.StringGetAsync($"course_id_{id}");
if (!cachedCourse.IsNull)
{
return JsonSerializer.Deserialize<Course>(cachedCourse.ToString());
}
var course = await _courseRepository.GetByIdAsync(id);
if (course is null)
{
return course;
}
var serializedCourse = JsonSerializer.Serialize(course);
var batch = new KeyValuePair<RedisKey, RedisValue>[]
{
new($"course_id_{course.Id}", serializedCourse),
new($"course_slug_{course.Slug}", course.Id.ToString())
};
await db.StringSetAsync(batch);
return course;
}
public async Task<Course?> GetBySlugAsync(string slug)
{
return await _courseRepository.GetBySlugAsync(slug);
}
public async Task<IEnumerable<Course>> GetAllAsync(string nameFilter, int pageNumber, int pageSize)
{
return await _courseRepository.GetAllAsync(nameFilter, pageNumber, pageSize);
}
public async Task<Course?> UpdateAsync(Course course)
{
var updated = await _courseRepository.UpdateAsync(course);
if (updated is null)
{
return updated;
}
var db = _connectionMultiplexer.GetDatabase();
var serializedCourse = JsonSerializer.Serialize(course);
var batch = new KeyValuePair<RedisKey, RedisValue>[]
{
new($"course_id_{course.Id}", serializedCourse),
new($"course_slug_{course.Slug}", course.Id.ToString())
};
await db.StringSetAsync(batch);
return updated;
}
public async Task<bool> DeleteAsync(Guid id)
{
var deleted = await _courseRepository.DeleteAsync(id);
if (!deleted)
{
return deleted;
}
var db = _connectionMultiplexer.GetDatabase();
var cachedCourseString = await db.StringGetAsync($"course_id_{id}");
if (cachedCourseString.IsNull)
{
return deleted;
}
var course = JsonSerializer.Deserialize<Course>(cachedCourseString!)!;
var deletedCache = await db.KeyDeleteAsync([$"course_id_{id}",$"course_slug_{course.Slug}"]);
return deletedCache > 0;
}
}
And now we can simply add a slug based cache lookup for the GetBySlugAsync
method.
public async Task<Course?> GetBySlugAsync(string slug)
{
var db = _connectionMultiplexer.GetDatabase();
var cachedCourseKey = await db.StringGetAsync($"course_slug_{slug}");
if (!cachedCourseKey.IsNull)
{
return await GetByIdAsync(Guid.Parse(cachedCourseKey.ToString()));
}
var course = await _courseRepository.GetBySlugAsync(slug);
if (course is null)
{
return course;
}
var serializedCourse = JsonSerializer.Serialize(course);
var batch = new KeyValuePair<RedisKey, RedisValue>[]
{
new($"course_id_{course.Id}", serializedCourse),
new($"course_slug_{course.Slug}", course.Id.ToString())
};
await db.StringSetAsync(batch);
return course;
}
Exercise: Update the cart to use Redis
Practice what you just learned by integrating Redis for the shopping cart. Every method should use the cache in the following way:
AddCourseAsync
should clear the cache key when the course is addedGetByIdAsync
should check if a cache entry exists. If it does, it will return it. If not, it will go to Cosmos DB and retrieve it, then cache it and then return itRemoveItemAsync
should clear the cache key when the course is addedClearAsync
Solutions
public class CachedShoppingCartRepository : IShoppingCartRepository
{
private readonly IShoppingCartRepository _shoppingCartRepository;
private readonly IConnectionMultiplexer _connectionMultiplexer;
public CachedShoppingCartRepository(
IShoppingCartRepository shoppingCartRepository,
IConnectionMultiplexer connectionMultiplexer)
{
_shoppingCartRepository = shoppingCartRepository;
_connectionMultiplexer = connectionMultiplexer;
}
public async Task<bool> AddCourseAsync(Guid studentId, Guid courseId)
{
var added = await _shoppingCartRepository.AddCourseAsync(studentId, courseId);
if (!added)
{
return added;
}
var db = _connectionMultiplexer.GetDatabase();
await db.KeyDeleteAsync($"cart_id_{studentId}");
return added;
}
public async Task<ShoppingCart?> GetByIdAsync(Guid studentId)
{
var db = _connectionMultiplexer.GetDatabase();
var cachedCartString = await db.StringGetAsync($"cart_id_{studentId}");
if (!cachedCartString.IsNull)
{
return JsonSerializer.Deserialize<ShoppingCart>(cachedCartString.ToString());
}
var cart = await _shoppingCartRepository.GetByIdAsync(studentId);
await db.StringSetAsync($"cart_id_{studentId}", JsonSerializer.Serialize(cart));
return cart;
}
public async Task<bool> RemoveItemAsync(Guid studentId, Guid courseId)
{
var removed = await _shoppingCartRepository.RemoveItemAsync(studentId, courseId);
if (!removed)
{
return removed;
}
var db = _connectionMultiplexer.GetDatabase();
await db.KeyDeleteAsync($"cart_id_{studentId}");
return removed;
}
public async Task<bool> ClearAsync(Guid studentId)
{
var cleared = await _shoppingCartRepository.ClearAsync(studentId);
if (!cleared)
{
return cleared;
}
var db = _connectionMultiplexer.GetDatabase();
await db.KeyDeleteAsync($"cart_id_{studentId}");
return cleared;
}
}