Skip to content

Recipes

Common caching patterns and real-world examples.

The most common pattern. getOrSet() handles cache lookup, stampede protection, and cache write in a single call.

typescript
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.

typescript
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.

typescript
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:

typescript
@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.

typescript
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);
  }
}
OptionWhen evaluatedEffect
conditionBefore methodIf false, skip cache entirely (always execute method)
unlessAfter methodIf 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.

typescript
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.

typescript
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).

typescript
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:

typescript
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:

typescript
// 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.

typescript
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.

typescript
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

typescript
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

PatternWhen to use
getOrSet()Default choice — cache-aside with stampede protection
wrap()Same loader reused in multiple places
@CachedSimple methods with primitive arguments
get() / set()Fine-grained control, conditional logic
getMany() / setMany()Batch operations

Next Steps

Released under the MIT License.