caching
why
a cache promises a fresh answer cheaply; a second key, owner, or invalidation path silently reverses that promise — entries no event can reach are unbounded stale data wearing a cache's name. one canonical identity and one invalidation path keep the cache a derived durable fact, provably consistent with its source, not a second drifting source of truth.
flowchart LRF[underlying durable facts] -->|compute| P[projection]P -->|one key from shared factory| K[(cache entry)]S[source change event] -->|invalidate narrowest family| KU[reader] --> KK -.stale past contract.-> M[marked stale · never silently served]
rule
a cache entry is a derived durable fact: one canonical identity, one owning boundary, an explicit freshness contract, an invalidation path that reaches it. cannot name all four → not a cache, just unbounded stale data.
failure modes:
- no invalidation path → serves stale answers indefinitely (profile update never reaches a route cache; old name served for an hour or until restart).
- wrong cache family invalidated → some surfaces unrefreshed, others nuked and recomputing.
- two identities for one projection → two caches in two services drift apart as mutations touch only one; "which cache am i looking at" at 3 a.m.
identity and ownership
| concern | owner |
|---|---|
| cache identity | the same domain query-key factory the client uses — query key ownership. |
| server-side entry | the domain service or projection that computes the answer. |
| freshness signal | the source that changes the underlying facts — event, hook, or schedule. |
| client cache | TanStack Query, per TanStack Query. |
server and client share the key factory vocabulary. a projection under two identities is two caches drifting toward different answers.
freshness contract
declare freshness at design time, in the slice or contract:
| pattern | when |
|---|---|
| event invalidation | the source publishes a trusted change event; the entry invalidates on receipt. |
| write-through invalidation | the owning mutation invalidates or updates the smallest affected key family. |
| ttl fallback | a background-refresh floor for entries whose source emits no events. |
| no cache | the fact is per-request (authorization decisions, rate-limit state) or intentionally always fresh. |
consumer status projection records the canonical pattern: durable facts first, events as freshness signals, one projection with one read path.
do not
- create a cache identity outside the key factory.
- invalidate the whole cache after a local mutation — narrowest affected family only.
- cache authorization verdicts, rate-limit counters, or other per-request truth.
- serve a stale entry past its declared freshness without the interface marking it stale, per truthful product states.
- store a cache with no named invalidation path because "it'll be fine".
verification
- list every cache identity; each must name owner, freshness contract, invalidation path.
- mutate an underlying fact: affected entries invalidate (or refresh), unaffected families do not.
- load the surface twice; the second read comes from the entry, not a fresh computation — prove it by log or counter at the computing boundary.
good and bad
bad
// service Aconst key = `orders:${userId}`;// service B, same projectionconst cacheKey = `user-${userId}-orders`;// both invalidated by clearAll() after any mutation
two identities for one projection, invalidated wholesale: caches drift toward different answers; one mutation nukes unrelated families; users see different data depending on which service answered; stale entries survive indefinitely because no event reaches their key; every miss after a wholesale clear is a full recompute storm; "which cache am i looking at" becomes the price of every inconsistency report.
good
const key = orderKeys.forUser(userId); // shared key factory// owning mutation invalidates the narrowest family:queryClient.invalidateQueries({ queryKey: orderKeys.forUser(userId) });
one identity from the shared factory, one owner, smallest affected family invalidated — unaffected entries stay warm. the cache stays provably consistent with its source; invalidation cost stays proportional to the mutation, not the whole cache. server and client readers share one key vocabulary, removing the "works on one surface, stale on another" defect class.