Heximon Logo
Capabilities

Caching

Multi-tier caching — TieredCache over the Storage port, the Cache facade with single-flight and tag invalidation, QueryCacheInterceptor for CQRS query results, CacheMiddleware for HTTP responses, CacheEnvelope TTL, CdnPurgeAdapter.

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

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.

src/app.module.ts
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:

src/catalog/get-product.handler.ts
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.

src/catalog/catalog.contract.ts
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.

Authenticated routes. A scopes-guarded route is cached only when an app-supplied 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 Storage DI token the tiers bind, and how to swap the driver.
  • CQRS — the query bus whose results QueryCacheInterceptor caches.
  • the flagship app's caching — both consumers over one Cache, with tag invalidation, end-to-end tested.
Copyright © 2026