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);
}),
);
}
}
Usage in Controllers
@Controller("/api/posts")
@UseInterceptors(CacheInterceptor)
export class PostsController {
@Get(":id")
@Cached((req) => `posts:${req.params.id}`)
getPost(@Param("id") id: string) {
// ...
}
}
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),
);
}
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
Step 2: Import AopModule
@Module({
imports: [
// ...
AopModule,
],
})
export class AppModule {}
Step 3: Create Decorator Symbol
export const CACHE_DECORATOR = Symbol("CACHE_DECORATOR");
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;
};
}
}
Step 5: Register the Decorator
@Module({
providers: [
// ...
CacheDecorator,
],
})
export class CacheModule {}
Step 6: Create the Decorator Function
export const Cached = (key: Key, ttl = 0) =>
createDecorator(CACHE_DECORATOR, { key, ttl });
Step 7: Use Anywhere in Your Application
export class PostsService {
@Cached((id: string) => `posts:${id}`)
getPostById(id: string) {
// ...
}
}
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 {}
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);
}
}
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);
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);
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)