DEV Community

Cover image for Adding Cache to NestJS Services Made Easy
Anatoly Kaliuzhny
Anatoly Kaliuzhny

Posted on

Adding Cache to NestJS Services Made Easy

Caching is a fundamental technique for improving application performance, but implementing it cleanly without mixing business logic can be challenging. This article shows how to implement elegant caching solutions for both controllers and services using decorators and Aspect-Oriented Programming (AOP).

The Problem

You have a service method with heavy database queries. You want caching that's reusable, separated from business logic, and easy to maintain.

Solution 1: Traditional Interceptor Approach

The obvious solution in the "NestJS way" is to use a decorator combined with a custom interceptor.

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Observable, of } from "rxjs";
import { tap } from "rxjs/operators";
import { CacheService } from "./cache.service";

export type KeyFn = (request: Request) => string;
export type Key = string | KeyFn;
export type CacheOptions = { key: Key; ttl?: number };

const CACHE_OPTIONS = "CACHE_OPTIONS";
const Cached = (key: Key, ttl = 0) => SetMetadata(CACHE_OPTIONS, { key, ttl });

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly cacheService: CacheService,
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const options = this.reflector.get<CacheOptions>(CACHE_OPTIONS, context.getHandler());
    if (!options) return next.handle();

    const { key, ttl } = options;
    const cacheKey = typeof key === "string" ? key : key(context.switchToHttp().getRequest());
    const cached = await this.cacheService.get(cacheKey);

    if (cached) return of(cached);

    return next.handle().pipe(
      tap(async (data) => {
        if (data) await this.cacheService.set(cacheKey, data, ttl);
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in Controllers

@Controller("/api/posts")
@UseInterceptors(CacheInterceptor)
export class PostsController {
  @Get(":id")
  @Cached((req) => `posts:${req.params.id}`)
  getPost(@Param("id") id: string) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Combining Interceptor and Decorator

For convenience, you can compose the interceptor and decorator into a single decorator:

import { applyDecorators, SetMetadata, UseInterceptors } from "@nestjs/common";
// ...

export function Cached(key: Key, ttl = 0) {
  return applyDecorators(
    SetMetadata(CACHE_OPTIONS, { key, ttl }),
    UseInterceptors(CacheInterceptor),
  );
}
Enter fullscreen mode Exit fullscreen mode

Limitation: Interceptors only work for HTTP controllers, not service methods.

Solution 2: AOP-Based Approach

This is where @toss/nestjs-aop comes to the rescue. It allows you to use decorators anywhere in your application. Configuration is straightforward:

Step 1: Install the Package

pnpm add @toss/nestjs-aop
Enter fullscreen mode Exit fullscreen mode

Step 2: Import AopModule

@Module({
  imports: [
    // ...
    AopModule,
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create Decorator Symbol

export const CACHE_DECORATOR = Symbol("CACHE_DECORATOR");
Enter fullscreen mode Exit fullscreen mode

Step 4: Implement LazyDecorator

// ...
export type KeyFn = (...args: any[]) => string;
export type Key = string | KeyFn;
export type CacheOptions = { key: Key; ttl?: number };

@Aspect(CACHE_DECORATOR)
export class CacheDecorator implements LazyDecorator<any, CacheOptions> {
  constructor(private readonly cacheService: CacheService) {}

  wrap({ method, metadata: { key, ttl } }: WrapParams<any, CacheOptions>) {
    return async (...args: any) => {
      const cacheKey = typeof key === "string" ? key : key(...args);
      const cached = await this.cacheService.get(cacheKey);
      if (cached) return cached;

      const data = await method(...args);
      await this.cacheService.set(cacheKey, data, ttl);
      return data;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Register the Decorator

@Module({
  providers: [
    // ...
    CacheDecorator,
  ],
})
export class CacheModule {}
Enter fullscreen mode Exit fullscreen mode

Step 6: Create the Decorator Function

export const Cached = (key: Key, ttl = 0) =>
  createDecorator(CACHE_DECORATOR, { key, ttl });
Enter fullscreen mode Exit fullscreen mode

Step 7: Use Anywhere in Your Application

export class PostsService {
  @Cached((id: string) => `posts:${id}`)
  getPostById(id: string) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting Up Cache Infrastructure

To make everything work, you need to set up a global cache module. Here's a complete example using Redis:

import { Global, Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CacheService } from "./cache.service";
import { CacheModule as NestCacheModule } from "@nestjs/cache-manager";
import { createKeyv } from "@keyv/redis";
import { EnvironmentVariables } from "../../environment";
import { CacheDecorator } from "./cache.decorator";

@Global() // Make this module available globally
@Module({
  imports: [
    NestCacheModule.registerAsync({
      useFactory: async (
        configuration: ConfigService<EnvironmentVariables, true>,
      ) => {
        const host = configuration.get("APP_REDIS_HOST", { infer: true });
        const port = configuration.get("APP_REDIS_PORT", { infer: true });
        return {
          stores: [createKeyv(`redis://${host}:${port}`)],
        };
      },
      inject: [ConfigService],
      isGlobal: true,
    }),
  ],
  providers: [
    CacheService,
    // Register the AOP cache decorator
    CacheDecorator,
  ],
  exports: [CacheService],
})
export class CacheModule {}
Enter fullscreen mode Exit fullscreen mode

And the corresponding cache.service.ts:

import { Injectable, Inject } from "@nestjs/common";
import { Cache } from "cache-manager";
import { CACHE_MANAGER } from "@nestjs/cache-manager";

@Injectable()
export class CacheService {
  constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}

  async get<T>(key: string): Promise<T | undefined> {
    return this.cache.get<T>(key);
  }

  async set<T>(key: string, value: T, ttl?: number): Promise<void> {
    await this.cache.set<T>(key, value, ttl);
  }

  async delete(key: string): Promise<void> {
    await this.cache.del(key);
  }
}
Enter fullscreen mode Exit fullscreen mode

Extending AOP: Beyond Caching

The beauty of AOP is that you can apply the same pattern to other cross-cutting concerns. Let's explore two more practical examples that demonstrate the power of this approach.

Logging Decorator

export const LOG_DECORATOR = Symbol("LOG_DECORATOR");

export type LogOptions = {
  level?: "log" | "debug" | "warn" | "error";
  includeArgs?: boolean;
  includeResult?: boolean;
};

@Aspect(LOG_DECORATOR)
export class LogDecorator implements LazyDecorator<any, LogOptions> {
  constructor(private readonly logger: Logger) {}

  wrap({ methodName, method, metadata }: WrapParams<any, LogOptions>) {
    const {
      level = "log",
      includeArgs = true,
      includeResult = false,
    } = metadata;

    return async (...args: any[]) => {
      if (includeArgs) {
        this.logger[level](`Calling ${methodName} with args:`, args);
      } else {
        this.logger[level](`Calling ${methodName}`);
      }

      try {
        const result = await method(...args);

        if (includeResult) {
          this.logger[level](`${methodName} returned:`, result);
        } else {
          this.logger[level](`${methodName} completed successfully`);
        }

        return result;
      } catch (error) {
        this.logger.error(`${methodName} failed:`, error);
        throw error;
      }
    };
  }
}

export const Logged = (options: LogOptions = {}) => createDecorator(LOG_DECORATOR, options);
Enter fullscreen mode Exit fullscreen mode

Zod Validation Decorator

import { z } from "zod";
import {
  BadRequestException,
  InternalServerErrorException,
} from "@nestjs/common";

export const VALIDATE_DECORATOR = Symbol("VALIDATE_DECORATOR");

export type ValidateOptions = {
  input?: z.ZodSchema;
  output?: z.ZodSchema;
};

@Aspect(VALIDATE_DECORATOR)
export class ValidateDecorator implements LazyDecorator<any, ValidateOptions> {
  wrap({ method, metadata }: WrapParams<any, ValidateOptions>) {
    const { input, output } = metadata;
    return async (...args: any[]) => {

      if (input) {
        try {
          input.parse(args[0]); // Assuming first arg is the input
        } catch (error) {
          throw new BadRequestException(
            `Validation failed: ${error.message}`,
          );
        }
      }

      const result = await method(...args);

      if (output) {
        try {
          return output.parse(result);
        } catch (error) {
          throw new InternalServerErrorException(
            `Output validation failed: ${error.message}`,
          );
        }
      }

      return result;
    };
  }
}

export const Validated = (options: ValidateOptions) => createDecorator(VALIDATE_DECORATOR, options);
Enter fullscreen mode Exit fullscreen mode

Conclusion

Aspect-Oriented Programming with NestJS provides a powerful way to implement cross-cutting concerns like caching, logging, and validation. The benefits include:

  • Clean Separation: Business logic stays focused and uncluttered
  • Reusability: Decorators work across controllers, services, and any injectable class
  • Composability: Multiple decorators can be combined seamlessly
  • Maintainability: Changes to cross-cutting logic are centralized

While traditional interceptors work well for HTTP-specific scenarios, AOP decorators offer the flexibility to enhance any method in your application. This approach scales beautifully as your application grows, keeping your code clean and your concerns properly separated.

Top comments (0)