Response Cache
@modularityjs/http-cache caches GET responses in CacheService and serves conditional requests with weak ETags — swap the cache driver and response caching becomes cross-instance.
@CacheResponse
import { CacheResponse } from '@modularityjs/http-cache';
@Controller('/products')
class ProductsController {
@CacheResponse({ ttlMs: 30_000, varyHeaders: ['accept-language'] })
@Get()
list() {
return this.catalog.expensiveListing();
}
}- First GET: handler runs, the JSON result is stored for
ttlMs, the response carriesETagandX-Cache: miss. - Subsequent GETs: served from the cache (
X-Cache: hit) without invoking the handler. - A request whose
If-None-Matchmatches the ETag gets a body-less 304. - Non-GET/HEAD requests and special return types (
FileResponse,HtmlResponse,SseResponse,RedirectResponse,null/undefined) pass through uncached.
Options:
| Option | Purpose |
|---|---|
ttlMs | Freshness window (required; validated at decoration time) |
scope | Per-caller cache-key dimension — see Private responses below |
varyHeaders | Request headers folded into the cache key — keep the list short (cardinality) |
keyPrefix | Cache-key namespace (default modularityjs:http-cache) |
tags | Passed to CacheService.set — invalidate a group via CacheService.invalidateTag |
Private responses
A cached response is keyed per caller so one user's private data is never served to another. By default the scope is the authenticated identity's id (request.auth, set by http-auth): an authenticated @Get('/me') @CacheResponse(...) caches separately per user. An authenticated request whose scope can't be resolved is not cached at all (fails safe), and an unauthenticated request caches under a shared key as before.
Override with scope to key on something else (or opt a route into shared caching by returning a constant):
@CacheResponse({
ttlMs: 60_000,
scope: (request) => request.headers['x-tenant-id'] as string | undefined,
})
@Get('/dashboard')
dashboard() { /* ... */ }The interceptor also merges its varyHeaders into any Vary header set earlier in the request (e.g. Vary: Origin from @fastify/cors) rather than replacing it, so a shared cache / CDN keeps every dimension.
Wiring
// modules: [
// ModularityModule,
// HttpModule.forRoot({ port: 3000 }),
// HttpFastifyModule,
// CacheModule,
// CacheMemoryModule, // or CacheRedisModule — cache entries become cross-instance
// HttpCacheModule,
// ...
// ]Like @SerializeWith, the decorator is a synthetic per-site interceptor on the UseInterceptor channel — zero adapter changes, portable to any HTTP driver. Using @CacheResponse without HttpCacheModule wired fails with a StateException naming the fix.
Invalidation
Pass tags and call CacheService.invalidateTag('products') after a write, or use a keyPrefix per resource family and delete by key. The cache key is prefix|METHOD|url|scope|varyValues (the scope segment is omitted when there is none), so distinct callers and distinct query strings are distinct entries.