Onboarding guide
9. Caching
Caching in this codebase is small, deliberate, and woven into the CQRS pipeline rather than scattered across handlers. The whole subsystem is four types: one interface that the Application layer depends on (ICacheService), two Infrastructure adapters that implement it (MemoryCacheService and DistributedCacheService), and a static TTL-policy factory (CacheOptions). No handler ever talks to Redis or IMemoryCache directly; the read-through and invalidate-on-write behaviour live in two pipeline decorators that you meet in the CQRS chapter. This chapter is the cache's own machinery, the contract, the two backends, and the policy, and how those plug into that pipeline.
The contract is a textbook Clean Architecture port/adapter split (primer §2; [Rubric §3, Clean Architecture]). ICacheService is defined in MMCA.Common.Application (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:8) and exposes exactly four operations: GetAsync<T> (returns T?, null on miss), SetAsync<T> with an optional TimeSpan? TTL, RemoveAsync for a single key, and RemoveByPrefixAsync for bulk eviction by key prefix. That last operation is the load-bearing one, it is what lets a write evict every cached read it could have invalidated in a single call, and it is why both backends had to be built rather than used off the shelf (IMemoryCache has no key enumeration; IDistributedCache has no prefix delete). The Application layer compiles against this interface only; the concrete backend is chosen at the composition root.
That selection happens in AddCaching() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:150-164). It always calls AddMemoryCache(), then registers ICacheService via TryAddSingleton with a factory that probes the container: if an IDistributedCache is registered and it is not the default MemoryDistributedCache (i.e. a real distributed cache such as Redis, typically wired by Aspire), it builds a DistributedCacheService (passing along any IConnectionMultiplexer it can resolve); otherwise it falls back to a MemoryCacheService over the registered IMemoryCache. So a single-host monolith caches in-process for free, and the same code transparently uses Redis once the distributed cache is present, no flag, no per-environment branch in application code. This is the same "abstraction in Application, transport chosen at the edge" pattern that the message bus and gRPC boundaries use, and it keeps the cache backend a deployment concern rather than a code concern ([Rubric §7, Microservices Readiness], [Rubric §12, Performance & Scalability]).
MemoryCacheService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:12) is the in-process adapter. Its one interesting trick is a side ConcurrentDictionary<string, byte> key set, because IMemoryCache cannot enumerate its own keys, without that shadow index, RemoveByPrefixAsync would be impossible. The index is kept honest by registering a post-eviction callback on every Set, so a key that expires or is evicted under memory pressure removes itself from the set rather than leaking. DistributedCacheService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:15) is the out-of-process adapter: it serialises values to UTF-8 JSON via System.Text.Json and stores them through IDistributedCache. Its prefix eviction is conditional on Redis, when an IConnectionMultiplexer is available it uses Redis SCAN (server.KeysAsync(pattern: "{prefix}*")) and deletes the matches; when no multiplexer was injected (a non-Redis IDistributedCache), RemoveByPrefixAsync is a deliberate no-op rather than an error. That asymmetry is worth holding in mind: prefix invalidation is fully supported in-memory and on Redis, but silently does nothing on a hypothetical non-Redis distributed cache.
CacheOptions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheOptions.cs:9) centralises TTL policy so neither adapter nor any caller hand-builds expiry options. Its DefaultExpiration is a deliberately short 30-second absolute window, and Create(TimeSpan?) returns either a caller-specified TTL or that default when the argument is null. The short default is a staleness guard, caching is opt-in and conservative by default; a query only earns a longer life when it explicitly declares one. Only DistributedCacheService routes through CacheOptions today; MemoryCacheService builds its MemoryCacheEntryOptions inline, applying the caller's TTL when present and otherwise leaving expiry to the IMemoryCache defaults, so the 30-second floor is a distributed-cache policy, not a universal one.
Stepping back, these four types are only Tier 1 of the framework's caching story, a deliberate split that ADR-026 (two-tier caching) records. Tier 1 is exactly this swappable ICacheService substrate, chosen at startup by AddCaching() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:150): a DistributedCacheService when Aspire registered Redis, otherwise a MemoryCacheService, with prefix invalidation and the 30-second default TTL described above. Tier 2 is a separate HTTP output-cache edge: MMCA.Common.API always calls app.UseOutputCache() in its middleware pipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:104) but ships no policies, so each host opts in with its own AddOutputCache(...) (most declare a NoCache base; read-heavy public services such as ADC Conference and Store Catalog declare real cacheable policies) to serve anonymous and public reads without ever touching a handler. The two tiers differ in lifetime, keying, and eviction, and both are distinct again from the ADR-014 caching decorators covered below, which are only consumers of this Tier-1 substrate, not part of it. One honest caveat from ADR-026 sharpens the no-op note above: distributed prefix invalidation is conditional on an IConnectionMultiplexer being registered, and the deployed services wire the Redis distributed cache without one, so today the 30-second TTL, not prefix eviction, is the real staleness backstop even on Redis.
The behaviour that makes any of this fire lives in the CQRS decorator pipeline (Logging → Caching → Transactional → handler, taught in Group 5, CQRS Pipeline), and it is entirely opt-in via two marker interfaces. On the read path, CachingQueryDecorator<TQuery, TResult> checks whether the query implements IQueryCacheable; if so it does a read-through against GetAsync<T>(cacheable.CacheKey), returns the cached value on a hit, otherwise runs the inner handler and stores the result under CacheKey for CacheDuration, but only when the result is not a failed Result, so error states are never cached. On the write path, CachingCommandDecorator<TCommand, TResult> runs the inner handler first and then, only if the command implements ICacheInvalidating and the result is not a failure, calls RemoveByPrefixAsync(command.CachePrefix) to evict every read the mutation could have staled. Failed commands deliberately skip invalidation, there is nothing to invalidate if the write did not persist. This is where RemoveByPrefixAsync pays off: a write under prefix "Catalog:Products" clears every paged/filtered read cached under that prefix in one operation.
Putting the runtime flow together: a query implementing IQueryCacheable hits the caching decorator, which asks the DI-resolved ICacheService, in practice a MemoryCacheService in the monolith or a DistributedCacheService backed by Redis in a multi-host deployment, for the value; a miss runs the handler and writes back through SetAsync, whose TTL is shaped by CacheOptions on the distributed path. Later, a command implementing ICacheInvalidating succeeds and its decorator calls RemoveByPrefixAsync, dropping the now-stale reads. Because invalidation runs after the inner handler returns (and, for transactional commands, after the transaction has been committed by the inner Transactional decorator), eviction happens against committed state, not in-flight state.
A note on what the cache is, and is not, used for. This is a request-result read-through cache for query handlers, not a session store or a write-behind buffer; the cross-source consistency mechanism in this codebase is the outbox (ADR-003, ADR-006), not the cache. The conservative 30-second default and the failure-skipping rules mean the cache errs toward correctness over hit-rate, which is the right default for an opt-in cache layered onto a database-per-service system. Observability and the per-type test coverage for these four types live in their own chapters: the unit tests for CacheOptions, DistributedCacheService, and MemoryCacheService (plus the two decorators) are catalogued in Group 25, Testing Infrastructure.
CacheOptions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheOptions.cs:9· Level 0 · class (static)
- What it is: a static factory for
DistributedCacheEntryOptionscarrying a 30-second default TTL. It centralises the cache-expiry policy so the distributed-cache adapter never constructs entry-options inline. - Depends on:
Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions(ASP.NET Core, NuGet). Indirectly fed by the per-query IQueryCacheable.CacheDuration, which flows in as theexpirationargument when a cacheable query's result is stored. - Concept introduced, §12 Performance & Scalability (TTL as a freshness/cost dial). §12 assesses whether the system bounds staleness and avoids unbounded growth. The deliberately short 30-second default (line 14-17) is the conservative knob: cached reads are served for at most 30s before falling through to the source, so a query that never declares its own duration cannot serve dangerously stale data. Callers that can tolerate longer staleness widen the window per query via
IQueryCacheable.CacheDuration.[Rubric §10, Cross-Cutting Concerns], TTL policy lives in one place, not scattered across handlers. - Walkthrough
DefaultExpiration(line 14-17), a property returning a freshDistributedCacheEntryOptionson each access, withAbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30). It is a property, not a shared field, so two callers can never alias and mutate the same options instance.Create(TimeSpan? expiration)(line 24-27), expression-bodied: returns a new options object with the caller'sAbsoluteExpirationRelativeToNowwhenexpirationis non-null, else hands backDefaultExpiration. A null duration therefore means "use the 30s default," matching theTimeSpan?optionality on ICacheService.SetAsync.
- Why it's built this way: a static factory (no instance, no shared mutable state) makes TTL policy a single, allocation-cheap decision point; the absolute-expiration choice (vs. sliding) means a cached entry's lifetime is bounded regardless of how often it is read, which is the safer default for read-through query caching.
- Where it's used: DistributedCacheService.SetAsync calls
CacheOptions.Create(expiration)to build the entry options for every write (DistributedCacheService.cs:39). Note: MemoryCacheService does not route throughCacheOptions, it constructsMemoryCacheEntryOptionsdirectly, so this factory governs the distributed path only. - Caveats / not-in-source: the 30-second default is a distributed-path policy. Because MemoryCacheService bypasses this factory, an in-process cache entry set with a null TTL never auto-expires by time. Do not read the 30s value as a universal cache floor.
ICacheService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:8· Level 0 · interface
- What it is: the Application layer's cache port: get by key, set with an optional TTL, remove by exact key, and remove every key matching a prefix. It hides whether the backing store is Redis, a SQL distributed cache, or an in-process
IMemoryCache. - Depends on: BCL only at the interface level (
Task,CancellationToken,TimeSpan?). Consumed by ICacheInvalidating (prefix eviction after mutations) and IQueryCacheable (key-based read-through caching). - Concept introduced, §3 Clean Architecture (dependency inversion for infrastructure). §3 assesses whether business code depends on abstractions while concrete technology lives at the edges. The Application layer defines the cache contract; the Infrastructure layer implements it (DistributedCacheService / MemoryCacheService). Handlers and the caching decorators never see
StackExchange.RedisorMicrosoft.Extensions.Caching, they program against this interface, and the implementation is chosen by DI.[Rubric §12, Performance & Scalability],RemoveByPrefixAsync(line 40) is the member that makes scoped invalidation possible: a single mutation can evict a whole family of cached query results (e.g. allCatalog:Products:*pages) without enumerating individual keys. - Walkthrough: members in declaration order:
Task<T?> GetAsync<T>(string key, CancellationToken)(line 15), returnsdefault/nullon a miss; the genericTis the deserialized value type.Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken)(line 24),expirationis nullable with a default ofnull, meaning "use the implementation's default TTL" (the distributed adapter resolves this through CacheOptions).Task RemoveAsync(string key, CancellationToken)(line 34), single-key eviction.Task RemoveByPrefixAsync(string prefix, CancellationToken)(line 40), bulk eviction of every key whose name starts withprefix; this is the operation CachingCommandDecorator<TCommand, TResult> invokes on a successful mutation.
- Why it's built this way: the optional
TimeSpan? expirationparameter lets callers override the global TTL without a second overload, andnullreads naturally as "use the configured default." Keeping the port in Application (not Infrastructure) is what allows the CQRS decorators, which live in Application, to depend on caching without dragging a Redis reference into the business layers. - Where it's used: both caching decorators take
ICacheServiceby constructor injection: the query decorator callsGetAsync/SetAsync, the command decorator callsRemoveByPrefixAsync. Infrastructure'sAddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:149-168) registers exactly one implementation behind this interface viaTryAddSingleton. - Caveats / not-in-source: the interface XML doc references
CachingCommandDecoratorfor invalidation (accurate), but the read-side caching is driven byIQueryCacheable, not a separate key-provider type (no such type exists in the current source).
DistributedCacheService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:17· Level 1 · class (internal sealed partial)
- What it is: the out-of-process implementation of ICacheService, backed by ASP.NET Core's
IDistributedCache(Redis or the SQL Server distributed cache). Values are serialized as UTF-8 JSON. When a RedisIConnectionMultiplexeris also available, prefix eviction is implemented via RedisSCAN; without it, prefix eviction is a logged no-op. - Depends on:
Microsoft.Extensions.Caching.Distributed.IDistributedCache,StackExchange.Redis.IConnectionMultiplexer(optional, NuGet),System.Text.Json.JsonSerializer(BCL),Microsoft.Extensions.Logging.ILogger<T>(BCL). Implements ICacheService; writes flow through CacheOptions. - Concept introduced, §12 Performance & Scalability (the distributed-cache adapter) and §7 Microservices Readiness. §7 assesses whether shared state survives a module being split into its own process; a distributed cache is shared across instances and across extracted services, so when ADC scales out or pulls a module into a standalone host, cached reads remain coherent. The interesting design point is that
IDistributedCachehas no key-enumeration API: you cannot ask it "give me all keys starting with X." This adapter therefore reaches past theIDistributedCacheabstraction to the rawIConnectionMultiplexerto satisfyRemoveByPrefixAsyncusing Redis server-sideSCAN.[Rubric §13, Observability & Operability], when that multiplexer is absent the class does not silently swallow the missed invalidation, it logs it (see the walkthrough) so a dead eviction path is visible rather than invisible. - Walkthrough: primary-constructor injection (line 17-20):
IDistributedCache cache(required),ILogger<DistributedCacheService> logger(required), andIConnectionMultiplexer? connectionMultiplexer = null(optional, present only when Redis is wired). The class is declaredpartialso the[LoggerMessage]source generator can emit its log methods.GetAsync<T>(line 23-28), fetches the rawbyte[]viacache.GetAsync; returnsdefaulton a null (miss), elseDeserialize<T>.SetAsync<T>(line 31-40), serializes the value to bytes viaSerialize, then writes viacache.SetAsync(key, bytes, CacheOptions.Create(expiration), …)(line 39), the single call site that turns the caller's optionalTimeSpan?into aDistributedCacheEntryOptionsthrough CacheOptions.RemoveAsync(line 43-44), expression-bodied passthrough tocache.RemoveAsync.DeleteBatchSize(line 47), aconst intof512: the number of keys deleted per Redis round trip during prefix invalidation._noMultiplexerWarned(line 50), anintflag flipped once viaInterlocked.Exchangeso the missing-multiplexer warning fires exactly once, not on every mutating command.RemoveByPrefixAsync(line 58-93), the meat. IfconnectionMultiplexeris null (line 60) it logs the no-op once throughLogPrefixEvictionNoMultiplexer(guarded byInterlocked.Exchange(ref _noMultiplexerWarned, 1) == 0at line 65) and returns, cached entries then expire on TTL alone. If a multiplexer is present but reports no servers (line 71) it logsLogPrefixEvictionNoServerwith the prefix and returns. Otherwise it takes the first server, issuesserver.KeysAsync(pattern: $"{prefix}*")(line 77), andawait foreach-accumulates matched keys into aList<RedisKey>(line 79); each time the batch reachesDeleteBatchSizeit flushes viadb.KeyDeleteAsync([.. batch])and clears (line 84-88), with a final flush for the remainder (line 91-92). Enumeration honors theCancellationTokenviaWithCancellation(line 81).Deserialize<T>/Serialize<T>(line 95-99), JSON helpers overSystem.Text.Json:SerializecallsJsonSerializer.SerializeToUtf8Bytes(value),DeserializecallsJsonSerializer.Deserialize<T>(bytes)!(null-forgiving, since the BCL signature is nominally nullable).LogPrefixEvictionNoMultiplexer/LogPrefixEvictionNoServer(line 101-105),[LoggerMessage]Warning-level partial methods generated by the logging source generator.
- Why it's built this way: UTF-8 JSON keeps cached payloads engine-agnostic and human-inspectable. The optional multiplexer is the pragmatic compromise behind ADR-006 / ADR-008 thinking: the cache contract must work whether the deployment has Redis (full prefix eviction) or only a fallback distributed store (single-key operations only), so prefix eviction degrades to a no-op rather than throwing, invalidation that cannot run simply lets entries expire by TTL instead. The warn-once behavior is the observability guard-rail that makes that degradation visible without flooding the log. Batching deletes in blocks of 512 keeps a large invalidation from stalling the mutating command on one round trip per key. The class is
internal sealed partial:partialfor the generated log methods,internal sealedbecause it is only ever resolved through the ICacheService registration, never referenced by name outside Infrastructure. - Where it's used: selected by
AddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:153-165): theTryAddSingleton<ICacheService>factory constructs this implementation only when a non-MemoryDistributedCacheIDistributedCacheis present (line 156-161), resolving the optionalIConnectionMultiplexerand anILogger(falling back toNullLoggerwhen none is registered); otherwise it falls back to MemoryCacheService. Downstream, the CachingQueryDecorator<TQuery, TResult> and CachingCommandDecorator<TCommand, TResult> consume it through the interface. - Caveats / not-in-source:
KeysAsync/SCANis O(keyspace) on the Redis side, acceptable for the invalidation cadence but not a hot-path operation. Per ADR-026, the deployed services wire the Redis distributed cache without anIConnectionMultiplexer, so in production the no-op branch (and its warn-once log) is the live path, and the 30-second TTL from CacheOptions, not prefix eviction, is the real staleness backstop today.
MemoryCacheService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:12· Level 1 · class (internal sealed)
- What it is: the in-process implementation of ICacheService, backed by
IMemoryCache. BecauseIMemoryCacheexposes no way to enumerate its keys, this service maintains its ownConcurrentDictionary<string, byte>key-set so it can honorRemoveByPrefixAsync, the one capability the BCL memory cache lacks. - Depends on:
Microsoft.Extensions.Caching.Memory.IMemoryCache/MemoryCacheEntryOptions(BCL),System.Collections.Concurrent.ConcurrentDictionary<TKey,TValue>(BCL). Implements ICacheService. - Concept introduced, §12 Performance & Scalability (the single-instance fast path) and a side-table to back-fill a missing API. §12 assesses cheap reads; an in-process cache is the lowest-latency option but is not shared across instances, so it is correct only for a single-process monolith or for genuinely per-instance data. The teachable mechanic here is the shadow key-set:
IMemoryCacheis a black box with no key listing, so the service mirrors every live key into_keysand keeps that mirror honest using a post-eviction callback: whenIMemoryCachedrops an entry (TTL expiry, capacity pressure, or manual remove) the callback prunes_keys, preventing the dictionary from leaking keys for entries the cache has already discarded.[Rubric §10, Cross-Cutting Concerns], it presents the identical ICacheService surface as the distributed adapter, so swapping backends changes nothing for callers. - Walkthrough: primary-constructor injection (line 12):
IMemoryCache cache._keys(line 19),new ConcurrentDictionary<string, byte>(StringComparer.Ordinal); thebytevalue is unused (set-like usage),Ordinalcomparison matches the prefix matching below and avoids culture-sensitive surprises.GetAsync<T>(line 22-33), callscache.TryGetValue(key, out var stored)and then type-checks the stored object withstored is T typed(line 27) before returning it, wrapping the result inTask.FromResult(no real async,IMemoryCacheis in-memory). The pattern-match cast is deliberate (comment at line 24-26): the genericTryGetValue<T>overload would do an unchecked(T)storedcast and throwInvalidCastExceptionwhen a key is reused under a differentT; matching on the stored object instead makes a type mismatch (or a stored null) surface as a clean miss.SetAsync<T>(line 36-57), buildsMemoryCacheEntryOptions, setsAbsoluteExpirationRelativeToNowonly whenexpiration.HasValue(note: no 30s default here, an unset TTL means the entry never auto-expires by time, unlike the distributed path's CacheOptions), registers the post-eviction callback that removes the key from_keys(line 50-51), callscache.Set, then_keys.TryAdd(key, 0).RemoveAsync(line 60-66), removes from bothcacheand_keys.RemoveByPrefixAsync(line 69-78), iterates_keys.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal))and removes each from both stores; this is what lets the in-process backend satisfy the same prefix-eviction contract Redis gets fromSCAN.
- Why it's built this way: keeping a parallel key index is the only way to give
IMemoryCachea prefix-removal capability without replacing it; the eviction callback is what keeps that index from drifting (a naive key-set would accumulate phantom keys as entries expired).internal sealed, resolved only through the ICacheService port. - Where it's used: the default / fallback registration in
AddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:164):AddCaching()always callsAddMemoryCache()(line 151), and when no real distributed cache is configured this is theICacheServicethe container hands out, so a host with no Redis behaves as a single-instance cached monolith. Consumed through the interface by both CQRS caching decorators. - Caveats / not-in-source: because the cache is per-process, two instances will hold independent, potentially divergent copies until each entry's TTL or an explicit eviction reconciles them, which is why the distributed adapter exists for scaled-out deployments.
⬅ Authentication & Authorization • Index • Notifications (Push + In-App Inbox + Email) ➡