.NET & C# Development · Lesson 193 of 229
OrderFlow Part 5: Redis Caching — Cache-Aside with Automatic Invalidation
OrderFlow: Redis Caching — Cache-Aside with Automatic Invalidation
This is part 5 of the OrderFlow series. Domain events are dispatched. Now we add Redis caching to cut database load on hot read paths — and use domain events to invalidate cache entries automatically when data changes.
Starting point: OrderFlow Domain Events complete.
What We're Caching
Hot read paths in OrderFlow:
GET /api/products → product catalogue (changes rarely)
GET /api/products/{id} → single product
GET /api/orders/{id} → order detail (cached per user)
GET /api/orders/summary → customer order summary
Cache invalidation triggers:
ProductUpdatedEvent → invalidate product cache
OrderPlacedEvent → invalidate customer order summary
OrderCancelledEvent → invalidate order detail + summaryStep 1: Add Redis
XML
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.*" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.*" />C#
// Program.cs
builder.Services.AddStackExchangeRedisCache(opts =>
opts.Configuration = builder.Configuration.GetConnectionString("Redis"));
// HybridCache — L1 (in-memory) + L2 (Redis) with stampede protection
builder.Services.AddHybridCache(opts =>
{
opts.MaximumPayloadBytes = 1024 * 1024; // 1 MB max per entry
opts.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(15),
LocalCacheExpiration = TimeSpan.FromMinutes(1), // L1 TTL
};
});Step 2: Cache the Product Catalogue
C#
// src/OrderFlow.Application/Products/Queries/GetProductsQueryHandler.cs
public class GetProductsQueryHandler(
OrderFlowDbContext db,
HybridCache cache)
: IRequestHandler<GetProductsQuery, List<ProductDto>>
{
public async Task<List<ProductDto>> Handle(
GetProductsQuery query,
CancellationToken ct)
{
// Cache key includes the category filter for per-variant caching
var cacheKey = $"products:catalogue:{query.Category ?? "all"}";
return await cache.GetOrCreateAsync(
cacheKey,
async innerCt =>
{
var q = db.Products.AsNoTracking().Where(p => p.IsActive);
if (!string.IsNullOrWhiteSpace(query.Category))
q = q.Where(p => p.Category == query.Category);
return await q
.OrderBy(p => p.Name)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category, p.StockLevel))
.ToListAsync(innerCt);
},
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30), // products change rarely
LocalCacheExpiration = TimeSpan.FromMinutes(5),
},
cancellationToken: ct);
}
}C#
// Single product with longer TTL
public class GetProductByIdQueryHandler(
OrderFlowDbContext db,
HybridCache cache)
: IRequestHandler<GetProductByIdQuery, ProductDto?>
{
public async Task<ProductDto?> Handle(GetProductByIdQuery query, CancellationToken ct)
=> await cache.GetOrCreateAsync(
$"products:{query.ProductId}",
async innerCt =>
{
var p = await db.Products.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == query.ProductId, innerCt);
return p is null ? null
: new ProductDto(p.Id, p.Name, p.Price, p.Category, p.StockLevel);
},
cancellationToken: ct);
}Step 3: Cache Order Summaries
C#
public class GetCustomerOrderSummaryQueryHandler(
OrderFlowDbContext db,
HybridCache cache)
: IRequestHandler<GetCustomerOrderSummaryQuery, CustomerOrderSummaryDto>
{
public async Task<CustomerOrderSummaryDto> Handle(
GetCustomerOrderSummaryQuery query,
CancellationToken ct)
=> await cache.GetOrCreateAsync(
$"orders:summary:customer:{query.CustomerId}",
async innerCt =>
{
var orders = await db.Orders.AsNoTracking()
.Where(o => o.CustomerId == query.CustomerId)
.GroupBy(_ => 1)
.Select(g => new CustomerOrderSummaryDto(
TotalOrders: g.Count(),
TotalSpend: g.Sum(o => o.Total),
ActiveOrders: g.Count(o => o.Status == "Pending" || o.Status == "Paid"),
LastOrderDate: g.Max(o => (DateTime?)o.CreatedAt)))
.FirstOrDefaultAsync(innerCt);
return orders ?? new CustomerOrderSummaryDto(0, 0, 0, null);
},
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5) },
cancellationToken: ct);
}
public record CustomerOrderSummaryDto(
int TotalOrders,
decimal TotalSpend,
int ActiveOrders,
DateTime? LastOrderDate);Step 4: Automatic Invalidation via Domain Events
C#
// When an order is placed, the customer summary is stale — invalidate it
public class InvalidateOrderSummaryOnOrderPlaced(HybridCache cache)
: INotificationHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent @event, CancellationToken ct)
=> await cache.RemoveAsync(
$"orders:summary:customer:{@event.CustomerId}", ct);
}
// When order is cancelled, invalidate detail + summary
public class InvalidateCacheOnOrderCancelled(HybridCache cache)
: INotificationHandler<OrderCancelledEvent>
{
public async Task Handle(OrderCancelledEvent @event, CancellationToken ct)
{
await cache.RemoveAsync($"orders:{@event.OrderId}", ct);
await cache.RemoveAsync($"orders:summary:customer:{@event.CustomerId}", ct);
}
}
// When a product is updated, invalidate all product catalogue variants
public class InvalidateProductCacheOnUpdate(IDistributedCache redis)
: INotificationHandler<ProductUpdatedEvent>
{
public async Task Handle(ProductUpdatedEvent @event, CancellationToken ct)
{
// Remove the specific product entry
await redis.RemoveAsync($"products:{@event.ProductId}", ct);
// Remove catalogue entries — use a tag-based approach
// (HybridCache v2 supports tags natively; for v1 we track keys manually)
var catalogueTags = new[] { "all", @event.Category };
foreach (var tag in catalogueTags)
await redis.RemoveAsync($"products:catalogue:{tag}", ct);
}
}Step 5: Order Detail with Short TTL
C#
// Order detail — shorter TTL because status changes
public class GetOrderByIdQueryHandler(
OrderFlowDbContext db,
HybridCache cache)
: IRequestHandler<GetOrderByIdQuery, OrderDetailDto?>
{
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery query, CancellationToken ct)
{
// Include the user role in the cache key — admin sees all orders
var cacheKey = query.RequestingRole == "Admin"
? $"orders:{query.OrderId}:admin"
: $"orders:{query.OrderId}:customer:{query.RequestingUserId}";
return await cache.GetOrCreateAsync(
cacheKey,
async innerCt =>
{
var order = await db.Orders.AsNoTracking()
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == query.OrderId, innerCt);
if (order is null) return null;
if (query.RequestingRole != "Admin" && order.CustomerId != query.RequestingUserId)
throw new ForbiddenException("You do not have access to this order.");
return MapToDto(order);
},
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(2), // order status changes
LocalCacheExpiration = TimeSpan.FromSeconds(30),
},
cancellationToken: ct);
}
private static OrderDetailDto MapToDto(Order o) => new(
o.Id, o.Status, o.Total, o.CreatedAt,
o.Lines.Select(l => new OrderLineDto(l.ProductName, l.Quantity, l.UnitPrice)).ToList());
}Step 6: Cache Warming on Startup
C#
// Pre-populate the product catalogue cache on startup
// so the first user doesn't hit a cold cache
public class CacheWarmingService(
IServiceScopeFactory scopeFactory,
ILogger<CacheWarmingService> logger)
: IHostedService
{
public async Task StartAsync(CancellationToken ct)
{
logger.LogInformation("Warming product catalogue cache...");
await using var scope = scopeFactory.CreateAsyncScope();
var mediator = scope.ServiceProvider.GetRequiredService<ISender>();
// Trigger the query — HybridCache populates automatically
await mediator.Send(new GetProductsQuery(Category: null), ct);
logger.LogInformation("Cache warmed.");
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
// Register
builder.Services.AddHostedService<CacheWarmingService>();Cache Key Design Reference
products:{id} → single product (30 min)
products:catalogue:{category|"all"} → product list by category (30 min)
orders:{id}:admin → order detail for admin (2 min)
orders:{id}:customer:{customerId} → order detail for customer (2 min)
orders:summary:customer:{customerId} → customer's aggregate summary (5 min)
Invalidation events:
ProductUpdatedEvent → remove products:{id}, products:catalogue:*
OrderPlacedEvent → remove orders:summary:customer:{customerId}
OrderCancelledEvent → remove orders:{id}:*, orders:summary:customer:{customerId}
OrderShippedEvent → remove orders:{id}:*What's Next
Next: OrderFlow Testing — write a complete test suite for OrderFlow: unit tests for command handlers, integration tests with Testcontainers (real PostgreSQL + Redis), and test data builders to keep tests readable.