| name | dotnet-hybrid-cache |
| description | Implement HybridCache with L1 Memory and L2 Redis for distributed caching with pub/sub invalidation. Use when user says "add caching", "cache this", "HybridCache", "Redis cache", "cache invalidation", "improve performance with cache", or asks about caching strategies. Do NOT use for database or messaging concerns. |
| metadata | {"author":"Claude-DotNet-Ultimate","version":"1.0.0","category":"caching"} |
HybridCache Implementation
Architecture
Request → L1 (Memory) → HIT? Return
↓ MISS
L2 (Redis) → HIT? Store in L1, Return
↓ MISS
Factory (DB/API) → Store in L1 + L2, Return
Already Configured
The project has HybridCacheService in src/Infrastructure/Caching/ implementing ICacheService.
ICacheService Interface
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
Task RemoveByTagAsync(string tag, CancellationToken ct = default);
Task<T> GetOrSetAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options = null, IReadOnlyCollection<string>? tags = null, CancellationToken ct = default);
}
Using Cache in Query Handlers
public sealed class GetProductQueryHandler(
IUnitOfWork unitOfWork,
ICacheService cache,
ILogger<GetProductQueryHandler> logger) : IRequestHandler<GetProductQuery, Result<ProductDto>>
{
public async Task<Result<ProductDto>> Handle(GetProductQuery request, CancellationToken ct)
{
var cacheKey = $"product-{request.ProductId}";
var dto = await cache.GetOrCreateAsync(
cacheKey,
async token =>
{
var product = await unitOfWork.Products.GetByIdAsync(
ProductId.From(request.ProductId), token);
if (product is null) return default;
return new ProductDto(product.Id.Value, product.Name,
product.Price.Amount, product.Price.Currency, product.CreatedAt);
},
ct);
return dto is null
? Result<ProductDto>.Failure(Error.NotFound("Product.NotFound", "Not found"))
: dto;
}
}
Cache Invalidation in Command Handlers
public async Task<Result<ProductDto>> Handle(UpdateProductCommand request, CancellationToken ct)
{
await cache.RemoveAsync($"product-{request.ProductId}", ct);
await cache.RemoveByTagAsync($"products-list", ct);
return ;
}
Cache Key Patterns
| Pattern | Example | Use |
|---|
| Entity by ID | product-{id} | Single entity lookups |
| List | products-list-page-{page} | Paginated lists |
| By parent | customer-{id}-orders | Related entities |
| Tag | products-list | Bulk invalidation |
Expiration Strategy
| Data Type | L1 (Memory) | L2 (Redis) | Reason |
|---|
| Entity by ID | 1 min | 5 min | Moderate change frequency |
| Lists | 30 sec | 2 min | Frequent changes |
| Static config | 10 min | 30 min | Rarely changes |
| User sessions | 5 min | 30 min | Security balance |
Pub/Sub Invalidation
When one instance invalidates a cache key, Redis pub/sub notifies all instances to clear their L1 cache:
await cache.RemoveAsync("product-123");
Rules
- Always check cache in queries before hitting database
- Always invalidate cache in commands after successful mutation
- Use tags for bulk invalidation (e.g., invalidate all product lists)
- Cache DTOs, not domain entities (serialization issues with private setters)
- Use consistent key naming conventions
- Set appropriate TTL — shorter for frequently changing data
- Never cache sensitive data without encryption