Cache Invalidation
ICacheService layered cache, IInvalidatesCache pattern, cache key conventions, Redis-with-in-memory-fallback architecture, and ETag-based HTTP cache.
SCRIPE's caching system is a two-layer architecture: Redis as the distributed cache for multi-instance deployments, with an in-memory fallback for development and offline scenarios. All caching is coordinated through ICacheService — handlers and services never interact with Redis directly. Cache invalidation is declarative: commands implement IInvalidatesCache and list the cache key prefixes they invalidate, with zero code in the CachingBehavior.
Two-Layer Cache Architecture
ICacheService is the single entry point for all caching. In production, it delegates to RedisCache (ConnectionMultiplexer) with IMemoryCache as a fallback if Redis is unavailable. In development, the InMemoryOnlyCache implementation is used, with no Redis dependency. The CachingBehavior pipeline behavior coordinates cache reads (position 5 in the pipeline — before the handler for queries) and cache invalidation (triggered by IInvalidatesCache on commands).
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default); // O(N) SCAN+DEL
Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiry = null, CancellationToken ct = default);
}Cache Key Conventions
Cache keys follow a strict hierarchical format: {EntityType}:{TenantId}:{Qualifier}. The TenantId isolation is critical — it prevents cache bleed between tenants in a multi-tenant setup. The {Qualifier} can be a specific entity ID, a page+filter hash, or a logical group name.
| Pattern | Example Key | Use Case |
|---|---|---|
| {EntityType}:{TenantId}:{Id} | Admin:ten_abc123:adm_xyz789 | Single entity GET by ID |
| {EntityType}:{TenantId}:list:{PageHash} | Admin:ten_abc123:list:p1s20 | Paginated list query |
| {EntityType}: | Admin: | Broad invalidation prefix — all Admin cache for all tenants |
| {EntityType}:{TenantId}: | Admin:ten_abc123: | Tenant-scoped invalidation — one tenant's Admin cache |
IInvalidatesCache — Declarative Invalidation
Commands that modify data declare their cache invalidation side effects by implementing IInvalidatesCache. The CachingBehavior reads the CachePrefixesToInvalidate array after the handler succeeds and calls ICacheService.RemoveByPrefixAsync for each prefix. This means cache invalidation logic lives on the command — not scattered across handlers.
public record UpdateAdminCommand(
Guid Id,
string Name,
byte[] RowVersion
) : ICommand, IInvalidatesCache
{
// The CachingBehavior reads this after the handler succeeds.
// It calls ICacheService.RemoveByPrefixAsync("Admin:") to flush all Admin cache.
// Pattern: use the entity type prefix to flush all related cache entries atomically.
public string[] CachePrefixesToInvalidate => ["Admin:"];
}ETag-Based HTTP Cache
GET API responses include an ETag header (a hash of the response body). Clients can send If-None-Match: {etag} on subsequent requests; if the response body hasn't changed, the server returns 304 Not Modified with no body. This dramatically reduces bandwidth for frequently polled data. ETag generation is handled by the ETagFilter action filter registered globally.