Skip to content

Decorators

Declarative caching with method decorators.

Overview

The cache module provides two decorator families:

Work on any Injectable class (services, repositories, etc.) — not limited to HTTP controllers.

DecoratorPurposeWhen Runs
@CachedCache method resultBefore method
@InvalidateTagsInvalidate by tagsBefore/After method
@InvalidateOnInvalidate + publish eventAfter method

Metadata-Based (Spring-Style)

Require DeclarativeCacheInterceptor in the request pipeline. Only work in controller context (HTTP, GraphQL, or any context with ExecutionContext and the interceptor attached). They do not work on plain service methods called outside the interceptor chain.

DecoratorPurposeWhen Runs
@CacheableCache method resultBefore method
@CacheEvictInvalidate cacheBefore/After method
@CachePutUpdate cache with resultAfter method

Setup for metadata-based decorators:

typescript
import { Module, Controller, UseInterceptors } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { DeclarativeCacheInterceptor } from '@nestjs-redisx/cache';

// Option 1: Per-controller
@Controller('users')
@UseInterceptors(DeclarativeCacheInterceptor)
export class UserController {}

// Option 2: Global (in AppModule)
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: DeclarativeCacheInterceptor },
  ],
})
export class AppModule {}

Why two families?

@Cached is the recommended, full-featured approach — it works everywhere and supports SWR, varyBy, contextKeys, and unless. The Spring-style decorators (@Cacheable/@CacheEvict/@CachePut) exist for developers who prefer the familiar Spring Cache pattern with separated concerns (read / write / evict).

Why @Cached Works Inside Services (Self-Invocation)

@Cached replaces method descriptors directly on the class prototype — no proxy objects wrapping the instance. Internal calls like this.method() go through caching automatically, because the prototype already contains the wrapped version before NestJS creates any instance.

Edge CaseStatusWhy
Self-invocation (this.method())✅ WorksDescriptor replacement on prototype, not proxy object
Background jobs / cron / RMQ✅ WorksNo dependency on HTTP pipeline or ExecutionContext
Callback extraction (const fn = svc.method)⚠️ Standard JS caveatthis lost — use arrow wrapper or .bind()
Inheritance without override✅ WorksPrototype chain preserved
Inheritance with override⚠️ Re-apply decoratorOverride creates new descriptor
Object args in key template⚠️ DocumentedJSON.stringify — order-dependent keys

Edge Cases & Gotchas

Details on each item from the table above.

Callback Extraction — Standard JS Caveat

Extracting a method as a standalone function loses this context. This is standard JavaScript behavior, not specific to RedisX:

typescript
// ❌ this is undefined — will throw
const fn = service.getUser;
await fn('123');

// ❌ same issue with array methods
ids.map(service.getUser);

// ✅ Arrow wrapper preserves context
ids.map(id => service.getUser(id));

// ✅ Explicit bind works too
ids.map(service.getUser.bind(service));

Inheritance — Works with Caveat

Decorated methods are inherited normally. However, if you override a decorated method, the override does not inherit the decorator — you must re-apply it:

typescript
class BaseService {
  @Cached({ key: 'item:{0}' })
  async getItem(id: string) { ... }
}

class ExtendedService extends BaseService {
  // ✅ Inherits @Cached from BaseService (no override)
}

class OverrideService extends BaseService {
  // ⚠️ Override creates new descriptor — @Cached is lost
  async getItem(id: string) {
    const item = await super.getItem(id); // ✅ super still cached
    return { ...item, extra: true };
  }

  // ✅ Re-apply decorator on override
  @Cached({ key: 'item:{0}' })
  async getItemCached(id: string) { ... }
}

Object Arguments in Key Templates

{0}, {1} placeholders use JSON.stringify() for objects. This produces order-dependent, potentially long keys:

typescript
// ⚠️ Different key despite same data
@Cached({ key: 'search:{0}' })
async search(filters: SearchDto) { }
// { a: 1, b: 2 } → 'search:{"a":1,"b":2}'
// { b: 2, a: 1 } → 'search:{"b":2,"a":1}'  ← Different key!

Recommendations for object arguments:

typescript
// ✅ Best: Use primitive arguments with @Cached
@Cached({ key: 'user:{0}' })
async getUser(id: string) { }

// ✅ For DTOs: Extract stable ID and pass as first arg
@Cached({ key: 'order:{0}' })
async getOrder(orderId: string) { }

async getOrderFromDto(dto: OrderDto) {
  return this.getOrder(dto.id); // Primitive key, cached
}

// ✅ For complex queries: Use programmatic getOrSet with custom key
async search(filters: SearchDto) {
  const key = `search:${filters.category}:${filters.sort}:${filters.page}`;
  return this.cache.getOrSet(key, () => this.repo.search(filters));
}

How Cache Keys Are Built

Understanding the key pipeline helps debug "why is my key different?" issues.

1. Base key
   ├─ Explicit: @Cached({ key: 'user:{0}' })  →  "user:123"
   └─ Auto:     @Cached({ ttl: 300 })          →  "UserService:getUser:123"

2. Context enrichment (if contextProvider configured and skipContext !== true)
   ├─ contextKeys (per-decorator or global) resolved from contextProvider
   ├─ varyBy keys resolved from contextProvider (added to contextKeys)
   └─ All context keys sorted alphabetically — order is deterministic
   Result: "user:123:_ctx_:locale.en:tenantId.acme"

3. L2 prefix (added by Redis store, not visible in decorator)
   Result in Redis: "cache:user:123:_ctx_:locale.en:tenantId.acme"
OptionScopeSourceEffect
contextKeysPer-decorator (overrides global)contextProviderReplaces global contextKeys for this method
varyByPer-decorator (additive)contextProviderAdds extra context keys on top of contextKeys
skipContextPer-decoratorDisables all context enrichment
namespace@Cacheable onlyStatic stringPrepends namespace: to key

WARNING

@Cached does not have namespace. Use key prefix instead: @Cached({ key: 'myapp:user:{0}' }).

Proxy-based decorator — works on any Injectable class.

Basic Usage

typescript
import { Injectable } from '@nestjs/common';
import { Cached } from '@nestjs-redisx/cache';
import { User, UserRepository } from '../types';

@Injectable()
export class UserService {
  constructor(private readonly repository: UserRepository) {}

  @Cached({ key: 'user:{0}', ttl: 300 })
  async getUser(id: string): Promise<User> {
    return this.repository.findOne(id);
  }
}

Auto-Generated Key

When key is not specified, a key is generated automatically as ClassName:methodName:args:

typescript
@Cached({ ttl: 300 })
async getUser(id: string) { }
// Key: "UserService:getUser:123"

@Cached({ ttl: 300 })
async search(query: string, limit: number) { }
// Key: "UserService:search:hello:10"

Full Options

typescript
@Cached({
  key: 'user:{0}',                    // Cache key ({0}, {1} for positional args)
  ttl: 300,                           // TTL in seconds
  tags: ['users'],                    // Static tags
  strategy: 'l1-l2',                  // 'l1-only' | 'l2-only' | 'l1-l2'
  condition: (id) => id !== 'admin',  // Skip cache if false
  unless: (result) => !result,        // Don't cache if true
  varyBy: ['locale', 'currency'],     // Additional context keys (from contextProvider)
  swr: { enabled: true, staleTime: 60 },  // Stale-while-revalidate
  contextKeys: ['tenantId'],          // Override global contextKeys
  skipContext: false,                 // Enable context enrichment (default)
})

Key Templates

@Cached uses positional placeholders {0}, {1}, etc.:

typescript
// First argument
@Cached({ key: 'user:{0}' })
async getUser(id: string) { }

// Multiple arguments
@Cached({ key: 'org:{0}:user:{1}' })
async getOrgUser(orgId: string, userId: string) { }

Dynamic Tags

typescript
@Cached({
  key: 'user:{0}',
  tags: (id: string) => [`user:${id}`, 'users'],
})
async getUser(id: string) { }

If the tags function throws, the error is caught and the method result is returned without caching.

Conditional Caching

typescript
@Cached({
  key: 'search:{0}',
  // Only cache if condition passes (before execution)
  condition: (query: string) => query.length > 2,
  // Skip caching if result matches (after execution)
  unless: (result: User[]) => result.length === 0,
})
async searchUsers(query: string): Promise<User[]> { }

VaryBy

Adds additional context keys to the cache key, resolved from contextProvider. This is not HTTP headers — values come from CLS / AsyncLocalStorage / custom context provider.

Requires contextProvider to be configured in plugin options. Ignored if no contextProvider.

typescript
// Different cache entries per locale and currency
@Cached({
  key: 'products:list',
  varyBy: ['locale', 'currency'],
})
async getProducts(): Promise<Product[]> { }
// Key with context: "products:list:_ctx_:currency.USD:locale.en"

Context Keys

contextKeys overrides global contextKeys for this specific method. varyBy adds to them.

typescript
// Override global context — only use tenantId for this method
@Cached({
  key: 'products:{0}',
  contextKeys: ['tenantId'],
})
async getProducts(category: string) { }

// No context at all — shared across all tenants
@Cached({
  key: 'config:app',
  skipContext: true,
})
async getAppConfig() { }

Default Behaviors

ScenarioBehavior
Method returns null / undefinedCached by default. Use unless: (r) => r == null to skip.
Redis connection errorFail-open. Method executes normally, error logged to console.
Cache key validation failsFail-open on read, fail-closed on write (throws CacheKeyError). Key must be non-empty, no whitespace, only a-zA-Z0-9_-:., max 1024 chars (configurable via keys.maxLength).
Tags function throwsError caught, result returned without caching.
strategy: 'l1-l2' but L1 disabledOnly L2 is used. No error.
strategy: 'l1-only' but L1 disabledCache is effectively skipped.

@InvalidateTags

Proxy-based — works on any Injectable class.

typescript
import { Injectable } from '@nestjs/common';
import { InvalidateTags } from '@nestjs-redisx/cache';
import { User, UpdateDto, UserRepository } from '../types';

@Injectable()
export class UserService {
  constructor(private readonly repository: UserRepository) {}

  // Invalidate after method execution
  @InvalidateTags({
    tags: (id: string) => [`user:${id}`, 'users'],
    when: 'after',
  })
  async updateUser(id: string, data: UpdateDto): Promise<User> {
    return this.repository.update(id, data);
  }

  // Invalidate before method execution
  @InvalidateTags({
    tags: (id: string) => [`user:${id}`, 'users'],
    when: 'before',
  })
  async deleteUser(id: string): Promise<void> {
    await this.repository.delete(id);
  }
}

Options

OptionTypeDefaultDescription
tagsstring[] | ((...args) => string[])Tags to invalidate (required)
when'before' | 'after''after'When to invalidate

Default Behaviors

ScenarioBehavior
when: 'after' and method throwsInvalidation does NOT run. The error propagates before invalidation is reached.
when: 'after' and invalidation failsError caught and logged, method result still returned. No race condition — invalidation is awaited.
when: 'before' and invalidation failsError caught and logged, method still executes.
Tags function throwsError caught, method still executes.

@InvalidateOn

Proxy-based — works on any Injectable class. Invalidates cache after method execution and optionally publishes named events for distributed invalidation across service instances.

How it works

@InvalidateOn always runs after the method. The events field defines event names to publish (not external triggers). The decorator:

  1. Executes the method
  2. Invalidates specified tags/keys locally
  3. If publish: true, emits events so other nodes (via AMQP, Redis Pub/Sub, etc.) can perform the same invalidation
typescript
import { Injectable } from '@nestjs/common';
import { InvalidateOn } from '@nestjs-redisx/cache';
import { User, UpdateDto, UserRepository } from '../types';

@Injectable()
export class UserService {
  constructor(private readonly repository: UserRepository) {}

  // Local invalidation only
  @InvalidateOn({
    events: ['user.updated'],
    tags: (result: any, [userId]: any[]) => [`user:${userId}`, 'users'],
  })
  async updateUser(userId: string, data: UpdateDto): Promise<User> {
    return this.repository.update(userId, data);
  }

  // Local + distributed invalidation
  @InvalidateOn({
    events: ['user.deleted'],
    keys: (result: any, [userId]: any[]) => [`user:${userId}`],
    tags: ['users'],
    publish: true,  // Other nodes will also invalidate these tags/keys
  })
  async deleteUser(userId: string): Promise<void> {
    await this.repository.delete(userId);
  }
}

Options

OptionTypeDefaultDescription
eventsstring[]Event names to categorize/publish this invalidation (required)
tagsstring[] | ((result, args) => string[])Tags to invalidate
keysstring[] | ((result, args) => string[])Keys to invalidate directly
condition(result, args) => booleanOnly invalidate if returns true
publishbooleanfalsePublish events for distributed invalidation (requires invalidation.source: 'amqp' or 'custom' in plugin config)

Default Behaviors

ScenarioBehavior
Method throwsInvalidation does NOT run. The error propagates before invalidation is reached.
Invalidation failsError caught and logged, method result still returned. No race condition — invalidation is awaited.
Tags/keys function throwsError caught and logged, method result still returned.
condition returns falseInvalidation skipped, method result returned as-is.
publish: true but no event serviceInvalidation runs locally, publishing silently skipped.

@Cacheable (Spring-Style)

Metadata-based decorator using {paramName} interpolation. Requires DeclarativeCacheInterceptor in the request pipeline.

Basic Usage

typescript
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { Cacheable, DeclarativeCacheInterceptor } from '@nestjs-redisx/cache';
import { User, UserServiceStub } from '../types';

@Controller('users')
@UseInterceptors(DeclarativeCacheInterceptor)
export class UserController {
  constructor(private readonly userService: UserServiceStub) {}

  @Cacheable({ key: 'user:{id}', ttl: 300 })
  @Get(':id')
  async getUser(@Param('id') id: string): Promise<User> {
    return this.userService.findOne(id);
  }
}

Full Options

typescript
@Cacheable({
  key: 'user:{id}',                    // Cache key ({paramName} interpolation, required)
  ttl: 300,                            // TTL in seconds (default: 3600)
  tags: ['users'],                     // Static tags or function
  condition: (id) => id !== 'admin',   // Cache only if true
  keyGenerator: (...args) => 'key',    // Custom key generator
  namespace: 'myapp',                  // Namespace prefix
})

Key Templates

@Cacheable uses named parameter interpolation {paramName}:

typescript
// Parameter by name
@Cacheable({ key: 'user:{id}' })

// Nested property
@Cacheable({ key: 'user:{dto.id}' })

// Multiple parameters
@Cacheable({ key: 'org:{orgId}:user:{userId}' })

@CacheEvict (Spring-Style)

Metadata-based decorator for cache invalidation. Requires DeclarativeCacheInterceptor.

Uses beforeInvocation (boolean) instead of when ('before'/'after') — consistent with Spring Cache convention.

Basic Usage

typescript
import { Controller, Delete, Param, UseInterceptors } from '@nestjs/common';
import { CacheEvict, DeclarativeCacheInterceptor } from '@nestjs-redisx/cache';
import { UserServiceStub } from '../types';

@Controller('users')
@UseInterceptors(DeclarativeCacheInterceptor)
export class UserController {
  constructor(private readonly userService: UserServiceStub) {}

  @CacheEvict({ keys: ['user:{id}'], tags: ['users'] })
  @Delete(':id')
  async deleteUser(@Param('id') id: string): Promise<void> {
    return this.userService.delete(id);
  }
}

Full Options

typescript
@CacheEvict({
  keys: ['user:{id}', 'users:list'],   // Keys to evict ({paramName} templates)
  tags: ['users', 'user-lists'],       // Tags to invalidate (static only)
  allEntries: false,                   // Clear entire cache (default: false)
  beforeInvocation: false,             // Evict before method (default: false)
  condition: (...args) => true,        // Only evict if true
  keyGenerator: (...args) => ['key'],  // Custom key generator
  namespace: 'myapp',                  // Namespace prefix
})

WARNING

@CacheEvict does not support wildcard patterns (user:*) in keys. Use tags for bulk invalidation instead. @CacheEvict tags are static only (string[]). For dynamic tags use @InvalidateTags (proxy-based).

@CachePut (Spring-Style)

Always execute method and update cache with result. Requires DeclarativeCacheInterceptor.

Basic Usage

typescript
import { Body, Controller, Param, Put, UseInterceptors } from '@nestjs/common';
import { CachePut, DeclarativeCacheInterceptor } from '@nestjs-redisx/cache';
import { User, UpdateDto, UserServiceStub } from '../types';

@Controller('users')
@UseInterceptors(DeclarativeCacheInterceptor)
export class UserController {
  constructor(private readonly userService: UserServiceStub) {}

  @CachePut({ key: 'user:{id}', tags: ['users'] })
  @Put(':id')
  async updateUser(@Param('id') id: string, @Body() data: UpdateDto): Promise<User> {
    return this.userService.update(id, data);
  }
}

Full Options

typescript
@CachePut({
  key: 'user:{id}',                    // Cache key ({paramName} interpolation, required)
  ttl: 3600,                           // TTL in seconds (default: 3600)
  tags: ['users'],                     // Static tags or function
  condition: (id) => id !== 'admin',   // Only cache if true
  keyGenerator: (...args) => 'key',    // Custom key generator
  namespace: 'myapp',                  // Namespace prefix
  cacheNullValues: false,              // Cache null/undefined results (default: false)
})
DecoratorChecks CacheAlways ExecutesUpdates Cache
@CacheableYesNo (if hit)On miss
@CachePutNoYesAlways

Combining Decorators

typescript
import { Injectable } from '@nestjs/common';
import { Cached, InvalidateTags, InvalidateOn } from '@nestjs-redisx/cache';
import { User, UpdateDto, UserRepository } from '../types';

@Injectable()
export class UserService {
  constructor(private readonly repository: UserRepository) {}

  // Read: cache result + tag it
  @Cached({
    key: 'user:{0}',
    ttl: 300,
    tags: (id: string) => [`user:${id}`, 'users'],
  })
  async getUser(id: string): Promise<User> {
    return this.repository.findOne(id);
  }

  // Update: invalidate related caches after success
  @InvalidateTags({
    tags: (id: string) => [`user:${id}`, 'users'],
    when: 'after',
  })
  async updateUser(id: string, data: UpdateDto): Promise<User> {
    return this.repository.update(id, data);
  }

  // Delete: invalidate + publish for distributed nodes
  @InvalidateOn({
    events: ['user.deleted'],
    tags: (result: any, [id]: any[]) => [`user:${id}`, 'users'],
    publish: true,
  })
  async deleteUser(id: string): Promise<void> {
    await this.repository.delete(id);
  }
}

Metadata-Based (Spring-Style)

typescript
import { Body, Controller, Delete, Get, Param, Put, UseInterceptors } from '@nestjs/common';
import {
  Cacheable,
  CachePut,
  CacheEvict,
  DeclarativeCacheInterceptor,
} from '@nestjs-redisx/cache';
import { User, UpdateDto, UserServiceStub } from '../types';

@Controller('users')
@UseInterceptors(DeclarativeCacheInterceptor)
export class UserController {
  constructor(private readonly userService: UserServiceStub) {}

  // Read: use cache
  @Cacheable({ key: 'user:{id}', tags: ['users', 'user:{id}'] })
  @Get(':id')
  async getUser(@Param('id') id: string): Promise<User> {
    return this.userService.findOne(id);
  }

  // Update: update cache + invalidate list
  @CachePut({ key: 'user:{id}', tags: ['users'] })
  @CacheEvict({ keys: ['users:list', 'users:count'] })
  @Put(':id')
  async updateUser(@Param('id') id: string, @Body() data: UpdateDto): Promise<User> {
    return this.userService.update(id, data);
  }

  // Delete: invalidate everything related
  @CacheEvict({ tags: ['users'], keys: ['users:list', 'users:count'] })
  @Delete(':id')
  async deleteUser(@Param('id') id: string): Promise<void> {
    return this.userService.delete(id);
  }
}

@CacheEvict after timing

When beforeInvocation: false (default), eviction runs as fire-and-forget — the Promise is not awaited. For awaited invalidation, use @InvalidateTags (proxy-based) instead.

@Cached vs @Cacheable

Feature@Cached@Cacheable
MechanismProxy-basedMetadata + Interceptor
Works onAny InjectableController context only
Works outside HTTP pipelineYes (services, workers, cron, RMQ)No (requires ExecutionContext)
Key syntax{0}, {1} (positional){paramName} (named)
Auto-generated keyYes (Class:method:args)No
SWR supportYesNo
Key pipeline (contextProvider, varyBy, contextKeys, auto key)YesNo (interceptor metadata only)
unlessYesNo
namespaceNo (use key prefix)Yes
Null caching controlYes (unless)No (@CachePut: cacheNullValues)
Invalidation timing@InvalidateTags: when@CacheEvict: beforeInvocation
RecommendedYesFor Spring-style patterns

Next Steps

Released under the MIT License.