Architecture Decision Record
ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge
Status
Accepted (2026-06-27, amended 2026-07-10, 2026-07-23, 2026-07-25, 2026-08-14). Amended by
ADR-077 (2026-08-13): Tier 1's substrate gains a third, opt-in
implementation (HybridCacheService, L1 plus L2) writing under a disjoint hc: keyspace. The default
path is unchanged; see the Revision (2026-08-13) below. Revised 2026-08-18: Tier 2 stops being
per-process. An OutputCacheEvictionRequested integration event lets a mutation in one service evict
another service's output cache over the existing outbox, broker and inbox path, per tag and
best-effort, on a new MMCA.Common.OutputCache meter. Tier 1 is untouched by that change, which is the
mirror image of ADR-077's Tier-1-only amendment; see the Revision (2026-08-18) at the end. Revised
2026-08-23: the counter trade-off now records that ICacheService's own <remarks> still anticipates a
Redis INCR override that no implementation provides, so a reader who starts at the interface is not
left with the opposite conclusion; anchors re-verified throughout. Amended 2026-09-01: an optional
third tier on the client is recorded, IUiReadCache, a per-circuit read-through cache over the API
client whose keys are the relative URL (path plus the full query) so they line up with Tier 2's
QueryKeys = "*" policy. It is shipped and DI-registered, but opt-in per UI service and passed by no
consumer app today; both server tiers are unchanged. See the Revision (2026-09-01) at the end.
Revised 2026-09-03: the Redis wiring moved into the framework. All seven ADC and Store services
reach Redis through MMCA.Common.Aspire's AddRedisCaching() / AddRedisOutputCaching() wrappers
rather than Aspire's integrations directly, because the raw integrations register an untagged health
check that gated readiness (ADR-025), and all seven also opt into ADR-077's HybridCacheService, so
the memory-or-distributed auto-swap is the no-Redis path rather than the production one. Tier 2 gains
a cache-bypassing-roles policy overload. See the Revision (2026-09-03) at the end.
Revised 2026-09-07 (Tier-1 query cache keys carry the caller for a caller-scoped query, the public
output-cache policy varies by resolved tenant and reads roles through the one shared helper, and the
cache key prefix is namespaced per application by default).
Revised 2026-09-25 (all seven services call both Redis wrappers unconditionally and reach
AddCommonHybridCache() only when the redis connection string is configured, today through the
framework's guarded AddCommonHybridCacheWhenRedisConfigured(...) helper; ADC Conference's bypass
overload covers eleven of its twelve policies; the service and substrate anchors are refreshed). See
the Revision (2026-09-25) at the end.
Also revised 2026-07-24, 2026-07-28, 2026-08-01, 2026-08-07 and 2026-08-31 (substrate corrections and
anchor refreshes; see those sections). Revised 2026-10-01 (the hybrid opt-in moved into the guarded
framework helper AddCommonHybridCacheWhenRedisConfigured(...), and the client tier's store became
generation-checked). See the Revision (2026-10-01) at the end.
Revised 2026-10-06: the client tier invalidates after any write that may have landed, not only after
a successful one, the default TTL is host-configurable through Cache:DefaultDuration, and ADC
Conference's eleven-of-twelve count is scoped to its public-read policies beside the built-in
ConferenceCache policy. See the Revision (2026-10-06) at the end.
Revised 2026-10-07: with Redis configured, the 30-second local expiration is also the cross-replica
backstop, because a HybridCacheService removal does not reach other replicas' L1 copies; anchors
refreshed after the v1.233.0 release.
Context
The framework needs caching in two distinct places. Inside the application pipeline, query results
are memoized and invalidated on mutation (the Caching decorators of ADR-014, keyed by
IQueryCacheable / ICacheInvalidating), and a brute-force counter backs login protection
(ADR-019). At the HTTP edge, public/anonymous read endpoints want their responses served without
touching a handler at all. These two needs have different lifetimes, different invalidation models,
and different failure tolerances, so a single cache primitive does not fit both.
The application-pipeline need also has to survive the monolith-to-services move (ADR-006/008): the same handler code runs in a single process (where an in-process cache is correct and cheapest) and in a scaled-out container app (where each replica's private memory cache would drift and a shared store is needed). We did not want handlers to know which deployment they are in.
This ADR records the caching decisions themselves. ADR-014 owns the decorator ordering and the marker interfaces; this ADR owns the substrate, the two tiers, and the invalidation semantics they sit on.
Decision
Cache in two tiers, each with its own substrate.
Tier 1: an application ICacheService substrate with a runtime memory-or-distributed swap
- One abstraction.
ICacheService(MMCA.Common.Application/Interfaces/ICacheService.cs) exposesGetAsync/SetAsync/RemoveAsync/RemoveByPrefixAsync. Application code (the ADR-014 Caching decorators,LoginProtectionService) depends only on this interface, never on a concrete cache or on Redis. - The backing store is chosen at startup, not in code (amended by ADR-077).
AddCaching(configuration)(MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:26, where the configuration parameter is optional, called fromAddInfrastructureatMMCA.Common.Infrastructure/DependencyInjection.cs:140) registersDistributedCacheServicewhen a realIDistributedCacheis present (one that is not the in-memoryMemoryDistributedCache, i.e. Aspire registered Redis), and otherwiseMemoryCacheService. The monolith with no distributed cache gets in-process caching for free; a host that wires Redis gets the distributed store with no application-code change. This is the same "monolith now, scale or extract later" extension point asInProcessMessageBusvsBrokerMessageBus(ADR-003/006/008). Since ADR-077 a third implementation exists,HybridCacheService(L1 in-process plus L2 distributed), and it is the one substrate that is not auto-selected: a host opts into it explicitly withAddCommonHybridCache(...)(MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:139), which replaces the registration this call made. That call, unlike the Redis wrappers in Trade-offs below, has no guard of its own, so the framework pairs it with a guarded helper,AddCommonHybridCacheWhenRedisConfigured(configuration, connectionName = "redis")(MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:200), which calls it only when the named connection string is non-empty (:206-208) and otherwise leaves the registration untouched. Every one of the seven ADC and Store services opts in through that helper: ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:203), Notification (.../MMCA.ADC.Notification.Service/Program.cs:119), Engagement (.../MMCA.ADC.Engagement.Service/Program.cs:116), Identity (.../MMCA.ADC.Identity.Service/Program.cs:138), Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:108), Sales (.../MMCA.Store.Sales.Service/Program.cs:113) and Identity (.../MMCA.Store.Identity.Service/Program.cs:102). SoHybridCacheServiceis the liveICacheServicewherever Redis is configured, and the two-way swap above is what runs where it is not: local runs, tests, and any host that wires no Redis. - Prefix invalidation, implemented per store.
IMemoryCachehas no key-enumeration API, soMemoryCacheServicetracks live keys in aConcurrentDictionary(kept in sync by a post-eviction callback) to supportRemoveByPrefixAsync.DistributedCacheServiceserializes values as UTF-8 JSON and, when anIConnectionMultiplexeris resolvable, enumerates matching Redis keys to delete them; when no multiplexer is registered it treats prefix removal as a no-op and relies on the TTL backstop below. - A short default TTL bounds staleness. The 30 seconds is one figure with one home:
CacheOptions.DefaultDuration(= TimeSpan.FromSeconds(30),MMCA.Common.Infrastructure/Caching/CacheOptions.cs:23), a bareTimeSpanso that an implementation which does not speakDistributedCacheEntryOptions(HybridCacheService, whose entry options are its own type, ADR-077) defaults to the same policy instead of hard-coding a second 30 seconds.CacheOptions.DefaultExpirationis that duration expressed as an absolute expiration (AbsoluteExpirationRelativeToNow = DefaultDuration,MMCA.Common.Infrastructure/Caching/CacheOptions.cs:28-31); callers may override per entry. A host may also move the default without code:CacheSettings.DefaultDuration(MMCA.Common.Infrastructure/Caching/CacheSettings.cs:32, theCache:DefaultDurationkey) defaults toCacheOptions.DefaultDuration, is bound byAddCachingwhen it receives configuration (MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:43-46), and is what the cache services apply to an entry whose caller supplies no expiration (for exampleMMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:72). The same section carries an optional L1 ceiling forHybridCacheService(CacheSettings.cs:42) and the populate-lock timeout (:57). The short default means even a prefix invalidation that cannot reach every replica (memory mode, or distributed mode without a multiplexer) self-heals within seconds. The same holds forHybridCacheServicewith Redis configured: a removal clears L2 and this process's L1, but other replicas' L1 copies keep serving the removed value until their local expiration lapses, for up toCache:LocalCacheDuration(30 seconds by default,MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:66; the L1 lifetime is the shorter of the entry TTL and that ceiling,:330-335) (:192-195forRemoveAsync,:210-212forRemoveByPrefixAsync).
Tier 2: an HTTP output-cache edge
- The pipeline always enables it; policies are opt-in per host.
MMCA.Common.APIcallsapp.UseOutputCache()in the shared middleware pipeline, which is a builder of named steps rather than a run of inline calls: the step is registered atMMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:132-133under the nameMiddlewarePipelineStepNames.OutputCache(MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:65). The pipeline ships no policies. Each service registers its ownAddOutputCache(...): most declare aNoCachebase policy (Identity, Sales, Engagement, Notification), while the read-heavy public services declare real cacheable policies. ADC Conference and Store Catalog are the adopters today, with named policies and[OutputCache]on their public read controllers. - Public-read policies cache authenticated requests too (amended by ADR-040). The framework UI
attaches a Bearer token to every outgoing API request, including reads of
[AllowAnonymous], user-independent endpoints, and ASP.NET Core's built-in default output-cache policy refuses to serve or store a cached response for any request carrying anAuthorizationheader. So the adopters do not cache only anonymous traffic: they register their public-read policies throughOutputCacheOptions.AddPublicEndpointPolicy(name, expiration, tags)(MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:20), backed byPublicEndpointOutputCachePolicy(MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:44), whoseCacheRequestAsynccaches GET/HEAD regardless of the caller's auth state (MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:81-87, via the private helpersIsCacheableRequest/IsBypassedCalleratMMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:134and:141). ADC Conference and Store Catalog register these policies on their public read controllers; ADR-040 records that the built-in default policy served 0% of logged-in (bearer-carrying) traffic on conference day. - One adopter exempts a privileged role from the cache.
AddPublicEndpointPolicyhas a second overload taking cache-bypassing roles,AddPublicEndpointPolicy(name, expiration, bypassRoles, tags)(MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:34): a caller in one of the named roles skips the cache entirely, no lookup and no storage, and always reads fresh. Store Catalog's four policies use the plain overload and cache every caller alike (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:152,:153,:159,:162). ADC Conference uses the bypass overload for eleven of its twelve public-read policies (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:269-270,:276-283and:300), passing anadminBypassRolesarray projected fromConferenceReadAudience.PrivilegedRoles(:268), the same list the API layer's visibility checks read, because those roles receive an elevated payload (unpublished rows) that must never be stored under a key the public shares. So "cache authenticated requests too" holds for the attendee and anonymous traffic that is the conference-day load, and stops at the privileged reader. The one public-read policy with no bypass list,NowNextCache(:286), returns the same payload to every role. Conference also registers a thirteenth named policy,ConferenceCache, through the plain built-inAddPolicy(:237-238), not throughAddPublicEndpointPolicy: it keeps the built-in default semantics (no caching for anAuthorization-carrying request) because a permission-gated controller also references it, and a public-read policy must never back a permission-gated endpoint (:232-236). - The output-cache store itself is Redis-backed wherever a service runs more than one replica.
AddOutputCachedefaults to a per-replica in-memory store, so a tag eviction reaches only the replica that served the mutation. Both adopters register a shared store through the framework wrapperbuilder.AddRedisOutputCaching()(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:91), which holds the framework's singleAddStackExchangeRedisOutputCache(...)call (:99) and no-ops when the named connection string is blank (:94). Both adopters call the wrapper unconditionally and lean on that no-op: ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:195) and Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:99).AddOutputCacheregisters its store withTryAdd, so the explicit registration wins regardless of call order, and with no Redis configured the in-memory store still applies, which is correct at a single replica. ADR-040's 2026-07-25 amendment makes the shared store the expected posture for any multi-replica deployment. The practical effect is that both tiers ride the same Redis instance: Tier 1 throughIDistributedCache, Tier 2 through the output-cache store.
An optional third tier on the client (amended 2026-09-01)
Two tiers are what this ADR decides. A third one exists in the framework as a capability a UI host may switch on, and it is recorded here so a reader is not surprised by it in the UI package.
IUiReadCachecaches in front of the API client, not in front of a handler. The interface (MMCA.Common.UI/Services/Caching/IUiReadCache.cs:32) isGeneration(:40),TryGetFresh(:50),Set(:59, plus a generation-checked overload at:70),InvalidatePrefix(:78) andClear(:85); the default implementation (MMCA.Common.UI/Services/Caching/UiReadCache.cs:18) is a lock-guarded dictionary with lazy expiry, registered scoped byAddUIShared(MMCA.Common.UI/DependencyInjection.cs:76,TryAddScoped), which is one instance per Blazor Server circuit and one per app lifetime on WebAssembly and MAUI.- The key is the relative URL, path plus the full query, deliberately the same key shape Tier 2
uses.
PublicEndpointOutputCachePolicysetsCacheVaryByRules.QueryKeys = "*"(MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:96), so mirroring it means the two layers agree on what "the same read" is: a filter, page or sort change misses on both sides instead of being answered stale by one of them (MMCA.Common.UI/Services/Api/EntityServiceBase.cs:239-241). - Freshness is stated in configuration.
UiReadCacheOptions(MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:13, bound from theUiReadCachesection,:16) carries anEnabledkill switch (:24), a 60-secondDefaultTtl(:32) and per-route-prefix TTL overrides (:41). The longest matching prefix wins (UiReadCache.cs:163-178, the length comparison at:170), so a nested route can state a stricter budget than the endpoint above it whatever order configuration enumerates in. - Successes only, prefix invalidation after any write that may have landed, clear on sign-out.
GetCachedAsync(MMCA.Common.UI/Services/Api/EntityServiceBase.cs:253) stores a value only when the read succeeded (:278-281, through the generation-checkedSetat:280), so a transient outage or a 404 is never pinned in front of the user. A write drops this endpoint's whole prefix (InvalidateAfterWrite,:300, callingInvalidatePrefix(Endpoint)at:304) on success and on every failure except one the server refused before touching state (:302): only validation, unprocessable-entity, unauthorized, forbidden and too-many-requests failures skip it (:309-314), so a 412, 404, 409, 5xx or transport failure, whose write may have landed, invalidates too. A read still in flight when that write lands is not re-cached, because the store is generation-checked (the generation is captured before the GET at:273andUiReadCache.Setdrops the value ifInvalidatePrefixorClearmoved it,UiReadCache.cs:119-122); andAuthUIServiceempties the cache on sign-out and on an unrefreshable session (MMCA.Common.UI/Services/Auth/AuthUIService.cs:393, inside the privateSignOutLocallyAsyncat:379that both logout and revoke-all-sessions reach, and:190), which is what keeps one account's reads from outliving its session where the scope does. - Shipped and registered, adopted by no app.
EntityServiceBasetakes the cache as an optional constructor parameter defaulting tonull(MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47, exposed as the protectedReadCacheproperty at:58); withnullevery read goes to the API and the class behaves exactly as it did before the cache existed (:260-263). No UI service in ADC, Store or Helpdesk passes it: a search of all four repositories findsIUiReadCacheonly inside MMCA.Common's own source and tests. This tier is therefore a capability the framework offers, not a posture the apps are in.
Rationale
- One substrate, swapped by environment. Keeping
ICacheServiceas the only thing application code sees lets the deployment decide memory vs distributed. The auto-swap (presence of a realIDistributedCache) means there is no flag to forget and no per-handler branching. - Two tiers because the jobs differ. Tier 1 memoizes handler results and invalidates them precisely
on mutation (prefix/
ICacheInvalidating). Tier 2 skips the handler entirely for public reads at the HTTP edge. It began as the anonymous-traffic lever ADR-019 leans on (anonymous callers are exempt from the per-user rate limiter, so output caching absorbs that load instead); as amended by ADR-040 the adopters' public-read policies now cache authenticated (bearer-carrying) requests too, so the edge absorbs logged-in read load as well (the anonymous-only description is the pre-ADR-040 behavior). They are not unified by design: different keys, different lifetimes, different eviction. - TTL-bounded correctness over distributed-invalidation guarantees. A short default TTL makes the cache eventually consistent cheaply, so the system stays correct-enough even where prefix invalidation is best-effort, rather than depending on a perfectly fan-out invalidation.
Trade-offs
- Memory mode is per-replica. In the in-process store each replica caches independently; a
scaled-out deployment that did not wire Redis would see cross-replica staleness bounded only by the
TTL. The framework's answer is to register a distributed cache once scaled out (both apps do), which
bounds cross-replica staleness rather than removing it: with Redis configured each replica still
holds an L1 copy, and an eviction on one replica leaves the others' copies serving for up to
Cache:LocalCacheDuration(MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:30-32,:192-195; see the next bullet). - Distributed prefix invalidation needs the multiplexer, and every service registers it through one
framework wrapper. Both distributed substrates can only scan-and-delete by prefix when an
IConnectionMultiplexeris in the container:DistributedCacheService, and the one that runs once Redis is configured,HybridCacheService.RemoveByPrefixAsync(MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:219-237), which without a multiplexer warns once and returns (:221-228). Putting it there is no longer each host's business:builder.AddRedisCaching()(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:57) registers the distributed cache (:64) and the client (:65) together, both withDisableHealthChecks = true, and no-ops when the named connection string is blank. The wrapper exists because the raw Aspire integrations register an untaggedStackExchange.Redishealth check, which readiness therefore includes, and against Azure Managed Redis that check issuesCLUSTER INFO, a command the client refuses outside admin mode: every probe threw and every replica reported not ready (ADR-025 owns that decision and the PING-only replacement check). All seven services call the wrapper, and all seven call it unconditionally, since it no-ops on its own (RedisCachingExtensions.cs:59): ADC ConferenceMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:185, NotificationMMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:111, EngagementMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:108, IdentityMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:130; Store CatalogMMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:89, SalesMMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:104, IdentityMMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:93. OnlyAddCommonHybridCache(), which has no guard of its own, is reached conditionally, through the guardedAddCommonHybridCacheWhenRedisConfigured(...)helper (Tier 1 above). Those seven are the whole set: a sweep of both repos'Source/trees finds no eighthAddRedisCachingcall and no directAddRedisDistributedCache/AddRedisClientcall outside the wrapper. So whenever Redis is configured, prefix-based invalidation against Redis is live and the shared L2 entries are evicted on write. Eviction does not reach every copy, though: with Redis configured the live substrate isHybridCacheService, and another replica's L1 copy keeps serving a removed value, for a single key or a prefix alike, until its local expiration lapses (for up toCache:LocalCacheDuration, 30 seconds by default,MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:192-195and:210-212); a read that must see a removal everywhere goes throughGetFromSharedStoreAsync(:154). So the 30-second bound is the cross-replica backstop in hybrid mode as well as the whole answer in memory mode, where prefix removal self-heals within seconds instead. - Distributed mode pays serialization and a network hop. Values cross the wire as JSON; large or hot objects cost more than the in-process path.
- Counter increments are not atomic, and that is the accepted position.
ICacheService.IncrementAsyncis a default interface member implemented as a read-modify-write (MMCA.Common.Application/Interfaces/ICacheService.cs:108), andDistributedCacheServiceoverrides it with the same read-modify-write shape rather than RedisINCR(MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:153). With Redis configured the override that runs isHybridCacheService.IncrementAsync(MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:264), also a read-modify-write, with both legs on L2 only (remarks at:243-248). The reason is a storage format mismatch, documented at the implementation (MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:134-152):INCRwrites a Redis string, whileStackExchangeRedisCachestores every entry as a Redis hash (absexp/sldexp/data, read back withHMGET). AnINCR-written counter therefore makes the next read of that key 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 wins over atomicity here: the ADR-029 brute-force and rate-limit counters can undercount under genuinely concurrent increments, and an occasional lost increment is the accepted cost of a counter that is always readable. One caveat for a reader who goes to the interface first: its<remarks>still anticipates the opposite outcome (backing stores that can do better with RedisINCRoverride it,MMCA.Common.Application/Interfaces/ICacheService.cs:103-107, the sentence at:106), and its<summary>opens with "Atomically increments" (:94). No implementation does, and the one that could deliberately does not, for the storage-format reason above. Read that comment as an option the framework declined, not as a description of a shipped override; the implementation's own<remarks>is the accurate one. - Output caching is opt-in per service. A read-heavy service that forgets to register a real
AddOutputCachepolicy gets no edge caching (theNoCachebase is the safe default), the same audit-the-inventory caveat as other opt-in capabilities (ADR-019/020/021). Adopting a policy is only half the decision: the store behind it is per-replica memory unless the service registers the shared Redis output-cache store (Tier 2 above), which is what a multi-replica adopter needs for tag eviction to reach every replica (ADR-040). - The optional client tier is an inventory item, and the inventory is empty.
IUiReadCacheis registered wherever a host callsAddUIShared(MMCA.Common.UI/DependencyInjection.cs:76), but a UI service reads through it only if its own constructor forwards the optional parameter (MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47), so registration on its own changes nothing and no build, test or startup notices the difference. That is Tier 2's audit-the-inventory caveat one layer further out, and it is the intended posture rather than a gap to sweep: client-side staleness is visible to the user, so each UI service opts in on its own read pattern or does not opt in at all.
Revision (2026-09-07)
Four changes from the 2026-09-07 security review. The tiers themselves are unchanged.
- Tier-1 keys are caller-scoped where the result is caller-scoped (SEC-Common-19 /
SEC-Common-37).
CachingQueryDecoratorcomposesTenantCacheKey.Scope(tenantContext, UserCacheKey.Scope(cacheable, cacheable.CacheKey))(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:59).UserCacheKey(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/UserCacheKey.cs:26) appends the target user when the query implementsIUserScopedRequest(MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8); theISharedQueryCachemarker (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/ISharedQueryCache.cs:21) opts a genuinely public result back out. Because the caller segment is a suffix, prefix invalidation still clears every caller's copy in one sweep. - The public output-cache key varies by resolved tenant (SEC-Common-46).
PublicEndpointOutputCachePolicystampsITenantContext.TenantIdintoCacheVaryByRulesunder thetkey (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:50,:102-105), mirroring thet:{tenantId}prefix the CQRS decorators already used. An unresolved tenant (a single-tenant host, a background call) adds nothing to the key, so nothing changes for the deployments that have no tenant. - Roles are read one way. The policy's bypass check goes through
ClaimsPrincipalExtensions.HasRole(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:75, overGetRoleValuesat:59, which readsClaimTypes.Role,roleandrolescase-insensitively), applied atPublicEndpointOutputCachePolicy.cs:142. A narrower read here than the permission handler's meant a privileged caller under an unmapped claim type could have an elevated response stored under the shared public key. - The cache key prefix is namespaced per application.
Cache:KeyPrefixleft unset now defaults to the resolved application namespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:83, rationale at:68), and the distributed-lock keyspace follows it. Two applications sharing one Redis no longer collide, at the cost of a cold cache on the deploy that adopts it.
Related
ADR-014 (the Caching decorators and IQueryCacheable / ICacheInvalidating markers that consume this
substrate), ADR-019 (output caching as the anonymous-traffic lever, and LoginProtectionService is
another ICacheService consumer), ADR-006 / ADR-008 (the same monolith-to-services swap boundary this
substrate follows), ADR-025 (why both tiers reach Redis through
MMCA.Common.Aspire's AddRedisCaching / AddRedisOutputCaching wrappers rather than Aspire's
integrations directly, and the PING-only health check that replaced the untagged one), ADR-040
(amends this ADR's Tier 2: the adopters' public-read policies cache authenticated, bearer-carrying
requests too, not only anonymous traffic, and its
CacheVaryByRules.QueryKeys = "*" rule is the key shape the optional client tier mirrors),
ADR-077 (amends this ADR's Tier 1: the opt-in HybridCacheService
substrate, the disjoint hc: keyspace that generalizes the WRONGTYPE lesson recorded in the counter
trade-off above, and the L1 bypass that keeps IncrementAsync semantics unchanged),
ADR-090 (the upcaster registration and the
RegisterUpcastedIntegrationEventConsumer<TEvent> sibling that a future reshape of this ADR's
OutputCacheEvictionRequested contract would go through).
Revision (2026-07-24)
Three substrate corrections from a code review.
- An optional key namespace (
Cache:KeyPrefix). Services sharing one cache instance also share one keyspace, and nothing stopped two of them choosing the same key for different data. The prefix is applied insideDistributedCacheServicerather than throughRedisCacheOptions.InstanceNamedeliberately:InstanceNameis prepended below this abstraction, where prefix invalidation cannot see it, so the SCAN inRemoveByPrefixAsyncwould look forproduct:*while the stored keys weresvc:product:*and evict nothing, silently. Applying it here keeps get, set, remove, the SCAN pattern and the Redis counter working from one key shape.MemoryCacheServiceignores it: its keyspace is private to the process by construction. - Prefix invalidation survives an overwrite.
MemoryCacheServicetracks live keys in a side dictionary becauseIMemoryCachecannot enumerate, and its post-eviction callback removed the key for every eviction reason.IMemoryCachequeues those callbacks to the thread pool, so overwriting a live key fired the old entry's callback asynchronously and it could land after the replacement was tracked, deleting the tracking record for an entry that was still cached. That entry was then live but invisible toRemoveByPrefixAsyncand clearable only by its TTL. The callback now skipsEvictionReason.Replaced; genuine evictions still clean up. ICacheService.IncrementAsync. A default interface member (so no implementer breaks), added as the single entry point for the ADR-029 counters. It is a read-modify-write, andDistributedCacheServicedeliberately keeps that shape rather than reaching for RedisINCR: see the counter trade-off above for the storage-format reason and why the non-atomicity is accepted.
The query-cache stampede lock also moved to a fixed-width KeyedSemaphoreStripe. The previous
per-key dictionary was documented as bounded by the set of distinct cache keys, which holds only for
parameterless keys; any CacheKey embedding a user id or filter value grew it without bound.
Revision (2026-07-25)
An audit against the code. No behavior changed; the ADR text did.
- The
IncrementAsyncentry above was wrong. It described a RedisINCRoverride. There is no such override, and none is intended: both the default interface member andDistributedCacheService's implementation are read-modify-write, because a counter written byINCR(a Redis string) cannot be read back by the hash-shapedStackExchangeRedisCachepath that consults it. The resulting non-atomicity is recorded in Trade-offs as the accepted position, not as an open defect. - Tier 2 now records the output-cache store, not just the policies. ADC Conference and Store Catalog back the output cache with Redis when Redis is configured, so tag eviction crosses replicas; the in-memory store remains the single-replica case. This tracks ADR-040's 2026-07-25 amendment.
- Refreshed line anchors for
AddCachingand for the two adopters' paired Redis registrations. That refresh did not hold: four of the per-service citations it covered no longer matched the code when the ADR was next swept, so read this item as the state on 2026-07-25 only. The 2026-07-28 entry below re-anchored them.
Revision (2026-07-28)
Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed.
- Tier 2. Store Catalog's
AddStackExchangeRedisOutputCache(...)is atProgram.cs:90; the previously cited:88had become a line inside the comment above the call. ADC Conference'sProgram.cs:129was re-checked and still holds. - Trade-offs, the seven paired Redis registrations. Three moved: ADC Identity to
Program.cs:108,113(from:109,114), Store Catalog toProgram.cs:75,80(from:73,78), and Store Identity toProgram.cs:79,84(from:77,82). The other four were re-checked and are unchanged: ADC Conference:113,118, ADC Notification:99,104, ADC Engagement:88,93, Store Sales:76,81.AddCachingatMMCA.Common.Infrastructure/DependencyInjection.cs:149is also unchanged, as are the Tier 2 policy anchors inMMCA.Common.APIand the counter anchors inICacheService/DistributedCacheService. - The 2026-07-25 anchor claim is annotated rather than removed, so the history stays readable without asserting an accuracy it no longer had.
Revision (2026-08-01)
Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed.
AddCaching. Now atMMCA.Common.Infrastructure/DependencyInjection.cs:150(from:149); the previous line is the<returns>doc comment above the declaration, not the declaration itself.- Tier 2. Store Catalog's
AddStackExchangeRedisOutputCache(...)moved again, toProgram.cs:96(from the 2026-07-28 anchor of:90, itself a correction of an earlier:88). ADC Conference's moved toProgram.cs:135(from:129). - Trade-offs, the seven paired Redis registrations. All seven shifted by the same six lines: ADC
Conference to
Program.cs:119,124(from:113,118), Notification toProgram.cs:105,110(from:99,104), Engagement toProgram.cs:94,99(from:88,93), Identity toProgram.cs:114,119(from:108,113); Store Catalog toProgram.cs:81,86(from:75,80), Sales toProgram.cs:82,87(from:76,81), Identity toProgram.cs:85,90(from:79,84). - Counter anchors.
DistributedCacheService'sIncrementAsyncoverride is now atDistributedCacheService.cs:175-181(from:122-128), and the storage-format-mismatch<remarks>documenting it is now atDistributedCacheService.cs:156-174(from:104-121). The old line range for both now falls inside the unrelatedRemoveByPrefixAsync/ScanAndDeleteAsyncSCAN logic. - The 2026-07-28 anchor claim is annotated rather than removed, consistent with how that revision itself treated the 2026-07-25 entry: the anchors it recorded were correct on 2026-07-28 and have since drifted again.
Revision (2026-08-07)
Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed.
AddCaching. Now atMMCA.Common.Infrastructure/DependencyInjection.cs:157(from:150); line 150 is now the closing brace of the preceding method. The registration behavior it describes (DistributedCacheServicewhen a real, non-MemoryDistributedCacheIDistributedCacheis present,MemoryCacheServiceotherwise) is unchanged.- The 30-second default TTL now cites its source. The figure was asserted without an anchor;
it is verified and the anchor added
(
MMCA.Common.Infrastructure/Caching/CacheOptions.cs:14-17,TimeSpan.FromSeconds(30)). - Tier 2. ADC Conference's
AddStackExchangeRedisOutputCache(...)moved toProgram.cs:147(from:135) and Store Catalog's toProgram.cs:98(from:96). - Trade-offs, the seven paired Redis registrations. Six moved: ADC Conference to
Program.cs:131,136(from:119,124), Notification toProgram.cs:106,111(from:105,110), Engagement toProgram.cs:96,101(from:94,99), Identity toProgram.cs:123,128(from:114,119); Store Catalog toProgram.cs:83,88(from:81,86), Identity toProgram.cs:87,92(from:85,90). Store Sales was re-checked and is unchanged at:82,87. The Tier 2 policy anchors inMMCA.Common.API(WebApplicationExtensions.cs:104,OutputCacheOptionsExtensions.cs:20,PublicEndpointOutputCachePolicy.cs:35,:71-75,:109-113) and the counter anchors (ICacheService.cs:57,DistributedCacheService.cs:156-174and:175-181) were re-checked and are unchanged. - The 2026-08-01 anchor claim is annotated rather than removed, consistent with how the two preceding revisions treated their predecessors: the anchors it recorded were correct on 2026-08-01 and have since drifted again.
Every anchor in this entry was correct on 2026-08-07 and has since drifted, the CacheOptions
citation in item 2 included: read the whole entry as the state on that date, and the 2026-08-14
entry below for the current anchors.
Revision (2026-08-13)
Tier 1 is amended by ADR-077, which is where the decision and its trade-offs are recorded. The three points that change the reading of this record:
- A third substrate, opted into rather than auto-selected.
HybridCacheService(L1 in-process plus L2 distributed,Microsoft.Extensions.Caching.Hybrid) joinsMemoryCacheServiceandDistributedCacheService.AddCaching()'s presence-of-a-real-IDistributedCacheswap still decides between the original two;AddCommonHybridCache(...)replaces the result. A host that does not call it gets byte-identical behavior to this record as written. - The counter trade-off above is generalized into a keyspace rule. The
WRONGTYPEincident documented in Trade-offs (a RedisINCRstring read back by the hash-shapedStackExchangeRedisCachepath) is a class of bug, not one occurrence: two serialization formats must never share one keyspace. The hybrid substrate therefore writes under a disjoint{prefix}hc:{key}keyspace, so a cross-format read cannot happen at all, and prefix eviction scans that one keyspace: what this substrate writes is exactly what it evicts. The non-atomic read-modify-writeIncrementAsyncposition is unchanged, and the hybrid implementation bypasses L1 on both legs precisely to keep it unchanged. - Nothing in Tier 2 moves. The output-cache edge, its policies and its Redis-backed store are untouched by ADR-077.
Revision (2026-08-14)
One substrate correction plus a line-anchor re-verification. No decision and no behavior changed.
- The 30-second default now has a named home,
CacheOptions.DefaultDuration. Tier 1 described the default TTL asDefaultExpiration's literalTimeSpan.FromSeconds(30).CacheOptionshas since gained aDefaultDurationproperty (CacheOptions.cs:17), a bareTimeSpanadded for the ADR-077HybridCacheService, whose entry options areHybridCacheEntryOptionsrather thanDistributedCacheEntryOptionsand so could not have reusedDefaultExpirationwithout hard-coding the figure a second time.DefaultExpirationnow readsAbsoluteExpirationRelativeToNow = DefaultDuration(CacheOptions.cs:22-25). The value is still 30 seconds and every claim built on it still holds; there is now one place to change it. - The seven paired Redis registrations are confirmed exhaustive. Previous revisions re-anchored
the seven named services one by one without ever asking whether an eighth had appeared. A sweep of
both repos'
Source/trees forAddRedisDistributedCache/AddRedisClientreturns exactly these fourteen call sites, so "all seven services" is a verified count, not an assumption. The Trade-offs entry now says so. AddCaching. Now atMMCA.Common.Infrastructure/DependencyInjection.cs:164(from:157); line 157 is now the closing brace of the preceding method.- Tier 2.
app.UseOutputCache()moved toWebApplicationExtensions.cs:111(from:104). ADC Conference'sAddStackExchangeRedisOutputCache(...)moved toProgram.cs:156(from:147) and Store Catalog's toProgram.cs:117(from:98). - Trade-offs, the seven paired Redis registrations. All seven moved: ADC Conference to
Program.cs:140,145(from:131,136), Notification toProgram.cs:114,119(from:106,111), Engagement toProgram.cs:111,116(from:96,101), Identity toProgram.cs:131,136(from:123,128); Store Catalog toProgram.cs:94,99(from:83,88), Sales toProgram.cs:111,116(from:82,87), Identity toProgram.cs:100,105(from:87,92). - Counter anchors.
DistributedCacheService'sIncrementAsyncis now atDistributedCacheService.cs:127-133(from:175-181) and the storage-format-mismatch<remarks>documenting it atDistributedCacheService.cs:108-126(from:156-174), a move of about 48 lines up. The read-modify-write behavior and the reason for it are unchanged.ICacheService.cs:57and the Tier 2 policy anchors (OutputCacheOptionsExtensions.cs:20,PublicEndpointOutputCachePolicy.cs:35,:71-75,:109-113) were re-checked and are unchanged. - The 2026-08-07 anchor claim is annotated rather than removed, consistent with how every preceding revision treated its predecessor: the anchors it recorded were correct on 2026-08-07 and have since drifted again.
Items 1, 3, 4 and 6 of this entry were correct on 2026-08-14 and have since drifted, and item 4's
WebApplicationExtensions.cs home no longer exists at all: read the entry as the state on that date,
and the 2026-08-23 entry below for the current anchors.
Revision (2026-08-18)
Every previous revision moved Tier 1. This one moves Tier 2, and it is the first change to the output-cache edge since ADR-040.
Tier 2 as decided here is per-process by construction: each host calls UseOutputCache, owns its own
policies, and evicts by tag through its own IOutputCacheStore. In a database-per-service deployment
(ADR-006/008) the thing that invalidates a cached read frequently happens somewhere else: a mutation in
one service makes another service's cached public read wrong, and that service has no way to hear about
it. The eviction call and the write that requires it were in different processes, so the only
invalidation that crossed a boundary was the expiry clock.
An integration event carries the eviction
OutputCacheEvictionRequested
(MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:29) is
a sealed record over BaseIntegrationEvent whose only own member is
IReadOnlyList<string> Tags { get; init; } = [] (:37), inheriting SchemaVersion => 1
(.../DomainEvents/BaseIntegrationEvent.cs:32) and therefore ADR-010's versioning contract. It is the
first concrete integration event the framework itself ships: a repository-wide search of Source/
finds no other, every prior hit being the interface, the abstract base or generic plumbing. Until now
the framework provided the delivery machinery and consumers provided all the messages.
Tags defaulting to empty is a safety decision, not a formality. An empty list means evict
nothing, so a message that arrives malformed, from an older producer, or with a property the consumer
cannot bind, degrades to a no-op instead of to a cache-wide flush. The failure mode of the default is a
stale response, which the TTL already bounds; the alternative default would have made a deserialization
accident indistinguishable from an intentional purge.
Delivery reuses the whole existing path rather than adding a channel: the producing service raises the event, ADR-003's outbox persists and publishes it, ADR-066's broker carries it, and ADR-021's inbox dedups the redelivery. Nothing about eviction is transactional or exactly-once, and it does not need to be: evicting twice is free, and evicting late is what a TTL is for.
The consumer side is two registrations in two packages
RegisterOutputCacheEvictionConsumer(bool registerFaultConsumer = true)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:108-110)
is the MassTransit-side shorthand for RegisterIntegrationEventConsumer<OutputCacheEvictionRequested>(),
so it inherits ADR-087's fault consumer by default. It now sits below a sibling in that same file,
RegisterUpcastedIntegrationEventConsumer<TEvent> (:78-90), the
ADR-090 registration a host adds while a retired contract still
drains from a queue. The two are alternatives on one event, never both: registering a plain and an
upcasting consumer for the same type puts two consumers on one queue. AddOutputCacheEvictionHandler()
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:111)
registers the handler itself, through TryAddEnumerable (:115-117). They are in different packages
because the two halves are genuinely different concerns (broker registration is Infrastructure, output
caching is API), and a host that calls only one gets silence: the consumer without the handler
receives and discards, the handler without the consumer is never invoked.
OutputCacheEvictionHandler
(.../MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32) loops the tags, skips blank ones, and
catches per tag so one failing tag cannot abandon the rest (:44-63), rethrowing only
OperationCanceledException so a stopping host is not counted as a cache failure. Failures increment
cache.eviction.failed, tagged cache_tag, on a new meter MMCA.Common.OutputCache
(.../Caching/OutputCacheMetrics.cs:19, instrument at :29-37), subscribed by the Aspire defaults
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:204).
One deliberate non-reuse: the handler does not route through the BestEffort.ExecuteAsync helper
that shipped in the same release. BestEffort lives in the Application layer and counts on its own
MMCA.Common.BestEffort meter, which the API package cannot reach without either a layer-crossing
reference or a duplicated meter name; the handler therefore hand-rolls the same swallow-log-count shape
against its own instrument. That is a small duplication chosen over a worse coupling, and it is the
reason two "best effort" counters exist in one release.
What this revision costs
- Cross-service eviction is asynchronous and unordered relative to the write. The stale window is at least an outbox poll plus broker delivery plus the consumer's own scheduling, so a reader can observe the old response after the writer's transaction has committed. Tier 2 was always eventually-consistent; this makes the eventual explicit and measurable rather than bounded by the TTL alone.
- The tag vocabulary is a shared string contract nothing validates. The producer names a tag the consumer's policies must also name. A typo evicts nothing and counts nothing, because evicting an unknown tag succeeds: the failure counter sees only faults, never misses, so the most likely error is the one with no signal.
cache.eviction.failedhas no alert and no runbook section. It joins the counters ADR-087 also left unwired (ADR-062), so a service quietly failing every eviction is visible only to someone already looking at the meter.- The two-call registration is an inventory item. Nothing fails a build, a test or a startup when a host wires one half, and both halves are opt-in per host on top of that.
- The framework now owns a wire contract.
OutputCacheEvictionRequestedis a published shape that ADR-010's rules apply to, so a future field is an additive change with an upcaster or a new type, never a reshape, and that obligation now sits on the framework rather than only on consumers. - Tier 1 and Tier 2 remain separate invalidation models. A single mutation may need a Tier 1 prefix invalidation and a Tier 2 tag eviction, and nothing coordinates them; ADR-077 moved one, this revision moves the other, and they still do not meet.
Revision (2026-08-23)
One correction of substance plus a line-anchor re-verification. No decision and no behavior changed.
- The counter trade-off now names the contradiction a reader will hit.
ICacheService's<remarks>onIncrementAsyncsays that backing stores which can do better with RedisINCRoverride it (MMCA.Common.Application/Interfaces/ICacheService.cs:54-57). Nothing does. The only store that could,DistributedCacheService, deliberately keeps the read-modify-write and documents why at its own implementation (DistributedCacheService.cs:107-133). The Revision (2026-07-25) already recorded that noINCRoverride exists or is intended, but this ADR never said that the interface comment still points the other way, so a reader starting from the interface reached the opposite conclusion. Trade-offs now says which of the two comments describes shipped behavior. The source comment is unchanged by this entry; only the ADR is. - Tier 2's
UseOutputCachecall has a new home, and a different shape of home.app.UseOutputCache()is no longer an inline call inMMCA.Common.API/Startup/WebApplicationExtensions.cs(it is not in that file at all). The shared middleware pipeline is now assembled from named steps: the output-cache step is atMMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:138, named byMiddlewarePipelineStepNames.OutputCache(MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:64). The decision is untouched, the pipeline still always enables output caching and still ships no policies, but the citation now points at a builder rather than at a sequence of calls. - The eviction event's method surface gained an ADR-090 sibling.
RegisterOutputCacheEvictionConsumermoved toIntegrationEventConsumerExtensions.cs:108-110(from:68-70) becauseRegisterUpcastedIntegrationEventConsumer<TEvent>(:78-90) was added above it, the registration ADR-090 defines for draining a retired contract. The 2026-08-18 revision predates it and did not mention it; the consumer paragraph now does, including that the two are alternatives on one event rather than a pair. ADR-090 is added to Related. - Remaining anchors.
AddCachingis now atMMCA.Common.Infrastructure/DependencyInjection.cs:177(from:164).OutputCacheEvictionRequested's declaration is at:27andTagsat:35(from:23and:31), both pushed down four lines by a Frozen-contract-candidate paragraph added to the doc comment.BaseIntegrationEvent.SchemaVersionis at:32(from:22), likewise behind added doc text.ICacheService'sIncrementAsyncdeclaration is at:59(from:57;:57is now the last line of the<remarks>discussed in item 1). Re-checked and unchanged:CacheOptions.cs:17and:22-25, both adopters'AddStackExchangeRedisOutputCachecalls and all seven paired Redis registrations,DistributedCacheService.cs:108-126and:127-133, the Tier 2 policy anchors (OutputCacheOptionsExtensions.cs:20,PublicEndpointOutputCachePolicy.cs:35,:71-75,:109-113),OutputCacheEvictionExtensions.cs:32and:36-38,OutputCacheEvictionHandler.cs:32and:44-63,OutputCacheMetrics.cs:19and:29-37, andMMCA.Common.Aspire/Extensions.cs:169. - The 2026-08-14 anchor claim is annotated rather than removed, consistent with how every preceding revision treated its predecessor.
Item 4's "re-checked and unchanged" list did not hold: CacheOptions, both adopters'
AddStackExchangeRedisOutputCache calls, all seven paired Redis registrations and the
DistributedCacheService counter anchors have all moved since. Read items 1 and 4 as the state on
2026-08-23, and the 2026-08-31 entry below for the current anchors. Item 2's
MiddlewarePipelineStepNames.cs:64 was an off-by-one from the start rather than drift: :64 is the
XML doc comment and the const it names is at :65, corrected in the Decision by the 2026-09-01 entry
below. The two file paths this entry records have also moved since: the pipeline builder and its step
names are now under MMCA.Common.API/Startup/Pipeline/, and IntegrationEventConsumerExtensions.cs is
now under MMCA.Common.Infrastructure/Messaging/Consumers/, both corrected in the live sections by the
2026-09-03 entry.
Revision (2026-08-31)
Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed.
AddCaching. Now atMMCA.Common.Infrastructure/DependencyInjection.cs:215(from:177); line 177 is now insideAddInfrastructure, at itsIOutboxSignalregistration. The presence-of-a-real-IDistributedCacheswap it describes is unchanged.CacheOptions.DefaultDurationis atCacheOptions.cs:23(from:17, now a line of the doc comment above it) andDefaultExpiration'sAbsoluteExpirationRelativeToNow = DefaultDurationat:28-31(from:22-25). The figure is still 30 seconds and still has exactly one home.- Tier 2. ADC Conference's
AddStackExchangeRedisOutputCache(...)is atProgram.cs:140(from:156) and Store Catalog's atProgram.cs:96(from:117). - Trade-offs, the seven paired Redis registrations. All seven moved up: ADC Conference to
Program.cs:124,129(from:140,145), Notification toProgram.cs:98,103(from:114,119), Engagement toProgram.cs:95,100(from:111,116), Identity toProgram.cs:116,121(from:131,136); Store Catalog toProgram.cs:73,78(from:94,99), Sales toProgram.cs:89,94(from:111,116), Identity toProgram.cs:78,83(from:100,105). The 2026-08-14 exhaustiveness check still holds: a sweep of both repos'Source/trees forAddRedisDistributedCache/AddRedisClientreturns these same fourteen call sites and no eighth service. - Counter anchors.
DistributedCacheService'sIncrementAsyncoverride is now atDistributedCacheService.cs:146-152(from:127-133) and the storage-format-mismatch<remarks>documenting it at:127-145(from:108-126); the old range now falls inside the unrelatedRemoveByPrefixAsync(its no-multiplexer warning and Redis SCAN delete). The read-modify-write behavior and the reason for it are unchanged, andICacheService.cs:54-57/:59were re-checked and still hold, so item 1 of the 2026-08-23 entry stands as written. - The eviction event.
OutputCacheEvictionRequested's declaration is at:29andTagsat:37(from:27and:35), both pushed down two lines by the[EventName("Common.OutputCacheEvictionRequested.v1")]attribute now sitting on the type (:28) and theusingit needs (:1). Re-checked and unchanged:IntegrationEventConsumerExtensions.cs:108-110and:78-90, and the Tier 2 policy anchors (OutputCacheOptionsExtensions.cs:20,PublicEndpointOutputCachePolicy.cs:35,:71-75,:109-113). - The 2026-08-23 anchor claim is annotated rather than removed, consistent with how every preceding revision treated its predecessor.
Every anchor in this entry has since drifted, and items 3 and 4 describe calls the applications no longer make at all: read the whole entry as the state on 2026-08-31, and the 2026-09-03 entry below for the current wiring and anchors.
Revision (2026-09-01)
One amendment of substance (an optional third tier is recorded) plus line anchors for the files the 2026-08-31 entry did not re-read. No behavior changed, and neither server tier changed.
- The eviction registration.
AddOutputCacheEvictionHandler()is now atMMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:111(from:32) and itsTryAddEnumerablecall at:115-117(from:36-38). The class itself is still the head of the file (:27); what moved the members is a secondextension(T)block added above theextension(IServiceCollection services)one (:36and:95respectively), holding the multi-tag eviction helpersEvictTagsAsync(:49) and its best-effort siblingTryEvictTagsAsync(:78) that a mutating controller calls after a write, plus theEvictOperationPrefixconst they name their operations from (:34). Twoextension(T)blocks in one static class is also why the file now carries a CA1708 suppression (:23-26). The registration behavior the citation describes, one handler however many callers register it, is unchanged. - Re-checked and unchanged.
OutputCacheEvictionHandler.cs:32(the class) and:44-63(the per-tag loop with its swallow-log-count catch),OutputCacheMetrics.cs:19(the meter name) and:29-37(thecache.eviction.failedinstrument plus its recorder), andMMCA.Common.Aspire/Extensions.cs:169(the meter subscription, which is the SIXTH of the seven chainedAddMetercalls, not the fifth: they begin atMMCA.Common.Outboxon:164and run Outbox, Cqrs, Idempotency, Scheduler, Broker, OutputCache, BestEffort). The 2026-08-31 entry's own anchors were not re-read this pass and stand as written there. - The output-cache step name was cited one line high, and always had been.
MiddlewarePipelineStepNames.OutputCacheis declared atMMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:65;:64, the anchor the Decision and the Revision (2026-08-23) carried, is the XML doc comment above it. The Decision's citation is corrected here and the 2026-08-23 entry is annotated in place rather than rewritten.MiddlewarePipelineBuilder.cs:138, theapp.UseOutputCache()step itself, was re-read and holds. - An optional third tier is recorded:
IUiReadCache, the client-side read-through cache. No code moves with this entry; this is shipped code the ADR had never described. A new Decision subsection and a new Trade-offs bullet record it: per-circuit read-through over the API client, keyed by the relative URL so the key shape matches Tier 2'sQueryKeys = "*"(PublicEndpointOutputCachePolicy.cs:81), a default TTL plus longest-prefix per-route overrides (UiReadCacheOptions.cs:32and:41), successes-only storage (EntityServiceBase.cs:262-264), prefix invalidation on a successful write (:285) and a clear on sign-out (AuthUIService.cs:127). It is DI-registered byAddUIShared(MMCA.Common.UI/DependencyInjection.cs:57) and still opt-in per service, through an optional constructor parameter that defaults tonull(EntityServiceBase.cs:47), and no consumer app passes it: ADC, Store and Helpdesk contain no reference to the type at all. It is recorded as a capability and an inventory item, not as something to adopt in a sweep. The title stays "Two-Tier Caching" on purpose: two tiers are what this ADR decides, and the client one is optional. - The 2026-08-31 anchor claim is annotated rather than removed, consistent with how every preceding revision treated its predecessor.
Items 2 and 4 of this entry have since drifted: the meter subscription is at
MMCA.Common.Aspire/Extensions.cs:204, AddUIShared's registration at
MMCA.Common.UI/DependencyInjection.cs:61, the sign-out clear at AuthUIService.cs:130, and
EntityServiceBase.cs now lives under MMCA.Common.UI/Services/Api/. Read the entry as the state on
2026-09-01, and the 2026-09-03 entry below for the current anchors.
Revision (2026-09-03)
Two substrate corrections plus a line-anchor and file-path re-verification. No decision changed; what changed is where the wiring lives and which substrate the applications actually run.
- Redis is wired through framework wrappers now, not by each host. Neither repo calls Aspire's
AddRedisDistributedCache/AddRedisClient/AddStackExchangeRedisOutputCacheany more. All three calls live once, inMMCA.Common.Aspire.Caching.RedisCachingExtensions(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:29), behindAddRedisCaching()(:57, registering the cache at:64and the client at:65) andAddRedisOutputCaching()(:91, the singleAddStackExchangeRedisOutputCachecall at:99). Both wrappers disable the Aspire integrations' health checks and no-op when the connection string is absent, which is why a service can call them unconditionally (the four ADC services did from this entry on, and the three Store services followed, see the Revision (2026-09-25)). The reason is a production incident this ADR had no record of: the raw integrations register an untaggedStackExchange.Redischeck that readiness includes, it issuesCLUSTER INFO, and Azure Managed Redis is detected as a cluster by StackExchange.Redis 3.x, which refuses the command without admin mode. Every probe threw and every replica reported not ready. ADR-025 owns that decision; it is added to Related and the Trade-offs bullet now names it. HybridCacheServiceis what production runs. ADR-077's substrate was recorded here as opt-in, which is still true of the mechanism, but the Tier 1 narrative read as though the memory-or-distributed auto-swap is what runs everywhere. It is not: all seven ADC and Store services callAddCommonHybridCache()(MMCA.Common.Infrastructure/DependencyInjection.cs:340) inside their redis-connection-string conditional, soDistributedCacheServiceis the liveICacheServicein no deployed service. The auto-swap is the no-Redis path: local runs, tests, and any host that wires no Redis. Tier 1 now says so and carries the seven anchors.- Tier 2 gains a cache-bypassing-roles policy overload.
AddPublicEndpointPolicy(name, expiration, bypassRoles, tags)(MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:34) sits beside the three-argument overload the ADR already cited (:20), and a caller in a named role skips the cache entirely, no lookup and no storage. ADC Conference uses it for every policy but one (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:216-244on that date); Store Catalog's four policies (Program.cs:150-155) use the plain overload. That materially qualifies "public-read policies cache authenticated requests too", so a new Decision bullet records it. - Three cited files moved into sub-folders.
MiddlewarePipelineBuilder.csandMiddlewarePipelineStepNames.csare underMMCA.Common.API/Startup/Pipeline/(both line anchors,:138and:65, still hold).EntityServiceBase.csis underMMCA.Common.UI/Services/Api/(all its line anchors still hold).IntegrationEventConsumerExtensions.csis underMMCA.Common.Infrastructure/Messaging/Consumers/(:108-110and:78-90still hold). Only the paths changed; the live sections carry the new ones. - Line anchors.
AddCachingis atMMCA.Common.Infrastructure/DependencyInjection.cs:229(from:215, now a comment line insideAddInfrastructure), called fromAddInfrastructureat:131.DistributedCacheService'sIncrementAsyncoverride is at:145-151(from:146-152) and the storage-format-mismatch<remarks>at:126-144(from:127-145); the behavior and the reason are unchanged.IUiReadCache'sTryAddScopedis atMMCA.Common.UI/DependencyInjection.cs:61(from:57, now blank;:56is theTimeProvider.Systemregistration it depends on).AuthUIService's twoClear()calls are at:130and:159(from:127and:156). TheMMCA.Common.OutputCachemeter subscription is atMMCA.Common.Aspire/Extensions.cs:204(from:169); the chain now runs:199-205and OutputCache is still the sixth of seven. Re-checked and unchanged:CacheOptions.cs:23and:28-31,ICacheService.cs:54-57and:59, the Tier 2 policy anchors (PublicEndpointOutputCachePolicy.cs:35,:71-75,:81,:109-113), the wholeIUiReadCache/UiReadCache/UiReadCacheOptionsset, andEntityServiceBase's:47,:58,:229,:241,:248,:262-264,:281and:285. - The client tier is still adopted by no app. A search of all four repositories finds
IUiReadCacheonly in MMCA.Common's own source and tests, so the 2026-09-01 entry's inventory claim stands. - The 2026-08-31 and 2026-09-01 anchor claims are annotated rather than removed, consistent with how every preceding revision treated its predecessor.
Items 1, 2, 3 and 5 of this entry have since drifted: read their anchors as the state on 2026-09-03, and the 2026-09-25 entry below for the current wiring and anchors.
Revision (2026-09-25)
One wiring convergence, one count correction, and a line-anchor re-verification of the service call sites and the substrate registrations. No decision changed, and the ADC-only bypass list stays the sanctioned asymmetry it was.
- Store calls both Redis wrappers unconditionally, as ADC does.
AddRedisCaching()andAddRedisOutputCaching()already no-op on a blank connection string (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:59and:94), so an outer connection-string branch around them in a host did nothing. The three Store services now call them top-level (CatalogMMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:88and:98, Sales.../MMCA.Store.Sales.Service/Program.cs:103, Identity.../MMCA.Store.Identity.Service/Program.cs:92), and both repositories wire Redis one way. OnlyAddCommonHybridCache(), which has no guard of its own, stays behind the conditional in all seven services. Tier 1, Tier 2 and the multiplexer trade-off now say so. - ADC Conference registers twelve public-read policies, and eleven take the bypass list.
BookmarkCountsCachepassesadminBypassRoles(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:297) beside the ten five-minute policies (:268-282);NowNextCache(:285) remains the one with no bypass list. The Decision bullet now gives the count as eleven of twelve. - Line anchors.
AddCachingis declared in the partial fileMMCA.Common.Infrastructure/DependencyInjection.Caching.cs:26and called fromAddInfrastructureatDependencyInjection.cs:134;AddCommonHybridCacheis atDependencyInjection.Caching.cs:137. The sevenAddCommonHybridCache()calls are at ADC Conference:203, Notification:119, Engagement:113, Identity:135, Store Catalog:109, Sales:114and Identity:103. The sevenAddRedisCaching()calls are at ADC Conference:183, Notification:109, Engagement:103, Identity:125, Store Catalog:88, Sales:103and Identity:92, and the twoAddRedisOutputCaching()calls at ADC Conference:193and Store Catalog:98. Store Catalog's four policies are atProgram.cs:154,:155,:161and:164, and ADC Conference'sadminBypassRolesarray at:267. Re-checked and unchanged:RedisCachingExtensions.cs:29,:57,:64,:65,:91,:99andOutputCacheOptionsExtensions.cs:20and:34. The exhaustive sweep still holds: both repos'Source/trees carry exactly these nine wrapper calls and no directAddRedisDistributedCache/AddRedisClient/AddStackExchangeRedisOutputCachecall.
Revision (2026-10-01)
One wiring correction, one recorded client-tier detail, and a line-anchor re-verification. No decision and no rationale changed.
- The hybrid opt-in is a framework helper now, not a host conditional. All seven ADC and Store
services call
AddCommonHybridCacheWhenRedisConfigured(builder.Configuration)(MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:200), which callsAddCommonHybridCache()(:139) only when theredisconnection string is non-empty (:206-208). The outcome the 2026-09-25 entry recorded is unchanged (HybridCacheServicewherever Redis is configured, the memory-or-distributed swap elsewhere); only where the guard lives moved. Call sites: ADC ConferenceProgram.cs:202, Notification:118, Engagement:112, Identity:133; Store Catalog:107, Sales:112, Identity:101. Status, Tier 1 and the multiplexer trade-off now say so. - The client tier's store is generation-checked.
IUiReadCachegained aGenerationmember (MMCA.Common.UI/Services/Caching/IUiReadCache.cs:40) and aSetoverload taking it (:70).GetCachedAsynccaptures the generation before the GET (EntityServiceBase.cs:261) and stores through that overload (:268), andUiReadCachedrops the value when anInvalidatePrefixorClearmoved the generation meanwhile (UiReadCache.cs:119-122), so a write that lands during an in-flight read is not undone by the late store. The client tier is still adopted by no app: ADC, Helpdesk and Storeorigin/maincarry no reference toIUiReadCacheunderSource/. - Line anchors.
MiddlewarePipelineBuilder.cs:132-133(from:138);PublicEndpointOutputCachePolicy.cs:44(from:35),CacheRequestAsyncat:81-87(from:71-75),QueryKeys = "*"at:96(from:81), the helpers at:134and:141(from:109-113); the four Store Catalog policies atProgram.cs:151,:152,:158,:161; ADC Conference'sadminBypassRolesat:265, its bypass policies at:266-280and:295,NowNextCacheat:283, andAddRedisOutputCaching()at:194; ADCAddRedisCaching()at Conference:184, Notification:110, Engagement:104;ICacheService.IncrementAsyncat:105with theINCRremark at:103;DistributedCacheService's override at:153and its<remarks>at:134-152;IUiReadCachemembers at:50,:59,:78,:85;MMCA.Common.UI/DependencyInjection.cs:63;UiReadCache.cs:163-178with the comparison at:170;EntityServiceBase.cs:266-269,:285and:289;AuthUIService.cs:352(sign-out) and:162(unrefreshable session). TheMMCA.Common.OutputCachemeter subscription cited in the Revision (2026-08-18) is now in the partial fileMMCA.Common.Aspire/Extensions.Telemetry.cs:312, the sixth of nine chainedAddMetercalls (:307-315). Re-checked and unchanged:DependencyInjection.Caching.cs:26,DependencyInjection.cs:134,CacheOptions.cs:23,MiddlewarePipelineStepNames.cs:65,OutputCacheOptionsExtensions.cs:20and:34,RedisCachingExtensions.cs:57,:59,:64,:65,:91,:94,:99, ADC Identity:125and the three StoreAddRedisCaching()calls (:88,:103,:92) and Store Catalog'sAddRedisOutputCaching()at:98.
Revision (2026-10-06)
Three content corrections plus a line-anchor re-verification. No decision changed.
- The client tier invalidates after any write that may have landed.
InvalidateOnSuccessno longer exists;InvalidateAfterWrite(MMCA.Common.UI/Services/Api/EntityServiceBase.cs:300) drops the endpoint prefix on success and on every failure except validation, unprocessable-entity, unauthorized, forbidden and too-many-requests (:302,:309-314), so a 412, 404, 409, 5xx or transport failure invalidates too. The Decision bullet now says so. - The default TTL is host-configurable.
CacheSettings.DefaultDuration(MMCA.Common.Infrastructure/Caching/CacheSettings.cs:32, keyCache:DefaultDuration) defaults toCacheOptions.DefaultDuration, is bound byAddCaching(configuration)(DependencyInjection.Caching.cs:43-46) and is applied by the cache services (DistributedCacheService.cs:72). The 30-second figure still has one home; Tier 1 now names the override. - ADC Conference's count is scoped to its public-read policies. Eleven of its twelve
AddPublicEndpointPolicyregistrations take the bypass list; a thirteenth named policy,ConferenceCache(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:236), is a plain built-inAddPolicybecause a permission-gated controller also references it. Tier 2 now says so. - Line anchors re-verified against current source.
AddCachingis called atDependencyInjection.cs:140. The seven hybrid opt-ins are at ADC Conference:203, Notification:119, Engagement:116, Identity:138, Store Catalog:108, Sales:113, Identity:102; the sevenAddRedisCaching()calls at ADC Conference:185, Notification:111, Engagement:108, Identity:130, Store Catalog:89, Sales:104, Identity:93; the twoAddRedisOutputCaching()calls at ADC Conference:195and Store Catalog:99. Store Catalog's four policies are at:152,:153,:159,:162; ADC Conference'sadminBypassRolesat:266, its bypass policies at:267-268,:274-281and:298,NowNextCacheat:284.MMCA.Common.UI/DependencyInjection.cs:76;EntityServiceBase.cskey rule:239-241,GetCachedAsync:253, bypass path:260-263, generation:273, store:278-281;AuthUIService.cs:374(sign-out) and:171(unrefreshable session). Two anchors recorded only in the Revision (2026-08-18) have moved:RegisterOutputCacheEvictionConsumeris atMMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:158-160andRegisterUpcastedIntegrationEventConsumer<TEvent>at:123-140(now also taking an optional endpoint-configuration callback), and theMMCA.Common.OutputCachemeter subscription is atMMCA.Common.Aspire/Extensions.Telemetry.cs:312. Re-checked and unchanged:DependencyInjection.Caching.cs:26,:139,:200,:206-208,CacheOptions.cs:23and:28-31,RedisCachingExtensions.cs:57,:59,:64,:65,:91,:94,:99, andEntityServiceBase.cs:47and:58.
Revision (2026-10-07)
Re-verified against current source. The two tiers, the substrate selection, the seven-service Redis
and hybrid wiring, ADC Conference's eleven-of-twelve bypass count and the client tier are unchanged.
One behavior description moved, the anchors in three files shifted, and new HybridCacheService
citations were added.
- Eviction in hybrid mode does not reach other replicas' L1. The Trade-offs said that whenever
Redis is configured cached entries are evicted on write and the 30-second TTL is the backstop only
for memory mode. With Redis configured the live substrate is
HybridCacheService, and itsRemoveAsyncandRemoveByPrefixAsyncclear L2 and this process's L1 only: other replicas' L1 copies keep serving the removed value for up toCache:LocalCacheDuration(30 seconds by default,MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:66; the L1 lifetime is the shorter of the entry TTL and that ceiling,:330-335; remarks at:192-195and:210-212, and the class remarks at:30-32). Registering Redis therefore bounds cross-replica staleness rather than removing it, and the prefix scan that runs isHybridCacheService.RemoveByPrefixAsync(:219-237, a warn-once no-op without a multiplexer at:221-228). A read that must see a removal everywhere goes throughGetFromSharedStoreAsync(:154). The Tier 1 TTL bullet and the Trade-offs bullet now say so; ADR-077 owns the substrate itself. - The interface caveat names both stale comments. Besides the
<remarks>sentence anticipating a RedisINCRoverride (MMCA.Common.Application/Interfaces/ICacheService.cs:106), the member's<summary>opens with "Atomically increments" (:94). Neither describes shipped behavior: the default member,DistributedCacheService's override (MMCA.Common.Infrastructure/Caching/DistributedCacheService.cs:153) and the override that runs with Redis configured,HybridCacheService.IncrementAsync(MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:264, L2-only, remarks at:243-248), are all read-modify-write. - Anchors re-verified against current source: ADC Conference
adminBypassRolesatMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:268, its bypass policies at:269-270,:276-283and:300,NowNextCacheat:286,ConferenceCache'sAddPolicyat:237-238and its comment at:232-236;ICacheService.IncrementAsyncatMMCA.Common.Application/Interfaces/ICacheService.cs:108, its<remarks>at:103-107;AuthUIService's read-cache clears atMMCA.Common.UI/Services/Auth/AuthUIService.cs:393(sign-out, insideSignOutLocallyAsyncat:379, reached from:101and:144) and:190(unrefreshable session). The 2026-08-18 anchors forRegisterOutputCacheEvictionConsumer,RegisterUpcastedIntegrationEventConsumer<TEvent>and theMMCA.Common.OutputCachemeter subscription are already re-pointed in the Revision (2026-10-06) and are left as written there.