Learnixo

.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 + summary

Step 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.