Caching
A read you've already computed shouldn't be computed again. Heximon's caching tier puts one Cache over an
ordered chain of Storage tiers, then caches reads at two layers on top of it: whole HTTP responses and
individual CQRS query results. Both share the one Cache, so a single tag invalidation clears every layer
at once.
One cache, two consumers
HTTP request QueryBus.execute(query)
│ │
CacheMiddleware QueryCacheInterceptor
(caches the response) (caches the query result)
└───────────────┬──────────────────────┘
▼
Cache ── getOrSet · invalidate(tag)
▼
TieredCache ── L1 (memory) → L2 (Redis / KV) → …
The Storage port underneath is a plain key/value store — caching adds a { value, expiresAt } envelope
on top, so the cache decides freshness, not the driver. That keeps Storage swappable and sidesteps
driver TTL floors (Cloudflare KV rounds its expirationTtl up to 60s).
Because everything round-trips through a JSON-domain Storage, cache only plain data (DTOs): a cache
hit returns plain data, not an instance of the original class.
The QueryCacheInterceptor enforces this —
a query whose result is (or contains) a class instance, Date, Map, Set, or bigint throws a clear error
at the first cache miss, naming the query class and the offending path. Return a read DTO from the query
handler, not a domain entity.
Install
pnpm add @heximon/cache
npm install @heximon/cache
yarn add @heximon/cache
bun add @heximon/cache
The tier chain — TieredCache
Tiers are fastest-first. A read walks L1 → L2 → …; the first non-expired hit is returned and promoted
into every faster tier, so the next read resolves closer to the front. Writes and deletes fan out to all
tiers. Each tier honors both its own ttlSeconds cap and the caller's TTL — the shorter wins — so an
in-process L1 can hold a value for seconds while a shared L2 holds it for minutes.
new TieredCache([
{ storage: l1, ttlSeconds: 5 }, // in-process hot tier
{ storage: l2, ttlSeconds: 300 }, // shared networked tier (Redis / Cloudflare KV)
]);
Wiring
Cache takes a config object: a single Storage (the common case) or a TieredCache for multiple tiers.
There are no framework tier tokens — bind your concrete storage classes (each is its own DI token) and pass
them to a useFactory. For dev, one MemoryStorage is enough; for production, pass a TieredCache with a
networked L2.
import {
Cache,
CacheMiddleware,
QueryCacheInterceptor,
QueryCachePoliciesConfig,
} from "@heximon/cache";
import { Module } from "@heximon/runtime";
import { MemoryStorage } from "@heximon/kv/memory";
export class AppModule extends Module({
providers: [
{ provide: MemoryStorage, useFactory: () => new MemoryStorage() },
{ provide: Cache, useFactory: (storage: MemoryStorage) => new Cache({ storage, ttlSeconds: 86_400 }) },
// The interceptor injects this. It can be empty when each cached query declares its policy on its
// handler (below); it is also where a `keyFn` / a not-your-handler override would live.
{ provide: QueryCachePoliciesConfig, useValue: new QueryCachePoliciesConfig() },
],
http: { middlewares: [CacheMiddleware] },
cqrs: { queryInterceptors: [QueryCacheInterceptor] },
}) {}
ttlSeconds is the default TTL (overridable per call; 0 = no expiry). For multiple tiers, pass
storage: new TieredCache([{ storage: l1, ttlSeconds: 5 }, { storage: l2, ttlSeconds: 300 }]) — tag markers
span the whole chain, so the durable tier retains them even if a bounded L1 evicts.
Caching query results — QueryCacheInterceptor
Listed under cqrs.queryInterceptors, it caches the result of any query that has a policy; a query with none
passes straight through and a hit short-circuits the handler. A policy comes from one of two places.
On the handler — the default, and what scales. @heximon/cache augments the query handler options, so a
handler declares its own policy as its second type argument. The policy sits next to the handler — nothing to
register centrally, nothing left dangling when the query is deleted:
export class GetProductQueryHandler implements QueryHandler<GetProductQuery, { cache: { ttlSeconds: 30; tags: ["product"] } }> {
public handle(query: GetProductQuery): Product | null {
return this.store.find(query.id);
}
}
The compiler forwards this policy onto the query class and the interceptor reads it. Because it is a
compile-time type literal, each value must be a single literal — only ttlSeconds + tags are declarable
here, and a non-literal (e.g. a union ttlSeconds: 30 | 60) is a build error rather than a silent default.
In the central QueryCachePoliciesConfig — the override seam, and the home for a keyFn. Keyed by the
query class you already import (never the compiler's internal message id). An explicit entry wins over
the handler-declared policy, and a keyFn (a runtime function, not expressible as a type literal) belongs
here:
new QueryCachePoliciesConfig()
.for(ReportQuery, { ttlSeconds: 60, keyFn: (q) => `report:${q.from.toISOString()}` })
.for(GetProductQuery, { ttlSeconds: 10 }); // overrides this query's handler-declared policy
Caching HTTP responses — CacheMiddleware
Listed under http.middlewares, it is driven by the matched route's cache options. Declare them on the
route — with the Route builder, or inline on a controller action (Get<"/products/:id", { cache: { maxAge: 2 } }>);
the same directives drive both the server-side cache and the client-facing Cache-Control header.
Route.get("/products/:id")
.pathParams(productParams)
.responses({ 200: productResponse })
.cache({ maxAge: 2, staleMaxAge: 5, tags: ["product"] });
A repeat GET is served from the cache before the controller runs, tagged X-Cache: HIT. Only safe
methods (GET/HEAD) and 2xx JSON responses are cached; within staleMaxAge past maxAge a stale
response is served immediately and refreshed in the background.
principalKey resolves
an identity (folded into the cache key), so a protected response is never served to another principal. List
CacheMiddlewareafter your authentication middleware, and provide the key via a useFactory:
new CacheMiddleware(cache, context, { principalKey: () => auth.subject() }).Invalidation
TTL expiry is the zero-config backstop. Tags are the opt-in precision layer: a route's cache.tags and
a query policy's tags register the entry under each tag, and one call clears every layer registered under
it.
await cache.invalidate("product"); // drops the cached response AND the cached query result
Bind a CdnPurgeAdapter to also purge an edge cache (Cloudflare Cache-Tags, Fastly surrogate keys) on
invalidation; when none is bound the purge is skipped.
Bounding the in-process tier
An in-process MemoryStorage tier grows unbounded on a long-lived process — and entry count alone doesn't
bound memory (100 entries × 1 MiB each is 100 MiB). Cap it by size with maxBytes (the recommended
out-of-memory guard for caching large values), and/or by count with maxEntries; eviction is
least-recently-used:
new MemoryStorage({ maxBytes: 64_000_000 });
Bound the value tier, not the durable tier that holds tag markers — markers are write-only, so an LRU bound would evict them first. In a multi-tier setup the markers default into the unbounded durable tier.
See also
- Key-value storage — the
StorageDI token the tiers bind, and how to swap the driver. - CQRS — the query bus whose results
QueryCacheInterceptorcaches. - the flagship app's caching
— both consumers over one
Cache, with tag invalidation, end-to-end tested.
Scheduled Work
Run work on a wall-clock cron schedule with ScheduledHandler — a process-local timer on Node, the platform cron trigger on Cloudflare, validated at build time.
Key/Value Storage
The abstract Storage DI token and its BatchStorage capability, MemoryStorage with TTL and LRU bounds, and the first-class UpstashStorage, CloudflareKvStorage, RedisStorage, and UnstorageStorage adapters.