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).
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.
AddCaching()(MMCA.Common.Infrastructure/DependencyInjection.cs:150, called fromAddInfrastructure) 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). - 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.
CacheOptions.DefaultExpirationis a 30-second absolute expiration; callers may override per entry. 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.
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 (MMCA.Common.API/Startup/WebApplicationExtensions.cs:104), but 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:35), whoseCacheRequestAsynccaches GET/HEAD regardless of the caller's auth state (MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:71-75, via the private helpersIsCacheableRequest/IsBypassedCalleratMMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:109-113). 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.
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).
- Distributed prefix invalidation needs the multiplexer, and every service now registers it.
DistributedCacheServicecan only scan-and-delete by prefix when anIConnectionMultiplexeris in the container. All seven services now registerAddRedisClient("redis")immediately alongsideAddRedisDistributedCache("redis"), gated by the same redis-connection-string conditional, precisely so the multiplexer is present for SCAN-based prefix eviction (ADC ConferenceMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:112,117, NotificationMMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:99,104, EngagementMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:88,93, IdentityMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:109,114; Store CatalogMMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:72,77, SalesMMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:76,81, IdentityMMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:77,82). So whenever Redis is configured, prefix-based invalidation against Redis is live and cached entries are evicted on write; the 30s TTL is now the backstop only for the no-Redis case (memory mode), where prefix removal self-heals within seconds instead. Single-keyRemoveAsyncis unaffected in either mode. - 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.
- 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).
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-040 (amends this ADR's Tier 2: the adopters' public-read policies cache
authenticated, bearer-carrying requests too, not only anonymous traffic).