Cache an Expensive Call
A product lookup that hits a slow upstream, or a report that scans a big table, shouldn't run on every
request when the answer hasn't changed. Cache.getOrSet wraps the read: a hit returns the cached value, a
miss runs your factory once — even under concurrent callers — and caches the result.
Bind a Cache
Cache takes a config object: a Storage for one tier (the common case), or a TieredCache for several.
There's no framework tier token — bind your own storage bare, and hand it to a useFactory for the Cache
itself, since its ttlSeconds is a literal config argument DI can't express.
import { Cache } from "@heximon/cache";
import { Module } from "@heximon/runtime";
import { MemoryStorage } from "@heximon/kv/memory";
export class AppModule extends Module({
providers: [
MemoryStorage,
{ provide: Cache, useFactory: (storage: MemoryStorage) => new Cache({ storage, ttlSeconds: 86_400 }) },
],
}) {}
ttlSeconds is the default TTL applied when a call doesn't pass its own (0 = no expiry). One
MemoryStorage is enough for dev; swap in a networked Storage for production without touching a caller.
Wrap the slow read
getOrSet(key, factory, options) checks the cache first; on a miss it runs factory, caches the result,
and returns it. Concurrent misses for the same key share one factory call (single-flight), so a burst of
requests for a cold key still hits the upstream once.
import { Cache } from "@heximon/cache";
export class ProductLookup {
public constructor(
private readonly cache: Cache,
private readonly upstream: SlowProductApi,
) {}
public async find(sku: string): Promise<Product> {
return this.cache.getOrSet(
`product:${sku}`,
() => this.upstream.fetch(sku),
{ ttlSeconds: 30, tags: [`product:${sku}`] },
);
}
}
A cached hit returns plain data, not a class instance — the value round-trips through a JSON-domain
Storage, so cache DTOs, not domain entities with methods.
Tag it, then bust it on write
TTL alone means a write has to wait out the expiry before readers see it. Tags fix that: register a key under one or more tags at write time, then drop every key under a tag with one call — no need to reconstruct or guess every cache key that touched that product.
public async updatePrice(sku: string, priceCents: number): Promise<void> {
await this.upstream.updatePrice(sku, priceCents);
await this.cache.invalidate(`product:${sku}`); // drops every key tagged with it
}
await cache.invalidate("product"); // a coarser tag clears every key registered under it, across tiers
product:${sku}, not one tag per field.Prove the miss/hit/bust cycle
A Cache is a plain class, so a test constructs it directly — no compiler, no running server — and
asserts the upstream ran once, not once per call:
import { Cache } from "@heximon/cache";
import { MemoryStorage } from "@heximon/kv/memory";
import { expect, test, vi } from "vitest";
import { ProductLookup } from "../src/catalog/product-lookup";
test("caches a lookup, then a price update busts it", async () => {
const cache = new Cache({ storage: new MemoryStorage() });
const fetch = vi.fn(async () => ({ sku: "ABC-1", priceCents: 1999 }));
const upstream = { fetch, updatePrice: vi.fn() };
const lookup = new ProductLookup(cache, upstream);
await lookup.find("ABC-1");
await lookup.find("ABC-1"); // served from cache
expect(fetch).toHaveBeenCalledTimes(1);
await lookup.updatePrice("ABC-1", 2499);
await lookup.find("ABC-1"); // tag invalidated the entry — runs again
expect(fetch).toHaveBeenCalledTimes(2);
});
See also
- Caching — the full tier chain (
TieredCache, L1/L2),QueryCacheInterceptorfor CQRS query results,CacheMiddlewarefor whole HTTP responses, and boundingMemoryStorageby size. - Key-value storage — the
Storageport a cache tier binds, and its networked drivers. - the flagship app's caching — both
consumers over one
Cache, with tag invalidation, end-to-end tested.
Upload a File
Accept a multipart/form-data upload validated by Route.files(), read it with readValidatedFormData(), and stream it into the abstract BlobStore DI token — swappable for filesystem, S3, or R2 with no controller change.
Paginate a List
Validate a list route's filter/order/page query with QuerySchema.create, and shape its response with PaginatedResponseSchema — capped page size, coerced numeric operands, a total-count envelope.