Heximon Logo
Recipes

Cache an Expensive Call

Wrap a slow read with the Cache facade's getOrSet over a Storage tier, tag the entry for invalidation, and bust it after a write — MemoryStorage, Cache.invalidate, tags.

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.

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

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

src/catalog/product-lookup.ts
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
Tags cost a little: each tagged write adds one marker entry per tag, and those markers live in the durable tier of a multi-tier cache (an LRU-bounded tier would evict them first). Tag what you'll actually invalidate together — a per-entity tag like 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:

test/product-lookup.test.ts
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), QueryCacheInterceptor for CQRS query results, CacheMiddleware for whole HTTP responses, and bounding MemoryStorage by size.
  • Key-value storage — the Storage port 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.
Copyright © 2026