|
1 | 1 | # Extension: Caching |
2 | 2 |
|
3 | 3 | ## Overview |
4 | | -Cache abstractions, key conventions, and a MediatR query caching behavior for Clean Architecture applications. Ships memory and distributed adapters, deterministic key generation, and options for stampede protection and TTL tuning without leaking infrastructure into handlers. |
| 4 | + |
| 5 | +CleanArchitecture.Extensions.Caching provides cache abstractions, deterministic key generation, and a MediatR query caching behavior. It is provider-agnostic and can target in-memory or distributed caches without leaking infrastructure concerns into handlers. |
5 | 6 |
|
6 | 7 | ## When to use |
7 | 8 |
|
8 | | -- You want query read-through caching without embedding cache calls in handlers. |
9 | | -- You need deterministic, namespace/tenant-aware cache keys and provider-agnostic entry options. |
10 | | -- You plan to start with in-memory caching for dev/test and swap to distributed stores (Redis via `IDistributedCache`) later. |
| 9 | +- You want transparent query caching without embedding cache calls in handlers. |
| 10 | +- You need deterministic, namespace-aware cache keys. |
| 11 | +- You want to start with memory caching in development and switch to distributed cache in production. |
11 | 12 |
|
12 | | -## Prereqs & Compatibility |
| 13 | +## Prereqs and compatibility |
13 | 14 |
|
14 | | -- Target frameworks: `net10.0`. |
15 | | -- Dependencies: MediatR `13.1.0`, `Microsoft.Extensions.Caching.Abstractions`, `Microsoft.Extensions.Caching.Memory` (defaults); distributed adapter uses `IDistributedCache` (MemoryDistributedCache by default). |
16 | | -- Pipeline fit: register `QueryCachingBehavior<,>` after authorization and request checks, and before performance logging to avoid skewing timing warnings. |
| 15 | +- Target framework: `net10.0`. |
| 16 | +- Dependencies: MediatR `13.1.0`, `Microsoft.Extensions.Caching.*` `10.0.0`. |
17 | 17 |
|
18 | 18 | ## Install |
19 | 19 |
|
20 | | -```bash |
21 | | -dotnet add src/YourProject/YourProject.csproj package CleanArchitecture.Extensions.Caching |
| 20 | +```powershell |
| 21 | +dotnet add src/Application/Application.csproj package CleanArchitecture.Extensions.Caching |
| 22 | +dotnet add src/Infrastructure/Infrastructure.csproj package CleanArchitecture.Extensions.Caching |
22 | 23 | ``` |
23 | 24 |
|
24 | | -## Usage |
25 | | - |
26 | | -### Register caching and pipeline behavior |
| 25 | +## Register services |
27 | 26 |
|
28 | 27 | ```csharp |
29 | 28 | using CleanArchitecture.Extensions.Caching; |
30 | 29 | using CleanArchitecture.Extensions.Caching.Options; |
31 | | -using MediatR; |
32 | 30 |
|
33 | | -services.AddCleanArchitectureCaching(options => |
| 31 | +builder.Services.AddCleanArchitectureCaching(options => |
34 | 32 | { |
35 | 33 | options.DefaultNamespace = "MyApp"; |
36 | | - options.MaxEntrySizeBytes = 256 * 1024; // optional |
37 | | -}, queryOptions => |
| 34 | + options.MaxEntrySizeBytes = 256 * 1024; |
| 35 | +}, behaviorOptions => |
38 | 36 | { |
39 | | - queryOptions.DefaultTtl = TimeSpan.FromMinutes(5); |
40 | | - // Default predicate caches types whose names end with "Query"; override to use a marker instead: |
41 | | - // queryOptions.CachePredicate = req => req is IQueryMarker; |
| 37 | + behaviorOptions.DefaultTtl = TimeSpan.FromMinutes(5); |
42 | 38 | }); |
| 39 | +``` |
43 | 40 |
|
44 | | -services.AddMediatR(cfg => |
| 41 | +## Add the MediatR behavior |
| 42 | + |
| 43 | +```csharp |
| 44 | +builder.Services.AddMediatR(cfg => |
45 | 45 | { |
46 | 46 | cfg.RegisterServicesFromAssemblyContaining<Program>(); |
47 | | - cfg.AddCleanArchitectureCachingPipeline(); // place after request checks |
| 47 | + cfg.AddCleanArchitectureCachingPipeline(); |
48 | 48 | }); |
49 | 49 | ``` |
50 | 50 |
|
51 | | -### Configure cache keys and TTLs |
| 51 | +## How query caching works |
52 | 52 |
|
53 | | -- Keys follow `{namespace}:{tenant?}:{resource}:{hash}` via `ICacheKeyFactory` and `ICacheScope`. Override `ResourceNameSelector`/`HashFactory` in `QueryCachingBehaviorOptions` for custom resource naming or hashing (e.g., when parameters should be normalized). |
54 | | -- Default TTL comes from `QueryCachingBehaviorOptions.DefaultTtl`; override per request type with `TtlByRequestType[typeof(MyQuery)] = TimeSpan.FromSeconds(30);`. |
55 | | -- `CachePredicate` controls which requests are cacheable. By default it caches request types whose names end with "Query"; override to use markers or explicit type checks. `ResponseCachePredicate` can skip caching for responses you do not want stored. |
| 53 | +`QueryCachingBehavior<TRequest, TResponse>` applies cache-aside semantics: |
56 | 54 |
|
57 | | -### Choose an adapter |
| 55 | +- The default predicate caches request types whose names end with `Query` (case-insensitive). |
| 56 | +- The cache key uses the request type name as the resource and a SHA256 hash of the request payload. |
| 57 | +- Cache hits short-circuit the handler; cache misses store the handler result. |
58 | 58 |
|
59 | | -- Memory (default): registered as `ICache` by `AddCleanArchitectureCaching`, uses `MemoryCacheAdapter` with stampede locking and jitter. |
60 | | -- Distributed: resolve `DistributedCacheAdapter` or replace `ICache` registration: |
| 59 | +Configure request selection and TTLs via `QueryCachingBehaviorOptions`: |
61 | 60 |
|
62 | 61 | ```csharp |
63 | | -services.AddCleanArchitectureCaching(); |
64 | | -services.AddStackExchangeRedisCache(opts => opts.Configuration = "..."); // or other IDistributedCache |
65 | | -services.AddSingleton<ICache, DistributedCacheAdapter>(); // override default |
| 62 | +builder.Services.AddCleanArchitectureCaching( |
| 63 | + configureQueryCaching: options => |
| 64 | + { |
| 65 | + options.CachePredicate = request => request is ICacheableQuery; // your own marker interface |
| 66 | + options.DefaultTtl = TimeSpan.FromMinutes(2); |
| 67 | + options.TtlByRequestType[typeof(GetUserQuery)] = TimeSpan.FromSeconds(30); |
| 68 | + options.CacheNullValues = false; |
| 69 | + }); |
66 | 70 | ``` |
67 | 71 |
|
68 | | -### Entry options and stampede settings |
| 72 | +## Cache keys and scopes |
69 | 73 |
|
70 | | -- `CachingOptions.DefaultEntryOptions` sets absolute/sliding expiration, priority, and size hints. |
71 | | -- `CachingOptions.StampedePolicy` controls locking timeout and jitter for both adapters. |
72 | | -- `CacheEntryOptions` can be passed per call or mapped by request type inside the behavior. |
| 74 | +- Key format: `{namespace}:{tenant?}:{resource}:{hash}`. |
| 75 | +- `DefaultCacheKeyFactory` hashes the request payload as JSON (deterministic SHA256). |
| 76 | +- `ICacheScope` supplies the namespace and optional tenant segment. |
73 | 77 |
|
74 | | -### Response-aware caching |
| 78 | +If you customize keys, keep them deterministic and stable across versions. |
75 | 79 |
|
76 | | -Use `QueryCachingBehaviorOptions.ResponseCachePredicate` to skip caching responses you want to exclude (for example, error payloads or partial results). |
| 80 | +## Choose a cache adapter |
77 | 81 |
|
78 | | -## Key components |
| 82 | +The default `ICache` implementation is `MemoryCacheAdapter`. |
79 | 83 |
|
80 | | -- `ICache`, `CacheEntryOptions`, `CacheStampedePolicy`, `CacheKey`, `ICacheKeyFactory`, `ICacheScope`, `ICacheSerializer`. |
81 | | -- `MemoryCacheAdapter`, `DistributedCacheAdapter` (for `IDistributedCache`). |
82 | | -- `QueryCachingBehavior<TRequest,TResponse>` with configurable TTLs, hash selection, predicate, and response filtering. |
| 84 | +!!! note |
| 85 | + The memory adapter is process-local. In a multi-instance deployment, use a distributed cache. |
| 86 | + |
| 87 | +To use a distributed cache, register `IDistributedCache` and swap the adapter: |
| 88 | + |
| 89 | +```csharp |
| 90 | +using CleanArchitecture.Extensions.Caching.Adapters; |
| 91 | +using Microsoft.Extensions.Caching.StackExchangeRedis; |
| 92 | + |
| 93 | +builder.Services.AddCleanArchitectureCaching(); |
| 94 | +builder.Services.AddStackExchangeRedisCache(options => |
| 95 | +{ |
| 96 | + options.Configuration = "<redis-connection-string>"; |
| 97 | +}); |
| 98 | + |
| 99 | +builder.Services.AddSingleton<ICache, DistributedCacheAdapter>(); |
| 100 | +``` |
| 101 | + |
| 102 | +## Serialization |
| 103 | + |
| 104 | +The default serializer is `SystemTextJsonCacheSerializer`. Replace it when needed: |
| 105 | + |
| 106 | +```csharp |
| 107 | +using CleanArchitecture.Extensions.Caching.Serialization; |
| 108 | + |
| 109 | +builder.Services.AddSingleton<ICacheSerializer>(sp => |
| 110 | + new SystemTextJsonCacheSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web))); |
| 111 | +``` |
83 | 112 |
|
84 | | -## Pipeline ordering |
| 113 | +## Stampede protection and entry options |
85 | 114 |
|
86 | | -- Recommended: Authorization → Request checks → **QueryCachingBehavior** → Performance/Logging → Handlers (align with the template order you already use). |
87 | | -- Place caching after request checks to avoid caching invalid requests and before performance logging to exclude cache hits from handler timing warnings. |
| 115 | +- `CachingOptions.StampedePolicy` controls locking, timeouts, and jitter. |
| 116 | +- `CachingOptions.DefaultEntryOptions` defines expiration, priority, and size hints. |
| 117 | + |
| 118 | +```csharp |
| 119 | +builder.Services.AddCleanArchitectureCaching(options => |
| 120 | +{ |
| 121 | + options.StampedePolicy = new CacheStampedePolicy |
| 122 | + { |
| 123 | + EnableLocking = true, |
| 124 | + LockTimeout = TimeSpan.FromSeconds(3), |
| 125 | + Jitter = TimeSpan.FromMilliseconds(50) |
| 126 | + }; |
| 127 | +}); |
| 128 | +``` |
88 | 129 |
|
89 | 130 | ## Invalidation guidance |
90 | 131 |
|
91 | | -- Cache-aside pattern: explicit `ICache.Remove` or `ICache.RemoveAsync` on command success or domain event handlers. |
92 | | -- Include versioning and tenant segments in keys to avoid collisions; adjust namespace when making breaking DTO changes. |
| 132 | +Caching is read-through; invalidation is explicit. On command success or domain events, remove keys: |
| 133 | + |
| 134 | +```csharp |
| 135 | +await cache.RemoveAsync(cacheScope.Create("GetUserQuery", hash)); |
| 136 | +``` |
| 137 | + |
| 138 | +Keep key conventions stable and consider bumping the namespace for breaking DTO changes. |
| 139 | + |
| 140 | +## Multitenancy integration |
| 141 | + |
| 142 | +If you use multitenancy, call `AddCleanArchitectureMultitenancyCaching` to include tenant IDs in cache keys: |
| 143 | + |
| 144 | +```csharp |
| 145 | +builder.Services.AddCleanArchitectureCaching(); |
| 146 | +builder.Services.AddCleanArchitectureMultitenancyCaching(); |
| 147 | +``` |
| 148 | + |
| 149 | +## Observability |
| 150 | + |
| 151 | +- `QueryCachingBehavior` logs cache hits and misses at `Debug` level. |
| 152 | +- Adapters log warnings on oversized payloads or deserialization failures. |
| 153 | + |
| 154 | +## Troubleshooting |
| 155 | + |
| 156 | +- Cache is never hit: ensure the request type matches the cache predicate and the behavior is registered. |
| 157 | +- Missing tenant in keys: call `AddCleanArchitectureMultitenancyCaching` after caching registration. |
| 158 | +- Large payloads: raise `MaxEntrySizeBytes` or skip caching via `ResponseCachePredicate`. |
93 | 159 |
|
94 | | -## Backlog / Next Iteration |
| 160 | +## Samples and tests |
95 | 161 |
|
96 | | -- Add PII/classification guardrails so sensitive payloads can be blocked or redirected to encrypted storage. |
97 | | -- Provide an optional encrypting serializer wrapper for distributed caches with guidance for key management. |
98 | | -- Expose instrumentation hooks (hits, misses, latency) without forcing a specific metrics provider. |
99 | | -- Document and/or implement schema-versioned key strategies to support DTO shape changes safely. |
| 162 | +See the caching tests under `tests/` for behavior coverage and usage patterns. |
100 | 163 |
|
101 | | -## Testing |
| 164 | +## Reference |
102 | 165 |
|
103 | | -- Use the default memory adapter for Application tests; distributed adapter can use `MemoryDistributedCache` for deterministic runs. |
| 166 | +- [Caching options](../reference/caching-options.md) |
0 commit comments