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. Use getOrSet() directly — SWR revalidation is triggered by getOrSet, not get.
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
SWR and @Cached decorator
The @Cached decorator uses get() for cache reads, which does not trigger SWR revalidation. For SWR to work correctly, use getOrSet() directly as shown above.
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