Recipes
Common caching patterns and real-world examples.
1. Cache-Aside with getOrSet (Recommended)
The most common pattern. getOrSet() handles cache lookup, stampede protection, and cache write in a single call.
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { User, UpdateUserDto, UserRepository } from '../types';
@Injectable()
export class UserService {
constructor(
private readonly cache: CacheService,
private readonly repository: UserRepository,
) {}
async getUser(id: string): Promise<User> {
return this.cache.getOrSet(
`user:${id}`,
() => this.repository.findById(id),
{ ttl: 3600, tags: [`user:${id}`, 'users'] },
);
}
async updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await this.repository.update(id, data);
await this.cache.invalidateTags([`user:${id}`, 'users']);
return user;
}
}Why getOrSet over get/set:
- Anti-stampede protection — only one loader runs for concurrent requests with the same key
- Atomic — no window between "cache miss" and "cache write" where duplicates can occur
- Less code — single call instead of get → check → load → set
Tag invalidation cost
invalidateTags() deletes all keys linked to the given tags. Cost is O(number of keys per tag). Avoid tagging high-cardinality hot keys with a single broad tag — prefer specific tags like user:123 over just users when possible.
2. Reusable Cached Functions with wrap
cache.wrap() creates a reusable cached function — useful when the same loader is called from multiple places.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { ConfigValue, ConfigRepository } from '../types';
@Injectable()
export class ConfigService implements OnModuleInit {
private getCachedConfig: (key: string) => Promise<ConfigValue>;
constructor(
private readonly cache: CacheService,
private readonly repository: ConfigRepository,
) {}
onModuleInit() {
// Create once in onModuleInit — avoids re-creating closures per request
this.getCachedConfig = this.cache.wrap(
(key: string) => this.repository.findByKey(key),
{
key: (key: string) => `config:${key}`,
ttl: 600,
tags: (key: string) => [`config:${key}`, 'config'],
},
);
}
async get(key: string): Promise<ConfigValue> {
return this.getCachedConfig(key);
}
}3. @Cached Decorator
Declarative caching on method level. Best for simple key patterns with primitive arguments.
import { Injectable } from '@nestjs/common';
import { Cached, InvalidateTags } from '@nestjs-redisx/cache';
import { Product, UpdateProductDto, ProductRepository } from '../types';
@Injectable()
export class ProductService {
constructor(private readonly repository: ProductRepository) {}
@Cached({
key: 'product:{0}',
ttl: 600,
tags: (id: string) => [`product:${id}`, 'products'],
})
async findById(id: string): Promise<Product> {
return this.repository.findById(id);
}
@InvalidateTags({
tags: (id: string) => [`product:${id}`, 'products'],
})
async update(id: string, data: UpdateProductDto): Promise<Product> {
return this.repository.update(id, data);
}
}@Cached key and object arguments
{0}, {1} placeholders use JSON.stringify() for objects. This produces long, order-dependent keys ({a:1, b:2} and {b:2, a:1} produce different keys). Use @Cached with primitive arguments (string, number).
For DTOs, create a thin wrapper that extracts the stable ID:
@Cached({ key: 'order:{0}' })
async getOrder(id: string) { return this.repo.findById(id); }
// Called from controller/handler:
async handleOrderRequest(dto: OrderDto) {
return this.getOrder(dto.id); // Primitive key, cached
}For complex query objects, use getOrSet() or wrap() with a custom key builder that produces a stable, short key.
4. Conditional Caching
Skip caching based on input or result.
import { Injectable } from '@nestjs/common';
import { Cached } from '@nestjs-redisx/cache';
import { Order, OrderRepository } from '../types';
@Injectable()
export class OrderService {
constructor(private readonly repository: OrderRepository) {}
@Cached({
key: 'order:{0}',
ttl: 300,
tags: (id: string) => [`order:${id}`],
// Don't cache if caller requests fresh data
condition: (id: string, options?: { fresh?: boolean }) => !options?.fresh,
// Don't cache empty results
unless: (result: Order | null) => result === null,
})
async findById(id: string, options?: { fresh?: boolean }): Promise<Order | null> {
return this.repository.findById(id);
}
}| Option | When evaluated | Effect |
|---|---|---|
condition | Before method | If false, skip cache entirely (always execute method) |
unless | After method | If true, don't store result in cache |
Cache key is based on id only
The options argument is intentionally not part of the cache key (key: 'order:{0}' uses only {0} = id). The fresh flag bypasses caching entirely via condition — it doesn't create a separate cache entry. All callers share the same cached value for a given id.
5. Pagination
Each page is a separate cache entry. Invalidate all pages when data changes.
import { Injectable } from '@nestjs/common';
import { Cached, InvalidateTags } from '@nestjs-redisx/cache';
import { Post, PaginatedResult, CreatePostDto, PostRepository } from '../types';
@Injectable()
export class PostService {
constructor(private readonly repository: PostRepository) {}
@Cached({
key: 'posts:page:{0}:size:{1}',
ttl: 300,
tags: ['posts:list'],
})
async findPaginated(page: number, size: number): Promise<PaginatedResult<Post>> {
// page is 1-based: page=1 returns first `size` items
return this.repository.findMany({
skip: (page - 1) * size,
take: size,
});
}
@InvalidateTags({ tags: ['posts:list'] })
async create(data: CreatePostDto): Promise<Post> {
return this.repository.create(data);
}
}6. Computed / Expensive Calculations
Cache results of expensive computations. Invalidate programmatically when source data changes.
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { PeriodStats, OrderRepository } from '../types';
@Injectable()
export class AnalyticsService {
constructor(
private readonly cache: CacheService,
private readonly orderRepository: OrderRepository,
) {}
async getStats(period: string): Promise<PeriodStats> {
return this.cache.getOrSet(
`analytics:${period}`,
async () => {
const orders = await this.orderRepository.findByPeriod(period);
const revenue = orders.reduce((sum, o) => sum + o.total, 0);
return {
totalOrders: orders.length,
totalRevenue: revenue,
avgOrderValue: orders.length > 0 ? revenue / orders.length : 0,
};
},
{ ttl: 3600, tags: ['analytics'] },
);
}
async onOrderCreated(): Promise<void> {
await this.cache.invalidateTags(['analytics']);
}
}7. Multi-Tenant Caching
Separate cache per tenant using varyBy with a context provider (CLS / AsyncLocalStorage).
import { Injectable } from '@nestjs/common';
import { Cached } from '@nestjs-redisx/cache';
import { TenantData, DataRepository } from '../types';
@Injectable()
export class TenantDataService {
constructor(private readonly repository: DataRepository) {}
@Cached({
key: 'data:{0}', // {0} = dataId
varyBy: ['tenantId'], // resolved from contextProvider, appended to key
ttl: 600,
tags: (dataId: string) => [`data:${dataId}`],
})
async getData(dataId: string): Promise<TenantData> {
// tenantId comes from context (CLS), not as method argument
return this.repository.findOne(dataId);
}
}Resulting key: data:item-5:_ctx_:tenantId.tenant-abc
varyBy requires contextProvider
varyBy resolves values from the configured contextProvider (CLS, AsyncLocalStorage), not from HTTP headers. Configure it in CachePlugin:
new CachePlugin({
contextProvider: clsService, // implements IContextProvider
contextKeys: ['tenantId'], // global context keys for all @Cached methods
})Use varyBy for per-method additions on top of global contextKeys.
Tag invalidation scope
In the example above, tags: ['data:item-5'] is shared across all tenants. Invalidating that tag clears data:item-5 for every tenant. This is correct when the underlying data is the same for all tenants. If data differs per tenant and you need per-tenant invalidation, include tenant in the tag:
// Per-tenant tags — requires tenantId as method argument
tags: (tenantId: string, dataId: string) => [`data:${tenantId}:${dataId}`],8. Stale-While-Revalidate (SWR)
Serve stale data instantly while refreshing in the background. Both @Cached and getOrSet() support full SWR behavior.
With @Cached
@Cached({
key: 'catalog:categories',
ttl: 300,
tags: ['catalog'],
swr: { enabled: true, staleTime: 120 },
})
async getCategories(): Promise<Category[]> {
return this.repository.findAllCategories();
}With getOrSet()
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { Category, CatalogRepository } from '../types';
@Injectable()
export class CatalogService {
constructor(
private readonly cache: CacheService,
private readonly repository: CatalogRepository,
) {}
async getCategories(): Promise<Category[]> {
return this.cache.getOrSet(
'catalog:categories',
() => this.repository.findAllCategories(),
{
ttl: 300,
tags: ['catalog'],
swr: { enabled: true, staleTime: 120 },
},
);
}
}staleTime is an additional window after TTL — not absolute age since write.
Timeline (ttl: 300, staleTime: 120):
- 0–300s: Fresh data served from cache
- 300–420s: Stale data served instantly, revalidation triggered in background
- 420s+: Cache expired, next request loads fresh data synchronously
9. Batch Operations
Load multiple items efficiently — fetch from cache first, load missing from DB, backfill cache. getMany() returns Array<T | null> — missing keys are strictly null, never undefined.
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { Product, ProductRepository } from '../types';
@Injectable()
export class ProductService {
constructor(
private readonly cache: CacheService,
private readonly repository: ProductRepository,
) {}
async getByIds(ids: string[]): Promise<Map<string, Product>> {
const keys = ids.map(id => `product:${id}`);
const cached = await this.cache.getMany<Product>(keys); // Array<Product | null>
// Collect results and find missing IDs
const results = new Map<string, Product>();
const missingIds: string[] = [];
for (let i = 0; i < ids.length; i++) {
if (cached[i] !== null) {
results.set(ids[i], cached[i]!);
} else {
missingIds.push(ids[i]);
}
}
// Load missing from DB and backfill cache
if (missingIds.length > 0) {
const loaded = await this.repository.findByIds(missingIds);
await this.cache.setMany(
loaded.map(p => ({ key: `product:${p.id}`, value: p, ttl: 3600 })),
);
for (const product of loaded) {
results.set(product.id, product);
}
}
return results;
}
}setMany and strategy
setMany() supports tags per entry, but does not support strategy per entry. If you need per-entry strategy, use individual set() calls.
10. Session Caching
import { Injectable } from '@nestjs/common';
import { Cached, InvalidateTags } from '@nestjs-redisx/cache';
import { Session, SessionRepository } from '../types';
@Injectable()
export class SessionService {
constructor(private readonly repository: SessionRepository) {}
@Cached({
key: 'session:{0}',
ttl: 1800,
tags: (sessionId: string) => [`session:${sessionId}`, 'sessions'],
})
async getSession(sessionId: string): Promise<Session> {
return this.repository.findOne(sessionId);
}
@InvalidateTags({
tags: (sessionId: string) => [`session:${sessionId}`],
})
async destroySession(sessionId: string): Promise<void> {
await this.repository.delete(sessionId);
}
}Choosing the Right Pattern
| Pattern | When to use |
|---|---|
getOrSet() | Default choice — cache-aside with stampede protection |
wrap() | Same loader reused in multiple places |
@Cached | Simple methods with primitive arguments |
get() / set() | Fine-grained control, conditional logic |
getMany() / setMany() | Batch operations |
Next Steps
- Troubleshooting — Debug common issues
- Overview — Back to overview