Onboarding guide
9. Caching
What this group covers. Caching in this codebase is small, deliberate, and woven into the CQRS
pipeline rather than scattered across handlers. The group is nine types: one port the Application
layer depends on (ICacheService), three Infrastructure adapters that implement it
(MemoryCacheService, DistributedCacheService,
and the opt-in two-level HybridCacheService), the shared Redis prefix-eviction
helper the last two both run (RedisPrefixScanner), a static TTL-policy factory
(CacheOptions) with the bound settings object that reproduces its defaults from
configuration (CacheSettings), and the optional key-namespace pair
(CacheKeyPrefixOptions plus its internal applier
CacheKeyNamespace) that keeps two services sharing one Redis instance out of
each other's keyspace. No handler ever talks to Redis or IMemoryCache directly: the read-through and
invalidate-on-write behavior lives in two pipeline decorators taught in
Group 5, CQRS Pipeline. This chapter is the cache's own machinery, the
contract, the three backends, the scanner, the TTL policy and its configuration object, and the
namespace, plus how they plug into that pipeline.
The contract. ICacheService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:10) is a textbook Clean
Architecture port/adapter split (see primer §1): the interface lives
in MMCA.Common.Application, all three implementations live in MMCA.Common.Infrastructure, and
application code compiles against the interface alone. It declares six members. Four are abstract:
GetAsync<T> returning T? with null for a miss (ICacheService.cs:17), SetAsync<T> with an
optional TimeSpan? TTL (ICacheService.cs:26), RemoveAsync for one key (ICacheService.cs:36),
and RemoveByPrefixAsync for bulk eviction by key prefix (ICacheService.cs:42). Two are default
interface members with working bodies, so each one shipped without breaking an implementer:
IncrementAsync (ICacheService.cs:59) is a read-modify-write counter (ICacheService.cs:61-64) that
gives the ADR-029
brute-force and rate-limit counters one entry point instead of scattered get-then-set pairs, and
GetOrCreateAsync<T> (ICacheService.cs:99) folds read, per-key stripe, double-check, factory and
write into one call (ICacheService.cs:104-124) using the process-wide CacheKeyLocks table
(ICacheService.cs:142-146), cross-referenced in
Group 5. Its XML doc is explicit about two limits that
matter: it caches whatever the factory returned, failed Result
included, which is exactly why the caching decorators do not route through it, and its stampede
protection is per process (ICacheService.cs:80-97). RemoveByPrefixAsync is the load-bearing member:
it is what lets a single write evict every cached read it could have staled, and it is why the backends
had to be built rather than used off the shelf (IMemoryCache has no key enumeration, IDistributedCache
has no prefix delete). [Rubric §3, Clean Architecture] assesses whether dependencies point inward and
infrastructure stays replaceable; this is that rule in one file, since the only thing Application knows
about caching is six method signatures.
Backend selection happens once, at the composition root. AddCaching(IConfiguration?)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:229, called from
AddInfrastructure at DependencyInjection.cs:131) always calls AddMemoryCache()
(DependencyInjection.cs:231), then binds the shared Cache configuration section three ways when
configuration was supplied (DependencyInjection.cs:242-255): to
CacheKeyPrefixOptions for the key namespace
(DependencyInjection.cs:244), to CacheSettings
for the TTL policy (DependencyInjection.cs:246-249), and to
QueryCachePipelineSettings for
the one knob the Application-layer query decorator needs (DependencyInjection.cs:251-254, defined in
that layer because it cannot reference Infrastructure). Without configuration both settings objects are
still registered at their defaults (DependencyInjection.cs:256-260), so IOptions<T> always resolves.
ICacheService itself is registered through TryAddSingleton with a factory that
probes the container (DependencyInjection.cs:262-281). If an IDistributedCache is registered and
it is not the default MemoryDistributedCache (DependencyInjection.cs:265), meaning a real
out-of-process store such as the Redis cache Aspire wires, the factory builds a
DistributedCacheService with whatever IConnectionMultiplexer and
ILogger it can resolve plus the bound key namespace and cache settings
(DependencyInjection.cs:267-276); otherwise it falls back to a
MemoryCacheService over the registered IMemoryCache
(DependencyInjection.cs:280). The same method registers the
IDistributedLock that the API idempotency filter pairs
with the cache, Redis-backed when a
multiplexer is present and process-local
otherwise (DependencyInjection.cs:287-301). A single-process monolith therefore caches in-process for
free, and the identical application code uses Redis the moment a distributed cache is present: no flag,
no per-environment branch in a handler. This is the same "abstraction in Application, transport chosen
at the edge" pattern the message bus and gRPC clients use, which is what [Rubric §7, Microservices
Readiness] looks for (can a module move to its own process without a code change) and part of what
[Rubric §12, Performance and Scalability] rewards (the scaled-out deployment gets a shared cache
without touching business code).
A third substrate, opt in and explicit. AddCommonHybridCache(Action<HybridCacheOptions>?)
(DependencyInjection.cs:340) calls AddHybridCache() (DependencyInjection.cs:342) and then
configures HybridCacheOptions through the options pipeline rather than through the
AddHybridCache callback (DependencyInjection.cs:348-362), because the TTL policy now comes from
the bound CacheSettings and the callback has no service provider to read it
from; the framework sets Expiration from DefaultDuration and
LocalCacheExpiration from LocalCacheDuration (falling back to
HybridCacheService.LocalCacheDefault) at DependencyInjection.cs:355-359, and the host's own hook
runs last so it can override anything (DependencyInjection.cs:361). The swap of the cache
implementation is deliberately RemoveAll<ICacheService>() followed by AddSingleton
(DependencyInjection.cs:366-380) rather than TryAdd, so the call wins whether it runs before or
after AddInfrastructure; the source is equally explicit that this also removes a host's own bespoke
ICacheService, so calling it is a statement that the two-level cache is the cache
(DependencyInjection.cs:328-333). All seven deployed service hosts call it, inside the same "is Redis
configured" branch that registers the distributed cache: ADC Conference at
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:151 and Store Catalog at
MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:94, with ADC Engagement, Identity
and Notification and Store Sales and Identity alongside them. Without Redis the branch does not run and
the host keeps the auto-selected substrate, which is the point: an L1 in front of an in-process L2 buys
nothing (ADR-077).
The in-process adapter. MemoryCacheService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:18) wraps
IMemoryCache and carries one side structure: a ConcurrentDictionary<string, object> of live keys
(MemoryCacheService.cs:31), because IMemoryCache cannot enumerate its own keys and without that
shadow index RemoveByPrefixAsync (MemoryCacheService.cs:128-138) would be impossible. Keeping the
cache and the index in agreement is the whole design. Every mutation takes that key's stripe from a
per-instance KeyedSemaphoreStripe
(MemoryCacheService.cs:38) and touches the cache before the table
(MemoryCacheService.cs:101-105), because ordering alone cannot close the window: track-then-write
lets a concurrent removal drop the record between the steps, and write-then-track lets a removal run
entirely between them, both leaving a live entry nothing can find. The post-eviction callback is
deliberately lock-free, since IMemoryCache queues it to the thread pool, and it removes the record
only while the record is still its own, comparing the entry token by reference and skipping
EvictionReason.Replaced outright (MemoryCacheService.cs:93-99). GetAsync also matches on the
stored object rather than using the generic TryGetValue<T> overload (MemoryCacheService.cs:46), so
a key reused under a different T surfaces as a clean miss instead of an InvalidCastException.
The out-of-process adapter. DistributedCacheService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:19)
serializes values to UTF-8 JSON via System.Text.Json (DistributedCacheService.cs:153-157) and
stores them through IDistributedCache, applying the bound TTL default when the caller supplies none
(DistributedCacheService.cs:37, DistributedCacheService.cs:64). Prefix eviction is where it earns
its keep: when an IConnectionMultiplexer is available it hands the namespace-qualified pattern and a
raw KeyDeleteAsync to RedisPrefixScanner
(DistributedCacheService.cs:116-122), resolving the IDatabase lazily on the first delete
(DistributedCacheService.cs:114, DistributedCacheService.cs:119). When no multiplexer is
registered, prefix eviction cannot run at all, and rather than failing silently the class logs a
warning once, guarded by an Interlocked.Exchange flag (DistributedCacheService.cs:73,
DistributedCacheService.cs:107-108) and naming the fix (AddRedisClient), because a permanently dead
invalidation is a steady state that must not flood the log on every command; the anomalous "multiplexer
with no servers" case and a per-server failure each get their own message
(DistributedCacheService.cs:120-121). All three are compile-time LoggerMessage sources
(DistributedCacheService.cs:159-166). That warn-once-versus-warn-always split is a small but real
[Rubric §13, Observability and Operability] decision: §13 assesses whether an operator can tell what
the system is doing, and a cache whose invalidation quietly does nothing is exactly the failure mode
that hides from dashboards. The class also overrides IncrementAsync
(DistributedCacheService.cs:145-151) while keeping the same non-atomic read-modify-write shape, and
the comment above it (DistributedCacheService.cs:126-144) is worth reading: Redis INCR would be
atomic but writes a Redis string, while StackExchangeRedisCache stores every entry as a Redis
hash, so an INCR-written counter makes the next read fail with WRONGTYPE. Readability of the
counter wins over atomicity, and ADR-026
records the resulting undercount as an accepted position, not an open defect.
One scanner, two callers. RedisPrefixScanner
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/RedisPrefixScanner.cs:24) is the shared
eviction engine. RemoveMatchingAsync (RedisPrefixScanner.cs:53) filters the multiplexer's servers
to the non-replicas (RedisPrefixScanner.cs:64), because keys are spread across primaries and scanning
only the first would leave the rest alive until their TTL, while a delete against a replica is rejected
outright. Each server is scanned inside its own try/catch so one unreachable node is logged and skipped
instead of aborting invalidation on the healthy ones, and cancellation is deliberately not caught
(RedisPrefixScanner.cs:71-83). ScanAndDeleteAsync (RedisPrefixScanner.cs:92) walks
server.KeysAsync(pattern: ...) and issues one single-key delete per match
(RedisPrefixScanner.cs:105-118): under Redis cluster policy a multi-key DEL must not span hash
slots and StackExchange.Redis answers a cross-slot command by throwing, so batching the keys of a
prefix would fault the invalidation rather than speed it up. Round trips still stay bounded by keeping
DeleteBatchSize = 512 deletes in flight and awaiting them as a group (RedisPrefixScanner.cs:27).
The delete itself is a caller-supplied callback and the log messages stay with the caller
(RedisPrefixScanner.cs:10-23), which is precisely what lets the two services share the scan while
removing keys differently.
The two-level cache. HybridCacheService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:36) puts an
in-process L1 in front of the registered distributed L2 and lets the platform's HybridCache supply
serialization, L1 promotion and stampede protection. Its structural decision is the disjoint
keyspace: every key is written as {prefix}hc:{key} (HybridCacheService.cs:47,
HybridCacheService.cs:260), so the payload layout HybridCache writes can never meet the UTF-8 JSON
DistributedCacheService writes at one key. That is the WRONGTYPE
lesson from IncrementAsync generalized: an entry in the other format is simply a clean miss, including
while a rolling deploy runs both builds (HybridCacheService.cs:18-28). RemoveByPrefixAsync
consequently runs the scanner over that one keyspace, {namespace}hc:{prefix}*, and deletes each match
through HybridCache.RemoveAsync rather than by raw key, so this process's L1 copy goes with the L2
entry (HybridCacheService.cs:173-179); a missing multiplexer warns once exactly as it does on the
single-level adapter (HybridCacheService.cs:163-171). Reads are fail-soft: a fault is logged,
answered as a miss, and the offending entry is dropped best-effort so the next write repopulates it
(HybridCacheService.cs:103-122, HybridCacheService.cs:290-300). IncrementAsync is the one member
that bypasses L1 on both legs (HybridCacheService.cs:71-76, HybridCacheService.cs:206-227), and
the reasoning is a [Rubric §11, Security] point rather than a performance one: a counter cached
per replica would let one process read its own stale count and write it back, so a brute-force limiter
could be held near its starting value indefinitely by a steady stream of attempts against a single
replica. Its faults are deliberately not swallowed either, since a counter that silently reads zero
resets the limit it exists to enforce (HybridCacheService.cs:201-204). GetOrCreateAsync overrides
the interface default with HybridCache's own implementation (HybridCacheService.cs:238-255).
Replica L1 staleness after an invalidation is bounded by the local expiration, the shorter of the
entry's TTL and Cache:LocalCacheDuration (30 seconds by default,
HybridCacheService.cs:54, HybridCacheService.cs:269-280), not by the eviction, and the source names
that as the accepted cost of the L1 hit rate.
The key namespace and the TTL policy. CacheKeyPrefixOptions
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:28) binds the Cache
configuration section (CacheKeyPrefix.cs:31) and carries one setting, KeyPrefix, defaulting to
empty (CacheKeyPrefix.cs:37). CacheKeyNamespace (CacheKeyPrefix.cs:41) is
the internal applier: a None instance for the untouched case (CacheKeyPrefix.cs:44), a From
factory that tolerates an unregistered options section (CacheKeyPrefix.cs:50-54), and Qualify
(CacheKeyPrefix.cs:57) which prepends the prefix. Both Redis-capable adapters honor it and apply it
inside the adapter rather than through RedisCacheOptions.InstanceName; the rationale in the source
(CacheKeyPrefix.cs:13-22) is precise, since InstanceName is prepended below this abstraction where
the SCAN cannot see it, so prefix eviction would search for product:* while the stored keys were
svc:product:* and evict nothing, silently. MemoryCacheService ignores
prefixes entirely because a per-process keyspace is private by construction (CacheKeyPrefix.cs:23-26).
TTL policy is centralized the same way: CacheOptions
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheOptions.cs:15) exposes a
deliberately short 30-second DefaultDuration (CacheOptions.cs:23) as a bare TimeSpan, so
implementations that do not speak DistributedCacheEntryOptions still default to the same policy, plus
DefaultExpiration (CacheOptions.cs:28) and Create(TimeSpan?) (CacheOptions.cs:38) for the ones
that do. Those values are the hard-coded framework defaults and the single source of truth for the
configurable path as well: CacheSettings
defaults DefaultDuration to CacheOptions.DefaultDuration
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:32), so a host that
configures nothing behaves exactly as it did before that section existed. The short default is a
staleness guard: caching is opt-in and conservative, and a read earns a longer life only by asking for
one. One policy object with many call sites is the [Rubric §12, Performance & Scalability] habit this framework
applies everywhere.
How it fires at runtime. Nothing above runs unless a use case opts in, via two marker interfaces
consumed by the CQRS decorator pipeline (FeatureGate then Logging then Caching then Validating
then Transactional then handler for commands, and the same chain without the last two for queries;
the order is documented at
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:66-83 and registered at
DependencyInjection.cs:134 and DependencyInjection.cs:142, taught in
Group 5). On the read path,
CachingQueryDecorator<TQuery, TResult>
tests the query for IQueryCacheable and passes straight
through when it is absent
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:63-64).
When present it scopes the key to the resolved tenant through
TenantCacheKey (CachingQueryDecorator.cs:57-58, so two
tenants can never share an entry), takes a lock-free fast path on a hit
(CachingQueryDecorator.cs:73-78), and on a miss acquires a per-key stripe from the process-wide
QueryCacheKeyLocks (CachingQueryDecorator.cs:184),
re-checks (CachingQueryDecorator.cs:99-104), records the miss on
CqrsMetrics exactly once (CachingQueryDecorator.cs:112),
and only then runs the inner handler. That stripe wait is itself bounded by
Cache:PopulateLockTimeout (CachingQueryDecorator.cs:83-84, default
Timeout.InfiniteTimeSpan on CacheSettings at CacheSettings.cs:57): when a
finite budget elapses the waiter logs, counts a miss, and runs the query itself uncached rather than
queueing behind a pathologically slow populate (CachingQueryDecorator.cs:86-95,
CachingQueryDecorator.cs:178-197). The lock table is a fixed-width
KeyedSemaphoreStripe
(MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22, 256 stripes by
default at KeyedSemaphoreStripe.cs:25), which bounds memory no matter how many parameterized cache
keys the process sees. Every cache call is fail-open: a read fault is logged and treated as a miss
(CachingQueryDecorator.cs:207-222) and a populate fault returns the result uncached
(CachingQueryDecorator.cs:119-130), so a cache outage degrades reads instead of turning cacheable
queries into 500s, which is the [Rubric §29, Resilience] posture in miniature. Results are stored only
when they are not a failed Result
(CachingQueryDecorator.cs:117). On the write path,
CachingCommandDecorator<TCommand, TResult>
runs the inner handler first and then, only if the command implements
ICacheInvalidating, the prefix is non-blank (a blank
prefix is the opt-out, and the guard is load-bearing since an empty prefix would evict the whole cache)
and the result is not a failure, evicts the tenant-scoped prefix with CancellationToken.None
(.../Decorators/CachingCommandDecorator.cs:59-72). It then schedules a second eviction five seconds
later (CachingCommandDecorator.cs:43, CachingCommandDecorator.cs:79,
CachingCommandDecorator.cs:96-109) to catch a read that began before the commit and repopulated the
entry with pre-write state. Because the Caching decorator sits outside the Transactional one, eviction
runs after the transaction committed: against persisted state, never in-flight state, and never at all
when the write failed.
Two tiers, not one. These nine types are only Tier 1 of the caching story that
ADR-026 records. Tier 2 is a separate
HTTP output-cache edge: MMCA.Common.API always runs app.UseOutputCache() as a named step in the
shared middleware pipeline
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:137-139, see
MiddlewarePipelineBuilder) but ships no
policies, so each host opts in with its own AddOutputCache(...). The read-heavy public services
declare real cacheable policies through
OutputCacheOptionsExtensions and its
AddPublicEndpointPolicy
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:20), backed
by PublicEndpointOutputCachePolicy
(.../Caching/PublicEndpointOutputCachePolicy.cs:35), which caches GET and HEAD regardless of an
Authorization header (PublicEndpointOutputCachePolicy.cs:110,
ADR-040),
and evict it across replicas through the
OutputCacheEvictionRequested integration
event and its OutputCacheEvictionHandler.
Both adopters back that edge with Redis when Redis is configured
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:141;
MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:104), so the two tiers ride the same
Redis instance from opposite ends: Tier 1 through IDistributedCache or HybridCache, Tier 2 through
the output-cache store. ADR-026 also records an optional third tier on the client,
IUiReadCache, a per-circuit read-through cache over
the API client; it is a framework capability rather than a posture every UI host adopts, and it belongs
to Group 15, Common UI Framework. Tier 2 belongs to
Group 12, API Hosting; both are named here only so you do not
confuse them with Tier 1 when you meet [OutputCache] on a controller.
Adoption reality, so you read the code with the right expectations. Prefix invalidation against
Redis is live in the deployed services: every service host registers the Aspire Redis integration
through RedisCachingExtensions, whose
AddRedisCaching() brings the IConnectionMultiplexer the SCAN needs along with the distributed cache
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:131;
MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:86), and the hybrid substrate is
registered inside the same connection-string conditional
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:149-152). Write-side adoption is
broad: about forty types across ADC's Conference, Engagement and Identity modules implement
ICacheInvalidating, for example
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:18
with its aggregate-scoped CachePrefix at :21. Read-side adoption is not: ADC's
GetNowNextQuery
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23,
key at :26-35, a 30-second CacheDuration at :38) is the only
IQueryCacheable implementation in ADC, so most
invalidation traffic currently evicts entries no query wrote. The substrate's other production
consumers are not decorators at all:
LoginProtectionService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19) injects
ICacheService for its brute-force and rate-limit counters
(LoginProtectionService.cs:75, LoginProtectionService.cs:130), covered in
Group 8, Authentication and Authorization, and
IdempotencyFilter resolves it per request to
store and replay responses
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:140). Three honest
caveats round this out. No host in the workspace sets Cache:KeyPrefix in an appsettings file today,
so CacheKeyNamespace resolves to None everywhere and the feature is available
rather than exercised. The stampede lock is per process
(CachingQueryDecorator.cs:238-244): across replicas over a shared Redis you get at most one handler
execution per replica, not one cluster-wide, which is deliberate (a distributed lock is not attempted)
and harmless because the duplicated writes carry equal content. And GetOrCreateAsync has no
first-party caller outside tests today: it is a published extension point plus the member
HybridCacheService overrides.
What the cache is not. This is a request-result read-through cache for query handlers plus a
counter store and an idempotency record store, not a session store and not a write-behind buffer;
cross-source consistency in this codebase is the outbox's job
(ADR-003,
ADR-006), not the cache's. The
short default TTL and the two failure-skipping rules (never cache a failed result, never invalidate on
a failed command) mean the layer errs toward correctness over hit rate, which is the right default for
an opt-in cache bolted onto a database-per-service system. Everything in the bound Cache section is
fail-open by design (CacheSettings.cs:10-15): no value there can turn a cache outage or a slow
populate into an error. The unit tests for these types, including the Redis-backed
DistributedCacheServiceRedisTests
and HybridCacheServiceRedisTests,
are catalogued in Group 27, Testing and Quality Infrastructure.
CacheKeyPrefixOptions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:28· Level 0 · class (public sealed, options)
- What it is: the bound options object for one setting,
Cache:KeyPrefix, the namespace prepended to every cache key written through DistributedCacheService, HybridCacheService and the Redis distributed lock. It exists so several services sharing one Redis instance cannot read each other's entries by accidentally choosing the same key. - Depends on: nothing first-party. It is a plain options POCO bound by
Microsoft.Extensions.Options/IConfiguration(BCL). Its value is turned into behavior by CacheKeyNamespace, which is what the cache adapters actually hold. - Concept introduced, keyspace isolation for a shared cache.
[Rubric §7, Microservices Readiness]assesses whether a module keeps working, and keeps its data to itself, once it is lifted into its own process next to its siblings. A cache instance is exactly the kind of shared infrastructure that survives extraction unchanged, so the isolation a private process gave you for free has to be re-created explicitly: the class doc (CacheKeyPrefix.cs:5-12) states the failure mode plainly, two services that pick the same key for different data will serve each other's values. Giving each service a prefix such as"conference:"restores the separation.[Rubric §11, Security]assesses whether the system prevents data reaching a caller who should not see it; a cross-service key collision is a data-exposure bug wearing a performance-feature costume, and this option is the control that prevents it. - Walkthrough
SectionName(CacheKeyPrefix.cs:31),const string="Cache". This is the configuration section, so the setting a host writes isCache:KeyPrefix. The same section carries the TTL policy (CacheSettings) and the query pipeline's populate-lock knob (QueryCachePipelineSettings), all three bound side by side inAddCaching(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:242-260).KeyPrefix(CacheKeyPrefix.cs:37),stringwith{ get; init; }and a default ofstring.Empty. Empty is the deliberate default: it leaves keys exactly as callers wrote them, which is the correct behavior for a host that owns its cache outright and does not share it.
- Why it's built this way: the class remarks (
CacheKeyPrefix.cs:13-26) record the decision that makes this type necessary rather than redundant. Redis has a built-in equivalent,RedisCacheOptions.InstanceName, and it was rejected:InstanceNameis prepended byIDistributedCachebelow this framework's abstraction, where prefix invalidation cannot see it. The SCAN that backsRemoveByPrefixAsyncmatches raw Redis keys, so it would search forproduct:*while the stored keys weresvc:product:*and evict nothing, silently. Applying the prefix inside the adapter instead keeps get, set, remove and prefix eviction working from one key shape. ADR-026 records the same reasoning (026-caching-strategy.md:235-238). - Where it's used: bound in
AddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:244) viaservices.Configure<CacheKeyPrefixOptions>(configuration.GetSection(CacheKeyPrefixOptions.SectionName)), and only when a non-nullIConfigurationwas passed (DependencyInjection.cs:242).AddInfrastructurealways passes one (DependencyInjection.cs:131), so a host composing through the normal entry point gets the binding; a test calling the parameterlessAddCaching()overload does not. The bound options are then read once per registration through CacheKeyNamespace.From, at three sites: the distributed cache factory (DependencyInjection.cs:270), the Redis lock factory (DependencyInjection.cs:294) and the opt-in hybrid factory (DependencyInjection.cs:372). - Caveats / not-in-source: MemoryCacheService never sees the prefix, because
a per-process keyspace is private by construction and a prefix would add nothing
(
CacheKeyPrefix.cs:23-26). Also worth knowing before you go looking for a live example: no checked-inappsettings*.jsonin the four repos setsCache:KeyPrefix, so the effective prefix everywhere today is the empty default, and the option is a capability that is wired but not exercised.
CacheOptions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheOptions.cs:15· Level 0 · class (public static)
- What it is: the framework's hard-coded TTL policy in one place. It exposes the default cache
lifetime both as a bare
TimeSpanand as a ready-madeDistributedCacheEntryOptions, so no adapter and no caller hand-builds expiry options. - Depends on:
Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions(ASP.NET Core, NuGet). It is the seed value for CacheSettings.DefaultDuration(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:32), and it is indirectly fed by the per-query IQueryCacheableCacheDuration, which arrives as theexpirationargument when a cacheable query's result is stored. - Concept introduced, TTL as the freshness dial.
[Rubric §12, Performance & Scalability]assesses whether the system bounds staleness and avoids unbounded growth. The deliberately short 30-second default (CacheOptions.cs:23) is the conservative knob: a cached read is served for at most 30 seconds before falling through to the source, so a query that never declares its own duration cannot serve dangerously stale data. Callers that can tolerate more staleness widen the window per query.[Rubric §12, Performance & Scalability]assesses whether concerns like caching, logging and validation live in one place instead of being re-decided per handler; TTL policy here is one property in one file rather than aTimeSpan.FromSeconds(30)scattered through call sites. - Walkthrough
DefaultDuration(CacheOptions.cs:23), astatic TimeSpanproperty =TimeSpan.FromSeconds(30). Its doc (CacheOptions.cs:17-22) explains why the bareTimeSpanexists alongside the options object: HybridCacheService expresses expiry inHybridCacheEntryOptions, its own type, and would otherwise have needed a second hard-coded 30 seconds. One number, two shapes.DefaultExpiration(CacheOptions.cs:28-31), a property returning a freshDistributedCacheEntryOptionson each access, withAbsoluteExpirationRelativeToNow = DefaultDuration. It is a property, not a shared static field, so two callers can never alias and mutate the same options instance.Create(TimeSpan? expiration)(CacheOptions.cs:38-41), expression-bodied: a new options object carrying the caller'sAbsoluteExpirationRelativeToNowwhenexpirationis non-null, otherwiseDefaultExpiration. A null duration therefore reads as "use the 30s default", which is exactly the meaning of the optionalTimeSpan?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. Choosing absolute expiration over sliding means an
entry's lifetime is bounded no matter how often it is read, which is the safer default for
read-through query caching: a hot key cannot keep itself alive indefinitely on stale data. The class
remarks (
CacheOptions.cs:9-14) name the split that came later: these are the framework defaults and the source of truth for the values, while per-host tuning goes through the bindableCachesection (CacheSettings), whose every property defaults to what this class exposes so the configured and hard-coded paths cannot drift. ADR-026 records the short default as the backstop that lets prefix invalidation stay best-effort without the system becoming incorrect (026-caching-strategy.md:62). - Where it's used: DistributedCacheService
.SetAsynccallsCacheOptions.Create(expiration ?? _settings.DefaultDuration)for every write (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:64), which is the only first-party call site ofCreate.DefaultDurationseeds CacheSettings.DefaultDuration(CacheSettings.cs:32), which is what both distributed adapters actually read at runtime. MemoryCacheService does not route through this type at all; it buildsMemoryCacheEntryOptionsinline. Unit-tested by CacheOptionsTests. - Caveats / not-in-source:
DefaultExpirationhas no first-party caller outsideCreateitself (CacheOptions.cs:41) and its test; it is a published convenience on the package surface. And do not read the 30 seconds as a universal cache floor: because MemoryCacheService bypasses this type, an in-process entry set with a null TTL has no time-based expiry at all (MemoryCacheService.cs:72-75) and leaves only capacity pressure or an explicit removal to clear it.
RedisPrefixScanner
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/RedisPrefixScanner.cs:24· Level 0 · class (internal static)
- What it is: the one implementation of "evict every Redis key matching this pattern". It SCANs each non-replica server and deletes every match, and both Redis-capable cache adapters call it instead of carrying their own copy of the loop.
- Depends on:
StackExchange.Redis(IConnectionMultiplexer,IServer,RedisKey,RedisException) as its only external, plus the BCL. It is called by DistributedCacheService and HybridCacheService; it references neither of them, so the dependency points one way. - Concept introduced, parameterizing the parts that differ instead of forking the algorithm.
[Rubric §1, SOLID]assesses single responsibility and dependency direction;[Rubric §2, Design Patterns]assesses whether the code reaches for a known shape rather than improvising. The two callers need the same scan and different deletes, so the delete is aFunc<RedisKey, Task>callback (RedisPrefixScanner.cs:56) rather than a fixedKeyDeleteAsync: DistributedCacheService deletes the raw Redis key, while HybridCacheService routes the delete back throughHybridCache.RemoveAsyncso its own in-process L1 copy dies with the L2 entry (RedisPrefixScanner.cs:11-17). Logging is parameterized the same way, as twoActionhooks (RedisPrefixScanner.cs:57-58), because each service owns its own compile-timeLoggerMessagedefinitions and its own notion of the prefix being evicted (RedisPrefixScanner.cs:18-22).[Rubric §14, Testability]assesses whether behavior can be exercised without standing up the world: folding the scan into one internal helper means the Redis-tier tests that cover one adapter cover the algorithm for both (RedisPrefixScanner.cs:6-8). - Walkthrough
DeleteBatchSize(RedisPrefixScanner.cs:27),const int=512. Read it carefully: it is the number of single-key deletes kept in flight at once, not the number of keys packed into one command.RemoveMatchingAsync(connectionMultiplexer, pattern, deleteAsync, onNoServers, onServerFailed, cancellationToken)(RedisPrefixScanner.cs:53-84), the entry point. It first collects every non-replica server,[.. connectionMultiplexer.GetServers().Where(s => !s.IsReplica)](RedisPrefixScanner.cs:64). Every primary is scanned, not just the first one the multiplexer reports, because keys are distributed across primaries and scanning one leaves the others' entries alive until their TTL expires; replicas are skipped because their keyspace mirrors a primary already scanned and a delete against a replica is rejected (RedisPrefixScanner.cs:42-46). An empty server list invokesonNoServers()and returns (RedisPrefixScanner.cs:65-69). Otherwise each server is scanned inside its own try/catch (RedisPrefixScanner.cs:71-83) whose filter admits onlyRedisException,RedisCommandExceptionandTimeoutException(RedisPrefixScanner.cs:77), so one unreachable node is logged throughonServerFailed(Describe(server), ex)and skipped while the healthy nodes still get invalidated. Cancellation is deliberately not caught (RedisPrefixScanner.cs:80).ScanAndDeleteAsync(server, pattern, deleteAsync, cancellationToken)(RedisPrefixScanner.cs:92-119), the per-server loop. It allocatesnew List<Task>(DeleteBatchSize)(RedisPrefixScanner.cs:103), enumeratesserver.KeysAsync(pattern: pattern)under.WithCancellation(cancellationToken)(RedisPrefixScanner.cs:105-107), issues one delete per key without awaiting it (RedisPrefixScanner.cs:109), and awaits the group withTask.WhenAllwhenever the list reaches 512 (RedisPrefixScanner.cs:110-114), with a final flush for the remainder (RedisPrefixScanner.cs:117-118).Describe(server)(RedisPrefixScanner.cs:122-123),server.EndPoint?.ToString() ?? "unknown": a stable identifier for log output that tolerates an unknown endpoint.
- Why it's built this way: the comment at
RedisPrefixScanner.cs:98-102is the part to internalize. Deletes go out one key at a time because a multi-keyDELmust not span hash slots under Redis cluster policy, and StackExchange.Redis answers a cross-slot multi-key command by throwing rather than under-deleting. The keys behind one cache prefix hash to arbitrary slots, so batching them into a single command would fault the entire invalidation instead of speeding it up. Single-key deletes are always slot-safe, and the round-trip cost is contained by keeping 512 of them in flight rather than awaiting each one. The per-server try/catch is the same fail-soft posture the rest of this group takes:[Rubric §29, Resilience]assesses whether a partial infrastructure failure degrades instead of cascading, and here a failing node costs you the freshness of the keys it holds, nothing more. - Where it's used: exactly two call sites, one per Redis-capable adapter:
DistributedCacheService
.RemoveByPrefixAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:116-122) and HybridCacheService.RemoveByPrefixAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:173-179). Exercised against a real Redis by DistributedCacheServiceRedisTests and HybridCacheServiceRedisTests, which live in a separateMMCA.Common.Infrastructure.Redis.Testsproject over Testcontainers rather than in the unit loop. - Caveats / not-in-source:
KeysAsync(SCAN) is O(keyspace) on the Redis side. That is acceptable at invalidation cadence and is not a hot-path operation. Nothing here reports how many keys it removed, so the only evidence a scan ran at all is the absence of the warning hooks firing.
CacheKeyNamespace
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:41· Level 1 · class (internal sealed)
- What it is: the tiny behavioral half of CacheKeyPrefixOptions. It holds
a resolved prefix string and exposes one operation,
Qualify, that turns a caller-supplied cache key into the key actually stored. Living in the same file as the options class keeps the setting and its only interpretation together. - Depends on: CacheKeyPrefixOptions (only as the input to its
Fromfactory) andMicrosoft.Extensions.Options.IOptions<T>(BCL). Consumed by DistributedCacheService, HybridCacheService and RedisDistributedLock. - Concept introduced, the null object as a configuration default.
[Rubric §15, Best Practices & Code Quality]assesses whether the code avoids incidental complexity and defensive noise. Rather than making every call site ask "is a prefix configured?", the unconfigured case is represented by a real instance,None, whoseQualifyreturns the key unchanged. There is one branch (CacheKeyPrefix.cs:58) instead of a null check at every use.[Rubric §14, Testability]assesses whether behavior can be exercised without standing up the world: because the type is a plain object that both adapters take as an optional constructor parameter, a unit test passesnew CacheKeyNamespace("svc:")directly and asserts on the qualified key with no configuration system involved (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Caching/DistributedCacheServiceTests.cs:425, and the same shape throughoutHybridCacheServiceTests.cs:78-95). - Walkthrough: primary constructor
CacheKeyNamespace(string prefix)(CacheKeyPrefix.cs:41).None(CacheKeyPrefix.cs:44), astaticproperty initialised tonew(string.Empty). One shared, immutable instance meaning "leave keys alone".Prefix(CacheKeyPrefix.cs:47), get-only, initialised withprefix ?? string.Empty, so a null argument degrades to the no-op prefix rather than throwing later insidestring.Concat.From(IOptions<CacheKeyPrefixOptions>? options)(CacheKeyPrefix.cs:50-54), the composition-root factory. It tolerates a null options object (the?is deliberate: the registrations resolve it withGetService, notGetRequiredService, so an unboundCachesection yields null), readsoptions?.Value.KeyPrefix, and returnsNonewhen that is null or empty. Both "no configuration section" and "section present but empty prefix" land on the same no-op path.Qualify(string key)(CacheKeyPrefix.cs:57-58), expression-bodied: returnskeyunchanged whenPrefix.Length == 0, otherwisestring.Concat(Prefix, key). No separator is inserted, so the configured prefix must carry its own delimiter ("conference:", not"conference").
- Why it's built this way:
internal sealedbecause it is an implementation detail of the Infrastructure caching adapters, never part of the package's public surface. Splitting aninit-only options POCO from this behavior object keeps the configuration contract (bindable, public) separate from the runtime helper (internal, immutable, allocation-free on the common path). Resolving the prefix once at construction rather than per call also means the options are read a single time for the lifetime of the singleton. - Where it's used: built in three DI factories and passed as a constructor argument: to
DistributedCacheService
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:270, passed at:261), to RedisDistributedLock (DependencyInjection.cs:294-295), and to HybridCacheService in the opt-in hybrid path (DependencyInjection.cs:372, passed at:364). Inside each adapter it lands in a_keysfield with a?? CacheKeyNamespace.Nonefallback (DistributedCacheService.cs:30,HybridCacheService.cs:82). The in-process branch ofAddCaching()constructs MemoryCacheService with no namespace at all (DependencyInjection.cs:279-280), and the comment above it says why: the keyspace is private to the process. - Caveats / not-in-source: because
Qualifyis applied inside the adapter and not by Redis, keys written by any code path that bypasses ICacheService and talks toIDistributedCachedirectly would land unprefixed. Nothing in the framework does that today, but it is the invariant the design depends on.
CacheSettings
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:22· Level 1 · class (public sealed, options)
What it is: the
Cachesection's TTL policy: the default entry lifetime, the ceiling on the in-process copy of a two-level entry, and how long a cache miss waits for the per-key populate lock before giving up.Depends on: CacheOptions, whose
DefaultDurationseeds this class's own default (CacheSettings.cs:32), which is what puts it at Level 1, plusTimeoutfrom the BCL. Consumed by ICacheService implementations (HybridCacheService, DistributedCacheService).Concept introduced, fail-open configuration.
[Rubric §29, Resilience & Business Continuity]assesses whether a degraded dependency degrades the answer. The class doc states the invariant for the whole section: the cache is an optimization, never the system of record, so no value here can turn a cache outage or a slow populate into an error. A miss, an unreachable cache, or an expiredPopulateLockTimeoutall degrade the request to an uncached read that still runs the real handler and still answers correctly (CacheSettings.cs:10-15).[Rubric §12, Performance & Scalability]: the populate lock is stampede protection, and giving it a finite timeout trades that protection for a latency bound, which is exactly the tradeoff the remarks spell out (:50-56).[Rubric §15, Best Practices & Code Quality]: the defaults are not re-typed literals.DefaultDurationinitializes from CacheOptions.DefaultDuration(CacheSettings.cs:32, defined as 30 seconds atCacheOptions.cs:23), so the configured path and the hard-coded path cannot drift apart, and a host that configures nothing behaves as it did before the section existed (CacheSettings.cs:3-7).Concept, one configuration section read by two layers. The
Cachesection is shared three ways: this class, CacheKeyPrefixOptions for the key namespace, and the Application layer's QueryCachePipelineSettings, which reads the sameCache:PopulateLockTimeoutkey from a layer that cannot reference this assembly (CacheSettings.cs:16-20; the Application type declaresSectionName = "Cache"atMMCA.Common/Source/Core/MMCA.Common.Application/Settings/QueryCachePipelineSettings.cs:23and the sameTimeout.InfiniteTimeSpandefault at:29).[Rubric §3, Clean Architecture]: the dependency rule forbids Application from referencing Infrastructure, so the duplication is not an accident, it is the price of keeping the layer boundary intact while both views read one operator-facing key.Walkthrough: one static field and three
initproperties.SectionName = "Cache"(CacheSettings.cs:25).DefaultDuration(:32), the absolute TTL applied when a caller supplies no expiration. HybridCacheService uses it as the fallback TTL (HybridCacheService.cs:271) and DistributedCacheService does the same (DistributedCacheService.cs:64).LocalCacheDuration(:42), nullable, the ceiling on the L1 copy of a two-level entry so a replica that never sees an invalidation still re-reads L2 within the window. Null keeps the built-in 30-second ceiling (HybridCacheService.cs:54), and the effective L1 lifetime is the shorter of the ceiling and the entry's own TTL (:271-272). The single-level cache services have no L1 and ignore it.PopulateLockTimeout(:57), defaulting toTimeout.InfiniteTimeSpan: waiters block until the one request holding the lock has populated the entry. A finite value bounds that wait and lets the waiter proceed uncached, and the remarks note that zero or a negative value means no bound, exactly like the default (:50-56).- Both cache services take the options as an OPTIONAL constructor parameter and fall back to a fresh
instance (
HybridCacheService.cs:41,:89;DistributedCacheService.cs:24,:37), so a service constructed outside the container still gets the framework defaults.
Why it's built this way: registration guarantees
IOptions<CacheSettings>always resolves. When configuration is available the section is bound and validated (DependencyInjection.cs:246-249, fail-fast viaValidateOnStart, see ADR-070); when the parameterless overload is used,AddOptions<CacheSettings>()is still called so the defaults materialize instead of failing the host (:258, rationale at:236-241). The same values are then projected intoHybridCache's own option type through the options pipeline rather than theAddHybridCachecallback, because the callback has no service provider to read the bound section from, and the host's own hook still runs last so it can override anything the framework set (DependencyInjection.cs:344-361, ADR-077).Where it's used: DistributedCacheService and HybridCacheService receive it through
IOptions<CacheSettings>(DependencyInjection.cs:276,:379), and theHybridCacheOptionsprojection reads it at:349-358. Its defaults and binding are pinned byMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/CacheSettingsTests.cs.
ICacheService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:10· Level 3 · interface
- What it is: the Application layer's cache port. Get by key, set with an optional TTL, remove by
exact key, remove every key matching a prefix, increment a counter, and get-or-create with stampede
protection. It hides whether the backing store is Redis, a SQL distributed cache, an in-process
IMemoryCache, or a two-levelHybridCache. - Depends on: BCL types at the signature level (
Task,CancellationToken,TimeSpan?) plus KeyedSemaphoreStripe fromMMCA.Common.Shared.Concurrency, which the defaultGetOrCreateAsyncbody uses through the CacheKeyLocks holder (ICacheService.cs:1,ICacheService.cs:142-146). Implemented by MemoryCacheService, DistributedCacheService and HybridCacheService. - Concept introduced, dependency inversion for infrastructure.
[Rubric §3, Clean Architecture]assesses whether business code depends on abstractions while concrete technology sits at the edges. The Application layer defines this contract; the Infrastructure layer implements it. Handlers, decorators and the auth services never seeStackExchange.RedisorMicrosoft.Extensions.Caching; they program against this interface and the container decides which adapter they get. Second concept, the default interface member as a non-breaking extension point.[Rubric §15, Best Practices & Code Quality]assesses whether the codebase can absorb change without a ripple. Two members here ship with bodies,IncrementAsync(ICacheService.cs:59) andGetOrCreateAsync(ICacheService.cs:99). Every existing implementer keeps compiling and inherits working behavior, while a store with a better primitive overrides. That is how the ADR-029 counters, and later the whole ADR-077 two-level substrate, were added to a published package contract without a breaking change.[Rubric §12, Performance & Scalability]:RemoveByPrefixAsync(ICacheService.cs:42) is the member that makes scoped invalidation possible, so one mutation evicts a whole family of cached query results without enumerating individual keys. - Walkthrough: members in declaration order.
Task<T?> GetAsync<T>(string key, CancellationToken)(ICacheService.cs:17), returnsdefault/nullon a miss.Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken)(ICacheService.cs:26-30). A nullexpirationmeans "use the implementation's default TTL", resolved from CacheSettings on both distributed paths (whose own default is CacheOptions.DefaultDuration).Task RemoveAsync(string key, CancellationToken)(ICacheService.cs:36), single-key eviction.Task RemoveByPrefixAsync(string prefix, CancellationToken)(ICacheService.cs:42), bulk eviction of every key starting withprefix. This is what CachingCommandDecorator<TCommand, TResult> invokes after a successful mutation.async Task<long> IncrementAsync(string key, TimeSpan expiration, CancellationToken)(ICacheService.cs:59-65), a default implementation: read the current value aslong?(0 on a miss), add one, write it back withexpiration, return the new value. The doc (ICacheService.cs:44-58) is explicit about why it exists: rate-limit and brute-force counters (ADR-029) built from a rawGetAsync+SetAsyncpair let concurrent requests overwrite each other's increments and undercount, so the read-modify-write is at least centralised in one auditable place, and a store with a native counter primitive can override.async Task<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, Task<T>> factory, TimeSpan? expiration = null, CancellationToken)(ICacheService.cs:99-124), the second default implementation and the more interesting one. It null-guards the factory (ICacheService.cs:105), takes a lock-free fast path on a hit (ICacheService.cs:108-110), and only on a miss acquires the key's stripe from CacheKeyLocks (ICacheService.cs:112), re-reads under the lock (ICacheService.cs:116-118, the double-check that lets waiters see the value the winner just wrote), then runs the factory and stores its result (ICacheService.cs:120-121). That is the same read-through-with-stampede-protection shape CachingQueryDecorator<TQuery, TResult> applies to cacheable queries, made available to any caller.- CacheKeyLocks (
ICacheService.cs:142-146), the non-generic holder for the fixed-width KeyedSemaphoreStripe that the defaultGetOrCreateAsyncuses. It is deliberately a separate table from the query decorator'sQueryCacheKeyLocks(ICacheService.cs:137-140): different call sites over different keys, and sharing stripes would only widen the unrelated-key collisions striping already tolerates.
- Why it's built this way: keeping the port in
MMCA.Common.Applicationrather than Infrastructure is what lets the CQRS decorators, which also live in Application, depend on caching without dragging a Redis reference into the business layers. The optionalTimeSpan? expirationlets callers override the global TTL without a second overload. The two defaulted members follow the precedent ADR-077 names explicitly: a default interface member is how this package grows a capability that no consumer has to react to. Note the honest boundary theGetOrCreateAsyncremarks draw (ICacheService.cs:80-98): caching is unconditional there, so a failed Result would be cached, which is exactly why the caching decorators do NOT route through this member and keep their own read/execute/write sequence. - Where it's used: both caching decorators take
ICacheServiceby constructor injection (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:45,.../CachingCommandDecorator.cs:33). Outside the pipeline, LoginProtectionService callsIncrementAsyncfor failed logins and per-IP registrations (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:75and:130); PasswordResetTokenService uses it for both the per-email request counter and the token entries themselves (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:66,:88,:100); SoftDeletedUserCache writes the soft-deleted marker withSetAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:60), read back by SoftDeletedUserMiddleware (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:91); IdempotencyFilter resolves it per request (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:140) to store and replay the cached response record (IdempotencyFilter.cs:360and:435, default expiration 24 hours at:80); and OAuthControllerBase takes it as a constructor dependency (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:37). Exactly one implementation is live per host:AddCaching()registers one viaTryAddSingleton(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:262), andAddCommonHybridCache()replaces it (DependencyInjection.cs:366-367). The defaultGetOrCreateAsyncbody is covered by CacheServiceGetOrCreateTests. - Caveats / not-in-source:
IncrementAsyncis not atomic on any shipped implementation. The default body is a read-modify-write, and both distributed adapters override it with the same shape rather than RedisINCR, for the storage-format reason spelled out in the DistributedCacheService section. Stampede protection inGetOrCreateAsyncis likewise per process (ICacheService.cs:88-91): with several replicas over one shared cache the factory can still run once per replica, and a cluster-wide guarantee would need a distributed lock, which is deliberately not attempted here. AndGetOrCreateAsynchas no first-party caller outside tests today: it is a published extension point plus the member HybridCacheService overrides.
DistributedCacheService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:19· Level 4 · class (internal sealed partial)
- What it is: the out-of-process implementation of ICacheService, backed by
ASP.NET Core's
IDistributedCache(Redis in the deployed services, or a SQL Server distributed cache). Values cross the wire as UTF-8 JSON, every key is namespaced through CacheKeyNamespace, and prefix eviction runs Redis SCAN when anIConnectionMultiplexeris available. - Depends on: ICacheService (implemented),
CacheKeyNamespace (optional constructor parameter),
CacheSettings (optional, the TTL policy),
CacheOptions (every write) and RedisPrefixScanner (prefix
eviction). Externals:
Microsoft.Extensions.Caching.Distributed.IDistributedCache,ILogger<T>,System.Text.Json.JsonSerializer(BCL) andStackExchange.Redis.IConnectionMultiplexer(NuGet, optional). - Concept introduced, reaching past an abstraction that cannot express what you need.
[Rubric §7, Microservices Readiness]assesses whether shared state survives a module moving into its own process: a distributed cache is shared across replicas and across extracted services, so cached reads stay coherent when a service scales out. The design point worth internalising is thatIDistributedCachehas no key-enumeration API; you cannot ask it for every key starting with X. This adapter therefore takes the optional rawIConnectionMultiplexeralongside the abstraction and uses server-side SCAN to satisfyRemoveByPrefixAsync, accepting a Redis-specific dependency for exactly one operation while every other operation stays store-agnostic, which is also the[Rubric §12, Performance & Scalability]trade this class exists to make.[Rubric §13, Observability & Operability]assesses whether the system makes its own degraded states visible: when the multiplexer is absent the class does not silently swallow the missed invalidation, it warns once, so a dead eviction path shows up in logs instead of as unexplained stale data. - Walkthrough: primary constructor (
DistributedCacheService.cs:19-24),IDistributedCache cacheandILogger<DistributedCacheService> loggerrequired,IConnectionMultiplexer? connectionMultiplexer = null,CacheKeyNamespace? keyNamespace = nullandIOptions<CacheSettings>? cacheSettings = nulloptional. The class ispartialso the[LoggerMessage]source generator can emit its log methods._keys(DistributedCacheService.cs:30), the resolved CacheKeyNamespace, defaulting toCacheKeyNamespace.None._settings(DistributedCacheService.cs:37), the boundCachesection ornew CacheSettings()when a host built the service without one (direct construction in tests). The fallback reproduces CacheOptions.DefaultDurationexactly (DistributedCacheService.cs:32-36), so an unconfigured host writes the TTL it always did.GetAsync<T>(DistributedCacheService.cs:40-45), fetches the rawbyte[]viacache.GetAsync(_keys.Qualify(key), ...); returnsdefaulton null (a miss), elseDeserialize<T>.SetAsync<T>(DistributedCacheService.cs:53-66), serializes to bytes and writes withcache.SetAsync(_keys.Qualify(key), bytes, CacheOptions.Create(expiration ?? _settings.DefaultDuration), ...)(DistributedCacheService.cs:64). That single line is where the caller's optionalTimeSpan?, the configured default and the hard-coded framework default all collapse into oneDistributedCacheEntryOptions.RemoveAsync(DistributedCacheService.cs:69-70), expression-bodied passthrough on the qualified key._noMultiplexerWarned(DistributedCacheService.cs:73), anintflag flipped once viaInterlocked.Exchangeso the missing-multiplexer warning fires exactly once per process rather than on every mutating command.RemoveByPrefixAsync(DistributedCacheService.cs:100-123). IfconnectionMultiplexeris null (DistributedCacheService.cs:102) it logs the no-op once, guarded byInterlocked.Exchange(ref _noMultiplexerWarned, 1) == 0(DistributedCacheService.cs:107-108), and returns; entries then expire on TTL alone. Otherwise it delegates the whole scan to RedisPrefixScanner.RemoveMatchingAsync(DistributedCacheService.cs:116-122), passing the namespaced pattern$"{_keys.Qualify(prefix)}*"(DistributedCacheService.cs:118, note the prefix is namespaced too, which is the entire reason the namespace lives here rather than inRedisCacheOptions.InstanceName), a rawKeyDeleteAsyncas the per-key delete over a lazily resolvedIDatabase(DistributedCacheService.cs:114and:120, so a host whose multiplexer reports no scannable server never asks for a database), and its own two log hooks (DistributedCacheService.cs:120-121).IncrementAsync(DistributedCacheService.cs:145-151), an override of the ICacheService default that keeps the same read-modify-write shape. The remarks (DistributedCacheService.cs:126-144) are the important read. RedisINCRwould be atomic, which is what the member was added for, butINCRwrites a Redis string whileStackExchangeRedisCachestores every entry as a Redis hash (absexp/sldexp/data, read back withHMGET). Mixing the two at one key makes the next read fail withWRONGTYPE, which surfaces as a 500 on whatever endpoint owns the counter (registration and login, in the ADR-029 case). A counter has to live in the same storage format as the reads that consult it, so readability was chosen over atomicity.Deserialize<T>/Serialize<T>(DistributedCacheService.cs:153-157), private static JSON helpers:SerializeToUtf8Bytes(value)andDeserialize<T>(bytes)!(null-forgiving, since the BCL signature is nominally nullable).LogPrefixEvictionNoMultiplexer/LogPrefixEvictionNoServer/LogPrefixEvictionServerFailed(DistributedCacheService.cs:159-166),[LoggerMessage]Warning-level partial methods. The first names the fix explicitly ("Register a Redis client (AddRedisClient) to enable prefix eviction"), and the third states the blast radius ("the remaining servers are still processed, so entries on this one are bounded only by their TTL"), which is the difference between a log line and an actionable one.
- Why it's built this way: UTF-8 JSON keeps cached payloads engine-agnostic and inspectable from
any Redis client. The optional multiplexer is the pragmatic compromise the
ADR-006 /
ADR-008 extraction path
demands: the cache contract must work whether the deployment has Redis (full prefix eviction) or only
a fallback distributed store (single-key operations), so prefix eviction degrades to a no-op rather
than throwing, and the 30-second TTL becomes the staleness backstop. Warn-once keeps that degradation
from being invisible without flooding the log. Lifting the scan into
RedisPrefixScanner came with the two-level cache: two adapters that evict
differently should still share one eviction algorithm.
internal sealed partial:partialfor the generated log methods,internal sealedbecause it is only ever resolved through the ICacheService registration. - Where it's used: selected by
AddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:262-281). TheTryAddSingleton<ICacheService>factory builds this implementation only when anIDistributedCacheis present and is not the defaultMemoryDistributedCache(DependencyInjection.cs:265), resolving the optionalIConnectionMultiplexer(DependencyInjection.cs:267), anILoggerwith aNullLoggerfallback (DependencyInjection.cs:268-269), the CacheKeyNamespace (DependencyInjection.cs:270) and the optional CacheSettings (DependencyInjection.cs:276); otherwise it falls back to MemoryCacheService. Downstream it is consumed only through the interface. Covered by DistributedCacheServiceTests and, against a real Redis, DistributedCacheServiceRedisTests. - Caveats / not-in-source: all seven deployed service hosts call
AddCommonHybridCache()inside a Redis-conditional block (for exampleMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:149-152,MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:94), so this adapter is not the one the container hands out in those hosts: it is the default for any host that registers a real distributed cache and does not opt in.IncrementAsynchere is not atomic (see the walkthrough); ADR-026 records the possible undercount as accepted rather than outstanding.
HybridCacheService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:36· Level 4 · class (internal sealed partial)
- What it is: the two-level implementation of ICacheService, backed by
Microsoft.Extensions.Caching.Hybrid.HybridCache: an in-process L1 in front of the host's registeredIDistributedCacheL2, with serialization, L1 promotion and stampede protection supplied by the platform. It is opt-in per host throughAddCommonHybridCache(HybridCacheService.cs:13-16). - Depends on: ICacheService (implemented),
CacheKeyNamespace (optional constructor parameter),
CacheSettings (optional, the TTL and L1
ceiling), CacheOptions (the value those settings default to) and
RedisPrefixScanner (prefix eviction). Externals:
HybridCache/HybridCacheEntryOptions/HybridCacheEntryFlags(NuGet),ILogger<T>andStackExchange.Redis.IConnectionMultiplexer(optional). - Concept introduced, two serialization formats must never share one keyspace.
[Rubric §8, Data Architecture]assesses whether stored data has one owner and one shape;[Rubric §15, Best Practices & Code Quality]assesses whether a change can be rolled out without a coordinated flag day. Every key this service writes carries ahc:segment inside the configured prefix,{prefix}hc:{key}(HybridCacheService.cs:20,:48,:261), which is the structural form of theWRONGTYPElesson recorded on DistributedCacheService.IncrementAsync.HybridCachewrites its own payload layout, not the UTF-8 JSON the older adapter writes, so letting the two meet at one key would reproduce that production failure at every key rather than at one counter. With the keyspaces disjoint, an entry written by the other service is simply invisible to this one (a clean miss) and vice versa, so two hosts can share one Redis without either being able to read a payload it cannot parse (HybridCacheService.cs:19-28). ADR-077 adds the case the code comment does not spell out, a rolling deploy where both builds serve traffic against one Redis (077-hybridcache-substrate.md:54-56), records the rejected alternative, a payload discriminator in one keyspace, and why "impossible" beat "unlikely" (077-hybridcache-substrate.md:58-61); it also states the consequence this code implements, that prefix eviction "scans thehc:keyspace and nothing else" (077-hybridcache-substrate.md:66-67).[Rubric §12, Performance & Scalability]: the whole point of L1 is removing a network hop and a JSON deserialize from every read of a small hot value. - Walkthrough: primary constructor (
HybridCacheService.cs:36-41), the same shape as the distributed adapter but overHybridCache hybrid.KeyspaceSegment(HybridCacheService.cs:47),internal const string="hc:", applied inside the configured namespace.LocalCacheDefault(HybridCacheService.cs:54),internal static readonly TimeSpan= 30 seconds. It is both the default L1 lifetime and the ceiling applied to every entry, andAddCommonHybridCacheseedsHybridCacheOptionsfrom the same field when the host configured noCache:LocalCacheDuration(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:358).ReadOnlyOptions(HybridCacheService.cs:62-65) andCounterReadOptions(HybridCacheService.cs:71-76), two static option objects. The first setsHybridCacheEntryFlags.DisableUnderlyingData, which tellsHybridCachenot to invoke the factory and not to write anything, so a miss stays a miss while an L2 hit is still promoted into L1. The second addsDisableLocalCacheRead | DisableLocalCacheWritefor the counter path._keys(HybridCacheService.cs:82),_settings(HybridCacheService.cs:89) and_noMultiplexerWarned(HybridCacheService.cs:92), identical in role to their counterparts on DistributedCacheService.GetAsync<T>(HybridCacheService.cs:103-122), callshybrid.GetOrCreateAsyncwith astaticno-op factory andReadOnlyOptions(HybridCacheService.cs:109-114), which is how you perform a plain read through an API whose primary shape is get-or-create. It is fail-soft (HybridCacheService.cs:95-102): any exception that is notOperationCanceledExceptionis logged at warning and answered as a miss (HybridCacheService.cs:116-121), and the offending entry is dropped best-effort throughSelfHealAsyncso the next write repopulates it instead of the process failing the same read forever.SetAsync<T>(HybridCacheService.cs:132-137), one call tohybrid.SetAsyncwith the optionsWriteOptions(expiration)builds.RemoveAsync(HybridCacheService.cs:141-142),hybrid.RemoveAsync, which clears this process's L1 copy along with the L2 entry.RemoveByPrefixAsync(HybridCacheService.cs:161-180). After the same warn-once no-multiplexer guard (HybridCacheService.cs:163-171) it runs RedisPrefixScanner once, over this service's own pattern$"{HybridKey(prefix)}*"(HybridCacheService.cs:175), which is the one keyspace this service writes and therefore the one it evicts. The per-key delete routes back throughhybrid.RemoveAsyncrather than a rawKeyDeleteAsync(HybridCacheService.cs:176): a raw delete would clear L2 and leave this process's own L1 copy serving the value it just invalidated (HybridCacheService.cs:151-155).IncrementAsync(HybridCacheService.cs:206-227), a read-modify-write like the distributed adapter's, and deliberately not routed through this class's ownGetAsync/SetAsyncbecause both legs must bypass L1 (HybridCacheService.cs:210-215reads withCounterReadOptions,:220-225writes with the two disable flags). The reason (HybridCacheService.cs:192-200) is the sharpest argument in this group: a counter is the one value whose correctness depends on every replica seeing the same number, so an L1 copy would let a process read its own stale count and write it back, and a brute-force counter could then be held near its starting value indefinitely by a steady stream of attempts against one replica. That is a security control silently weakened by a cache optimization, so[Rubric §11, Security]is the category that decided this member, not §12. Faults are also not swallowed here, unlikeGetAsync(HybridCacheService.cs:201-204): a counter that silently reads as zero would reset the limit it exists to enforce.GetOrCreateAsync<T>(HybridCacheService.cs:238-255), an override of the interface default that hands the work toHybridCache's own primitive, which folds the double-check and the stampede protection into one call and additionally deduplicates concurrent callers before they reach L2. The factory is passed as state rather than captured (HybridCacheService.cs:248-251), so the delegate staysstaticand no closure is allocated per call.HybridKey(HybridCacheService.cs:260),WriteOptions(HybridCacheService.cs:269-280) andSelfHealAsync(HybridCacheService.cs:290-300), the private helpers.WriteOptionsis where both dials meet:ttl = expiration ?? _settings.DefaultDuration(HybridCacheService.cs:271),localCeiling = _settings.LocalCacheDuration ?? LocalCacheDefault(HybridCacheService.cs:272), andLocalCacheExpiration = ttl < localCeiling ? ttl : localCeiling(HybridCacheService.cs:277), so a long-lived entry does not sit in another replica's memory for its whole TTL after an invalidation that process never saw.
- Why it's built this way:
ADR-077 is the record. Opt-in
rather than default keeps the release non-breaking: a host that never calls
AddCommonHybridCachegets a byte-identical registration to before, and a memory-only host would gain nothing from an L1 in front of an L1 anyway (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:314-319). The registration deliberately usesRemoveAll+Addrather thanTryAddso it wins in either call order (DependencyInjection.cs:364-367), with the honest warning thatRemoveAlldoes not distinguish the framework's registration from a host's own custom ICacheService (DependencyInjection.cs:328-333).[Rubric §29, Resilience]shows up in the fail-soft read: the cache is an optimization, never the system of record, so an unreadable entry costs a database round trip rather than a failed request. - Where it's used: registered only by
AddCommonHybridCache(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:340-383), which all seven deployed service hosts call inside aGetConnectionString("redis")conditional (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:151,MMCA.ADC.Engagement.Service/Program.cs:113,MMCA.ADC.Identity.Service/Program.cs:133,MMCA.ADC.Notification.Service/Program.cs:116,MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:94,MMCA.Store.Sales.Service/Program.cs:111,MMCA.Store.Identity.Service/Program.cs:100). Everything downstream still talks to ICacheService and is unaware. Covered by HybridCacheServiceTests, the registration semantics by AddCommonHybridCacheTests, and the storage format against a real Redis by HybridCacheServiceRedisTests. - Caveats / not-in-source: replica L1 staleness after an invalidation is bounded by the local
expiration (30 seconds by default,
Cache:LocalCacheDurationto change it), not by the eviction, because only the evicting process's L1 is cleared (HybridCacheService.cs:29-34). That is the accepted cost of the L1 hit rate, and it is the same order as the 5-second delayed re-invalidation CachingCommandDecorator<TCommand, TResult> already performs (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:45and:79). Because the keyspaces are disjoint, a host that switches substrate starts cold: entries another ICacheService implementation wrote are invisible here and age out on their own TTL, which ADR-077 records as a cost rather than a correctness problem (077-hybridcache-substrate.md:61-65).
MemoryCacheService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Caching·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:18· Level 4 · 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, object>tracking table so it can honorRemoveByPrefixAsync, the one capability the BCL memory cache lacks. The cache and that table are two structures that have to agree, so every mutation of a key runs under that key's lock stripe (MemoryCacheService.cs:8-17). - Depends on:
IMemoryCache/MemoryCacheEntryOptionsandConcurrentDictionary<TKey, TValue>(both BCL), plus KeyedSemaphoreStripe fromMMCA.Common.Shared.Concurrency(MemoryCacheService.cs:4,:38) for the per-key mutual exclusion. Implements ICacheService and overrides neither default member, so it inherits the non-atomicIncrementAsyncand the stripe-plus-double-checkGetOrCreateAsync. It takes no CacheKeyNamespace and no CacheSettings at all. - Concept introduced, a shadow index to back-fill a missing API.
[Rubric §12, Performance & Scalability]assesses cheap reads and sound invalidation; an in-process cache is the lowest-latency option available but is not shared across instances, so it is correct for a single-process monolith or for genuinely per-instance data and wrong for anything else. The teachable mechanic is the shadow index:IMemoryCacheis a black box with no key listing, so the service mirrors every live key into_keysand keeps that mirror honest with a post-eviction callback, so an entry that expires or is dropped under memory pressure prunes its own tracking record instead of leaking.[Rubric §12, Performance & Scalability]: it presents the identical ICacheService surface as the distributed adapters, so swapping backends changes nothing for callers. Second concept, an invariant that write ordering cannot buy you.[Rubric §15, Best Practices & Code Quality]assesses everyday craftsmanship, including whether comments explain why rather than what. TheSetAsyncremarks (MemoryCacheService.cs:55-63) are the worked example: with two structures to update, track-then-write lets a concurrent removal drop the tracking record between the two steps, and write-then-track lets a removal run entirely between them; both leave a live entry nothing can find. Neither order closes the window, so the class buys the invariant with mutual exclusion instead, and says so in the code rather than leaving the next reader to rediscover it. - Walkthrough: primary constructor injection (
MemoryCacheService.cs:18),IMemoryCache cache._keys(MemoryCacheService.cs:31),new ConcurrentDictionary<string, object>(StringComparer.Ordinal). The value is load-bearing: it is the tracking token of the cache entry the record belongs to, a plainobjectcompared by reference (MemoryCacheService.cs:20-30). It exists so a post-eviction callback, which necessarily runs after its own entry may already have been superseded, can remove only its OWN record and never the record of a newer live entry.Ordinalcomparison matches the ordinal prefix test below._keyLocks(MemoryCacheService.cs:38), a KeyedSemaphoreStripe serializing the paired mutation of the cache and_keysfor one key. It is per instance rather than static (MemoryCacheService.cs:33-37): the tracking table belongs to this service instance, so two instances have nothing to serialize against each other.GetAsync<T>(MemoryCacheService.cs:41-52), callscache.TryGetValue(key, out var stored)and then type-checks the stored object withstored is T typed(MemoryCacheService.cs:46) before returning it, wrapped inTask.FromResult(there is no real async work;IMemoryCacheis synchronous). It takes no lock: it touches only the cache. The pattern match is deliberate (MemoryCacheService.cs:43-45): the genericTryGetValue<T>overload performs an unchecked(T)storedcast and throwsInvalidCastExceptionwhen a key is reused under a differentT, so matching on the stored object turns a type mismatch (or a stored null) into a clean miss.SetAsync<T>(MemoryCacheService.cs:64-106), the method that establishes the invariant the class rests on. It buildsMemoryCacheEntryOptions(MemoryCacheService.cs:70) and setsAbsoluteExpirationRelativeToNowonly whenexpiration.HasValue(MemoryCacheService.cs:72-75), so there is no 30-second floor on this path: an unset TTL means no time-based expiry, unlike the distributed paths. It mints this entry's identity,var token = new object()(MemoryCacheService.cs:78), and registers the post-eviction callback (MemoryCacheService.cs:93-99) with that token as the callback state. The callback body skipsEvictionReason.Replaced(MemoryCacheService.cs:97) and removes through theKeyValuePairoverload,_keys.TryRemove(new KeyValuePair<string, object>(evictedKey.ToString()!, state!)), which deletes the record only while the tracked value is still this entry's own token. The comment (MemoryCacheService.cs:80-92) explains the shape: the callback stays deliberately lock-free becauseIMemoryCachequeues it to the thread pool, and waiting on a stripe from a pool thread would stall the pool behind whichever caller holds it; running lock-free means it can land when the key already carries a newer live entry, so the token check is what stops it untracking an entry that is still cached (live but invisible toRemoveByPrefixAsync, clearable only by its TTL). Only then does the write happen, under the key's stripe:using (await _keyLocks.AcquireAsync(key, cancellationToken)...)(MemoryCacheService.cs:101),cache.Set(key, value, options)(MemoryCacheService.cs:103),_keys[key] = token(MemoryCacheService.cs:104).RemoveAsync(MemoryCacheService.cs:110-117), the same stripe and the same order (MemoryCacheService.cs:109): acquire (:112),cache.Remove(:114), then_keys.TryRemove(key, out _)(:115).RemoveByPrefixAsync(MemoryCacheService.cs:128-138), iterates_keys.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal))(MemoryCacheService.cs:130) and removes each key from both stores under its own stripe (MemoryCacheService.cs:132-136). Two details from the remarks (MemoryCacheService.cs:120-127): the candidate list is a snapshot, becauseConcurrentDictionary.Keysalready copies, so it is enumerated outside every lock; and each stripe is released before the next one is taken, never accumulated across the loop, because distinct keys can map to the same stripe and to different stripes in a different relative order, so holding several at once would let two prefix removals block on each other and deadlock. This is what lets the in-process backend satisfy the same prefix-eviction contract Redis gets from SCAN.
- Why it's built this way: a parallel key index is the only way to give
IMemoryCachea prefix-removal capability without replacing it, and the two guards on the callback (skipReplaced, and match the token) are what keep that index from drifting in either direction: a naive key set would accumulate phantom keys as entries expired, while a naive callback would delete records for entries that are still live. The stripe then covers what neither guard can, the window between the two writes that any single-threaded reading of the code hides. Striping rather than a semaphore per key is a bounded-memory choice made once in KeyedSemaphoreStripe and reused here. The class isinternal sealedbecause it is only ever resolved through the ICacheService registration. - Where it's used: the fallback branch of
AddCaching()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:280).AddCaching()always callsAddMemoryCache()first (DependencyInjection.cs:231), so when no real distributed cache is registered this is the ICacheService the container hands out, and a host with no Redis behaves as a single-instance cached monolith with the full interface intact. Consumed through the interface by both CQRS caching decorators, LoginProtectionService, PasswordResetTokenService, SoftDeletedUserCache and IdempotencyFilter. Unit-tested by MemoryCacheServiceTests, which pins the concurrency behavior deterministically rather than racing for it: the test takes the key's own stripe first, then asserts that aSetAsyncand aRemoveByPrefixAsyncboth park on it (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Caching/MemoryCacheServiceTests.cs:187) and thatRemoveAsyncwaits on the same stripe asSetAsync(MemoryCacheServiceTests.cs:218). - Caveats / not-in-source: the cache is per-process, so two replicas hold independent and
potentially divergent copies until each entry's TTL or an explicit eviction reconciles them. That is
why the distributed adapters exist for scaled-out deployments, and why
ADR-026 lists per-replica memory
mode as a trade-off rather than a supported multi-replica posture
(
026-caching-strategy.md:163)._keysis unbounded in the sense that only eviction prunes it, so a cache key embedding a high-cardinality value grows the table alongside the cache itself. Two limits of the locking are worth knowing: the stripe is per service instance, so the invariant holds for the singleton the container registers and not across two hand-constructed instances sharing oneIMemoryCache; andRemoveByPrefixAsyncworks from a snapshot, so a key written after the snapshot is taken is simply not a candidate for that call. Finally, the per-key stripe and the tracking token are documented only in the source comments cited above, not in a decision record.
⬅ Authentication & Authorization • Index • Notifications (Push + In-App Inbox + Email) ➡