This guide explains how to configure and use caching in the BookStore API, specifically focusing on the hybrid caching strategy integrated with Aspire and localization.
The BookStore API uses Hybrid Caching (HybridCache), enriched by Aspire for seamless distributed cache orchestration.
Components:
- L1 Cache (In-Memory): Local, fast access per instance
- L2 Cache (Distributed): Redis, orchestrated by Aspire, shared across instances
- Stampede Protection: Built-in request coalescing prevents cache stampedes
- Localization Awareness: Automatically scopes cache keys to the user's UI culture
The caching infrastructure is automatically wired up by Aspire.
-
AppHost: Declares the Redis resource using the
ResourceNames.Cacheconstant.var cache = builder.AddRedis(ResourceNames.Cache); builder.AddProject<Projects.BookStore_ApiService>("apiservice") .WithReference(cache) .WaitFor(cache);
-
API Service: Redis is registered as the L2 distributed cache via Aspire service discovery.
AddHybridCachelayers L1 on top.// In Program.cs — L2 distributed cache backed by Redis builder.AddRedisDistributedCache(ResourceNames.Cache); // In ApplicationServicesExtensions.cs — registers HybridCache (L1 + L2) services.AddHybridCache();
All cache tags and their item-tag helpers live in Infrastructure/CacheTags.cs. Always use these constants — never inline raw tag strings — so invalidation sites and read sites stay in sync.
public static class CacheTags
{
// Category tags
public const string CategoryItemPrefix = "category";
public const string CategoryList = "categories";
// Book tags
public const string BookItemPrefix = "book";
public const string BookList = "books";
// Author tags
public const string AuthorItemPrefix = "author";
public const string AuthorList = "authors";
// Publisher tags
public const string PublisherItemPrefix = "publisher";
public const string PublisherList = "publishers";
// Security stamp tags
public const string SecurityStampPrefix = "security-stamp";
/// <summary>Creates a cache tag for a specific item by ID.</summary>
public static string ForItem(string prefix, Guid id) => $"{prefix}:{id}";
/// <summary>Creates a cache tag for a user's security stamp in a tenant.</summary>
public static string ForSecurityStamp(string tenantId, Guid userId)
=> $"{SecurityStampPrefix}:{tenantId}:{userId:D}";
}Two tags per entity are the standard pattern:
- Item tag (
CacheTags.ForItem(prefix, id)→"book:123"): clears every language/tenant variant of a single entity. - List tag (
CacheTags.BookList→"books"): clears all paginated, filtered, and sorted list variants.
For content that varies by language, use GetOrCreateLocalizedAsync. This extension method (in Infrastructure/Extensions/HybridCacheExtensions.cs) automatically appends CultureInfo.CurrentUICulture to the cache key using the format {key}|{culture}.
Example from CategoryEndpoints:
var culture = CultureInfo.CurrentUICulture.Name; // ← CurrentUICulture, not CurrentCulture
var defaultCulture = localizationOptions.Value.DefaultCulture;
var cacheKey = $"categories:tenant={tenantContext.TenantId}:culture={culture}:search={request.Search}:page={paging.Page}:size={paging.PageSize}:sort={normalizedSortBy}:{normalizedSortOrder}";
var response = await cache.GetOrCreateLocalizedAsync(
cacheKey,
async cancel =>
{
await using var session = store.QuerySession(tenantContext.TenantId);
// ... query logic
return new PagedListDto<CategoryDto>(...);
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5), // L2 (Redis) expiration
LocalCacheExpiration = TimeSpan.FromMinutes(2) // L1 (in-memory) expiration
},
tags: [CacheTags.CategoryList],
token: cancellationToken);Cache key format: The localization extension appends the culture with a pipe delimiter, so the final stored key is e.g. categories:tenant=acme:culture=en-US:search=...|en-US.
For content that doesn't vary by language, use the standard GetOrCreateAsync.
Example from PublisherEndpoints:
var response = await cache.GetOrCreateAsync(
$"publisher:{id}",
async cancel =>
{
var publisher = await session.LoadAsync<PublisherProjection>(id, cancel);
return publisher == null ? null : new PublisherDto(publisher.Id, publisher.Name);
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
},
tags: [CacheTags.ForItem(CacheTags.PublisherItemPrefix, id)],
cancellationToken: cancellationToken);For search or list endpoints, include all query parameters in the cache key — including tenant ID and admin flag — to avoid serving stale or incorrect results.
Example from BookEndpoints (book search):
var cacheKey = $"books:search={request.Search}:author={request.AuthorId}:category={request.CategoryId}:publisher={request.PublisherId}:onSale={request.OnSale}:minPrice={request.MinPrice}:maxPrice={request.MaxPrice}:currency={request.Currency}:page={paging.Page}:size={paging.PageSize}:sort={normalizedSortBy}:{normalizedSortOrder}:admin={isAdmin}:tenant={tenantContext.TenantId}";
var response = await cache.GetOrCreateLocalizedAsync(
cacheKey,
async cancel => { /* query logic */ },
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(2), // Shorter for dynamic content
LocalCacheExpiration = TimeSpan.FromMinutes(1)
},
tags: [CacheTags.BookList],
token: cancellationToken);Single book (GetBook) uses a per-item tag:
var response = await cache.GetOrCreateLocalizedAsync(
$"book:{id}:admin={isAdmin}:tenant={tenantContext.TenantId}",
async cancel => { /* load book */ },
options: new HybridCacheEntryOptions { ... },
tags: [CacheTags.ForItem(CacheTags.BookItemPrefix, id)],
token: cancellationToken);Command handlers call RemoveByTagAsync directly after appending events, before SaveChangesAsync returns. This provides immediate invalidation from the write side.
// Create — invalidate the list only (item doesn't exist yet)
await cache.RemoveByTagAsync([CacheTags.CategoryList], default);
// Update / SoftDelete — invalidate both the item and the list
await cache.RemoveByTagAsync(
[CacheTags.CategoryList, CacheTags.ForItem(CacheTags.CategoryItemPrefix, command.Id)],
default);Handlers that follow this pattern: CategoryHandlers, PublisherHandlers, UserCommandHandler.
ProjectionCommitListener (Infrastructure/MartenCommitListener.cs) implements both IDocumentSessionListener and IChangeListener, and is registered as a singleton.
It fires after the async daemon commits projections to PostgreSQL, giving a second invalidation pass that is guaranteed to run after the read model is updated.
public async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token)
{
try
{
await ProcessDocumentChangesAsync(commit.Inserted, ChangeType.Insert, eventId, token);
await ProcessDocumentChangesAsync(commit.Updated, ChangeType.Update, eventId, token);
await ProcessDocumentChangesAsync(commit.Deleted, ChangeType.Delete, eventId, token);
}
catch (Exception ex)
{
Log.Infrastructure.ErrorProcessingProjectionCommit(_logger, ex);
}
}Key characteristics:
- Tied to projection commits: Runs only after the async daemon writes updated read models to the database, so the cache is never invalidated before the new data is available.
- Error-isolated: Failures are logged but never surface to the daemon — projection writes always succeed.
- Awaited invalidation: Each
RemoveByTagAsynccall is awaited; only the outerAfterCommitAsyncis fire-and-forget relative to HTTP responses.
| Projection | Item tag prefix | List tag |
|---|---|---|
CategoryProjection |
CacheTags.CategoryItemPrefix |
CacheTags.CategoryList |
BookSearchProjection |
CacheTags.BookItemPrefix |
CacheTags.BookList |
AuthorProjection |
CacheTags.AuthorItemPrefix |
CacheTags.AuthorList |
PublisherProjection |
CacheTags.PublisherItemPrefix |
CacheTags.PublisherList |
BookStatistics |
CacheTags.BookItemPrefix |
CacheTags.BookList |
CategoryStatistics |
CacheTags.CategoryItemPrefix |
CacheTags.CategoryList |
AuthorStatistics |
CacheTags.AuthorItemPrefix |
CacheTags.AuthorList |
PublisherStatistics |
CacheTags.PublisherItemPrefix |
CacheTags.PublisherList |
UserProfile |
(SSE notification only, no cache tags) | — |
ApplicationUser |
(SSE notification only, no cache tags) | — |
Tenant |
(SSE notification only, no cache tags) | — |
Warning
If you add a new projection type that requires cache invalidation, update the ProcessDocumentChangeAsync switch in ProjectionCommitListener.cs to handle it. Unrecognised types emit a debug-level warning log.
async Task InvalidateCacheTagsAsync(Guid id, string entityPrefix, string collectionTag, CancellationToken token)
{
var itemTag = CacheTags.ForItem(entityPrefix, id); // e.g. "book:123"
await _cache.RemoveByTagAsync(itemTag, token); // all language/tenant variants of this item
await _cache.RemoveByTagAsync(collectionTag, token); // all list/search variants
Log.Infrastructure.CacheInvalidated(_logger, itemTag, collectionTag);
}sequenceDiagram
participant Command as Command Handler
participant Marten as Event Store
participant Daemon as Async Daemon
participant Listener as ProjectionCommitListener
participant Cache as HybridCache
Command->>Cache: 1. RemoveByTagAsync (explicit — handlers)
Command->>Marten: 2. Append event + SaveChangesAsync
Command-->>Command: 3. Return HTTP response
Note over Daemon: Asynchronous Processing
Daemon->>Daemon: 4. Process events
Daemon->>Daemon: 5. Update projections
Daemon->>Listener: 6. AfterCommitAsync(changeSet)
Note over Listener: After projection commit
Listener->>Cache: 7a. RemoveByTagAsync(item tag)
Listener->>Cache: 7b. RemoveByTagAsync(list tag)
Caching personalized data (e.g., "Is this book in my favourites?") naively leads to cache explosion (one entry per user × book). The overlay pattern avoids this:
- Cache the shared content:
BookDtois stored withIsFavorite = falseandUserRating = 0. - Fetch user state separately: Load the lightweight
UserProfilefrom the database. - Merge at response time:
// 1. Get cached book (generic, shared by all users)
var response = await cache.GetOrCreateLocalizedAsync(...);
// 2. Overlay user-specific fields for authenticated callers
if (context.User.Identity?.IsAuthenticated == true && response.Items.Count > 0)
{
var profile = await session.LoadAsync<UserProfile>(userId, cancellationToken);
if (profile != null)
{
var updatedItems = response.Items.Select(b =>
{
var result = b;
if (profile.FavoriteBookIds.Contains(b.Id))
result = result with { IsFavorite = true };
if (profile.BookRatings.TryGetValue(b.Id, out var rating))
result = result with { UserRating = rating };
return result;
}).ToList();
response = response with { Items = updatedItems };
}
}This keeps the cache hit-rate high (shared entries) while delivering personalised responses.
If you need to invalidate outside a handler or listener, call RemoveByTagAsync with CacheTags constants:
// Invalidate specific entity (all language/tenant variants)
await cache.RemoveByTagAsync(CacheTags.ForItem(CacheTags.BookItemPrefix, id), cancellationToken);
// Invalidate all lists for an entity type
await cache.RemoveByTagAsync(CacheTags.BookList, cancellationToken);The OnTokenValidated JWT Bearer event verifies each access token's embedded security_stamp claim against the current value in the database. To avoid a database round-trip on every authenticated API request, the stamp is cached with a deliberately short TTL via SecurityStampCache.
// Key: auth:security-stamp:{tenantId}:{userId}
// Tag: security-stamp:{tenantId}:{userId}
public static string GetCacheKey(string tenantId, Guid userId)
=> $"auth:security-stamp:{tenantId}:{userId:D}";
public static string GetCacheTag(string tenantId, Guid userId)
=> CacheTags.ForSecurityStamp(tenantId, userId);The key is scoped per-tenant to prevent cross-tenant leakage.
public static HybridCacheEntryOptions CreateEntryOptions() => new()
{
Expiration = TimeSpan.FromSeconds(30), // L2 (Redis) — shared across instances
LocalCacheExpiration = TimeSpan.FromSeconds(15) // L1 (in-memory) — per instance
};The TTL is intentionally short (30 s) to bound the window during which a revoked token might still be accepted. After a security event the cache is immediately invalidated so there is no forced wait.
If the user no longer exists in the database, the factory returns the sentinel "__missing__" instead of null. This prevents null from being cached (which could mask a real missing-user condition on the next lookup).
var user = await session.Query<ApplicationUser>()
.FirstOrDefaultAsync(u => u.Id == userGuid, ct);
return user?.SecurityStamp ?? "__missing__";
// Caller checks:
if (currentSecurityStamp == "__missing__")
context.Fail("User not found.");The security stamp cache is invalidated immediately after any event that rotates the stamp:
// Called in password change, passkey add/delete, etc.
public static ValueTask InvalidateAsync(
HybridCache cache, string tenantId, Guid userId,
CancellationToken cancellationToken = default)
=> cache.RemoveByTagAsync([GetCacheTag(tenantId, userId)], cancellationToken);Note
Unlike content cache invalidation (which runs in ProjectionCommitListener), security stamp invalidation is awaited inline in the endpoint handler — revocation is immediate and synchronous with the response.
| Content Type | L2 (Redis) | L1 (In-Memory) | Rationale |
|---|---|---|---|
| Single entities (book, author, category) | 5 min | 2 min | Relatively stable, infrequent updates |
| Search results / lists | 2 min | 1 min | Dynamic, parameter-dependent |
| Paginated lists | 5 min | 2 min | Moderate stability |
| Security stamp | 30 s | 15 s | Short to bound revocation window; invalidated immediately on security events |
- Use
CacheTagsconstants: Never inline raw tag strings. Always use the constants and helpers fromCacheTags.csso invalidation sites stay in sync with read sites. - Use
GetOrCreateLocalizedAsyncfor localized content: Prefer this extension overGetOrCreateAsyncfor any content that may vary by language — even if it isn't localised yet. - Include all discriminating parameters in the cache key: For search/filter endpoints, include tenant, admin flag, culture, pagination, sort, and all filter values.
- Tag with both item and list tags: A write operation should invalidate both
CacheTags.ForItem(prefix, id)and the relevant list tag to keep single-item and list endpoints consistent. - Set appropriate expiration: Dynamic/search content → shorter TTLs (2 min L2, 1 min L1). Stable entities → longer TTLs (5 min L2, 2 min L1).
- Aspire manages connections: Don't configure Redis connection strings manually. Aspire injects them via service discovery automatically in both dev (container) and production (managed Redis).
✅ Two-Tier Caching: L1 (fast, local) + L2 (shared, distributed)
✅ Stampede Protection: Built-in request coalescing
✅ Localization-Aware: CultureInfo.CurrentUICulture-based keys via GetOrCreateLocalizedAsync
✅ Tag-Based Invalidation: Clears all language/tenant variants at once
✅ Dual Invalidation: Immediate (handlers) + guaranteed (ProjectionCommitListener after async daemon)
✅ Aspire Integration: Automatic Redis orchestration and service discovery