Enhancing Application Behavior with AOP Decorators in NestJS and tsyringe
Emily Parker
Product Engineer · Leapcell

Introduction
In modern software development, applications often share common, repetitive tasks that span across various modules or components. These tasks, known as cross-cutting concerns, might include logging, caching, authentication, error handling, or transaction management. While essential for robust applications, scattering their logic throughout the codebase can lead to code duplication, increased complexity, and reduced maintainability – a phenomenon often referred to as "callback hell" or "spaghetti code" at scale. This is where Aspect-Oriented Programming (AOP) comes to the rescue. AOP provides a powerful paradigm to modularize these cross-cutting concerns, separating them from the core business logic.
In the JavaScript and TypeScript ecosystem, particularly within frameworks like NestJS and dependency injection libraries like tsyringe, decorators offer an elegant and idiomatic way to implement AOP principles. They allow us to declaratively inject behaviors into classes and methods without altering their original implementation, greatly improving code readability and maintainability. This article will delve into how we can leverage decorators in NestJS and tsyringe to achieve practical AOP implementations for common scenarios like logging and caching, ultimately leading to cleaner, more efficient, and easier-to-manage applications.
Understanding the Building Blocks of AOP
Before diving into practical examples, let's clarify some key AOP terminology that will be central to our discussion:
- Aspect: A modular unit of cross-cutting concern. An aspect encapsulates the behavior that cuts across multiple parts of the application, such as "logging" or "caching."
- Join Point: A specific point in the execution of a program where an aspect can be applied. In object-oriented programming, this typically includes method calls, method execution, constructor calls, and field access.
- Advice: The action taken by an aspect at a particular join point. Advice defines what the aspect does. Common types of advice include:
- Before advice: Executes before a join point.
- After advice: Executes after a join point (regardless of outcome).
- After returning advice: Executes only if a join point completes successfully.
- After throwing advice: Executes only if a join point throws an exception.
- Around advice: Wraps around a join point, allowing custom logic to run before and after, even modifying the arguments or return value. This is the most powerful type of advice.
- Pointcut: A set of join points where an advice should be applied. Pointcuts specify where the aspect's behavior should be injected. In our decorator-based approach, the presence of the decorator itself often defines the pointcut.
- Weaving: The process of combining aspects with the core application code to create the final executable system. With decorators, this weaving happens at compile-time or runtime depending on the TypeScript compilation and JavaScript execution.
Decorators in TypeScript provide a syntactic sugar that allows us to annotate classes, methods, accessors, properties, or parameters. When used for AOP, they effectively act as pointcuts, and the logic within the decorator function implements the advice.
AOP Implementation with Decorators: Logging and Caching
Let's explore how to implement AOP for logging and caching using decorators in a NestJS context, which also demonstrates principles applicable to tsyringe.
Logging Aspect
A common requirement is to log the execution of methods, including their arguments and return values, and any errors that might occur. This helps in debugging and monitoring.
// log.decorator.ts import { Logger } from '@nestjs/common'; export function LogMethod( logLevel: 'log' | 'error' | 'warn' | 'debug' | 'verbose' = 'log', logArgs: boolean = true, logResult: boolean = true, logError: boolean = true, ) { const logger = new Logger('LogMethod'); return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const methodName = `${target.constructor.name}.${propertyKey}`; if (logArgs) { logger[logLevel](`Calling ${methodName} with args: ${JSON.stringify(args)}`); } else { logger[logLevel](`Calling ${methodName}`); } try { const result = await originalMethod.apply(this, args); if (logResult) { logger[logLevel]( `Method ${methodName} returned: ${JSON.stringify(result)}`, ); } return result; } catch (error) { if (logError) { logger.error( `Method ${methodName} threw an error: ${error.message}`, error.stack, ); } throw error; // Re-throw the error so the original logic can handle it } }; return descriptor; }; }
Now, let's apply this decorator to a NestJS service:
// hero.service.ts import { Injectable } from '@nestjs/common'; import { LogMethod } from './log.decorator'; interface Hero { id: number; name: string; } @Injectable() export class HeroService { private heroes: Hero[] = [ { id: 1, name: 'Superman' }, { id: 2, name: 'Batman' }, ]; @LogMethod('verbose', true, true, true) async findAllHeroes(): Promise<Hero[]> { // Simulate async operation await new Promise((resolve) => setTimeout(resolve, 100)); return this.heroes; } @LogMethod('error', true, false, true) async findHeroById(id: number): Promise<Hero> { await new Promise((resolve) => setTimeout(resolve, 50)); const hero = this.heroes.find((h) => h.id === id); if (!hero) { throw new Error(`Hero with ID ${id} not found.`); } return hero; } }
In this example, @LogMethod acts as an Around advice. It intercepts the findAllHeroes and findHeroById methods, logs their execution, arguments, and results (or errors), and then proceeds with the original method execution. Notice how the business logic remains clean, unencumbered by logging statements.
Caching Aspect
Caching is another crucial cross-cutting concern, especially for performance optimization. We can create a decorator to cache method results.
// cache.decorator.ts import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject } from '@nestjs/common'; import { Cache } from 'cache-manager'; export function CacheResult(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { // NestJS provides a way to inject dependencies into decorators at runtime // For a more robust solution, especially with tsyringe, // you might need a custom decorator factory or a service locator pattern // to access the cache manager. Here, we assume CacheManager is available. let cacheManager: Cache; try { // This is a simplified access; in a real NestJS app, // you'd typically inject CacheManager via constructor. // For decorators, you might need a custom provider or module setup // to make it available closures or use the Inject decorator more directly. // For demonstration, we'll assume 'this' can eventually resolve it // or a global mechanism exists. // A better approach for NestJS might be using an Interceptor for caching. // However, for pure decorator AOP, we simulate it. // If using tsyringe, you could inject it directly if the decorator // is applied to a class instance where tsyringe manages dependencies. // A truly framework-agnostic decorator for caching would need a way // to get the cache instance. For now, let's mock it or assume availability. // Here, we'll demonstrate using a basic in-memory cache for simplicity // or assume the framework provides a way. cacheManager = (this as any).cacheManager; // Assuming cacheManager is injected into the class if (!cacheManager) { throw new Error('CacheManager not available in this context for CacheResult decorator.'); } } catch (e) { console.warn('CacheManager not found for decorator, proceeding without cache. Error:', e.message); // Fallback to original method if cache manager isn't available return originalMethod.apply(this, args); } const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheManager.get(cacheKey); if (cachedResult) { console.log(`Cache hit for ${cacheKey}`); return cachedResult; } console.log(`Cache miss for ${cacheKey}, executing original method.`); const result = await originalMethod.apply(this, args); await cacheManager.set(cacheKey, result, ttlSeconds * 1000); // ttl in milliseconds return result; }; return descriptor; }; }
Note on CacheManager Injection in Decorators: Directly injecting CACHE_MANAGER (or any service) into a method decorator is not straightforward in NestJS/TypeScript because decorators execute at module loading time, before instances are created or dependency injection occurs. The example above makes a simplifying assumption that cacheManager is available on this. In a practical NestJS application, a more robust way to implement caching AOP would be to use Interceptors or create a custom provider that wraps the decorator factory with dependency injection capabilities.
For libraries like tsyringe, you might leverage its container to resolve dependencies within a decorator factory if you wrap your class in a way that allows for it. Here's a conceptual way you might achieve it with tsyringe for accessing a CacheService:
// cache.service.ts import { injectable, container } from 'tsyringe'; @injectable() export class CacheService { private cache = new Map<string, any>(); async get<T>(key: string): Promise<T | undefined> { return this.cache.get(key); } async set<T>(key: string, value: T, ttlMs: number): Promise<void> { this.cache.set(key, value); if (ttlMs > 0) { setTimeout(() => this.cache.delete(key), ttlMs); } } // Register CacheService with tsyringe static register() { container.registerSingleton(CacheService); } } // In your main application setup: // CacheService.register(); // cache.decorator.ts (tsyringe-aware) import { container } from 'tsyringe'; import { CacheService } from './cache.service'; export function CacheResultTsyringe(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const cacheService = container.resolve(CacheService); // Resolve from tsyringe container const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheService.get(cacheKey); if (cachedResult) { console.log(`Cache hit for ${cacheKey}`); return cachedResult; } console.log(`Cache miss for ${cacheKey}, executing original method.`); const result = await originalMethod.apply(this, args); await cacheService.set(cacheKey, result, ttlSeconds * 1000); return result; }; return descriptor; }; }
Then, you would use @CacheResultTsyringe on your methods in classes that are themselves managed by tsyringe.
// hero.service.ts (tsyringe-managed) import { injectable } from 'tsyringe'; import { CacheResultTsyringe } from './cache.decorator'; interface Hero { id: number; name: string; } @injectable() export class HeroServiceTsyringe { private heroes: Hero[] = [ { id: 1, name: 'Wonder Woman' }, { id: 2, name: 'Aquaman' }, ]; @CacheResultTsyringe(30) // Cache for 30 seconds async findAllHeroes(): Promise<Hero[]> { console.log('Fetching all heroes from data source...'); await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate delay return this.heroes; } } // Usage: // const heroService = container.resolve(HeroServiceTsyringe); // await heroService.findAllHeroes(); // First call - cache miss // await heroService.findAllHeroes(); // Second call - cache hit
Application Scenarios
- Logging: Beyond basic method entry/exit, AOP logging can be used for auditing specific critical operations, tracking performance metrics (method execution time), or logging sensitive data access.
- Caching: Essential for high-performance applications, especially for read-heavy operations accessing databases or external APIs. You can cache database queries, API responses, or computationally expensive function results.
- Authentication/Authorization: A decorator can check user permissions before allowing a method to execute.
- Transaction Management: Ensuring a series of database operations either all succeed or all fail together. A decorator can wrap a method in a database transaction.
- Input Validation: Decorators can validate method arguments automatically before the core logic executes.
- Error Handling: A decorator can catch specific exceptions, adorn them with additional context, or trigger custom fallback behaviors.
Conclusion
Decorators in NestJS and with tsyringe provide a highly effective way to implement Aspect-Oriented Programming, allowing developers to manage cross-cutting concerns like logging and caching cleanly and efficiently. By centralizing these common behaviors into reusable decorators, we significantly reduce boilerplate code, enhance modularity, and improve the overall maintainability and readability of our applications. AOP, powered by decorators, empowers us to build robust and scalable systems where business logic remains focused and clear, while essential infrastructure concerns are seamlessly handled behind the scenes. This approach leads to more agile development and easier adaptation to changing requirements.

