Skip to main content

Command Palette

Search for a command to run...

From Decorator Clutter to Clean Code

Combining NestJS Decorators for Readable Controllers

Updated
15 min read
From Decorator Clutter to Clean Code

If you have built an API with NestJS, we can all agree to love (or like😅) its modularity, dependency injection, and out of the box support for powerful utilities like Swagger and Guards. But as your project grows, you might notice a frustrating pattern: your controller files start looking incredibly bloated.

A single, simple endpoint that should take up five lines of code can easily balloon into twenty or thirty lines of pure decorator clutter. You have your HTTP method, the Swagger summary, the success responses, the error responses, the security guards, the rate limiting, and maybe more. This is what developers call decorator hell.

In this guide, we will look at an elegant, production-grade technique to solve this problem. We will learn how to combine multiple decorators into clean, single-point entry custom decorators that make our controllers a joy to read and maintain.

As a massive bonus, we will also look at how to implement a custom database @Transactional decorator using NestJS, PostgreSQL, and Node's native AsyncLocalStorage to manage db transactions effortlessly... just because we can 😌


The Clutter Problem

To understand what we are fixing, let's look at what a standard, fully documented endpoint looks like in NestJS when you do things the default way:

@Post('products')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new product', description: 'Creates a product and stores it in the database.' })
@HttpCode(HttpStatus.CREATED)
@ApiResponse({ status: HttpStatus.CREATED, description: 'Product successfully created.', type: ProductResponseDto })
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid product details provided.' })
@ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'User is not authenticated.' })
async createProduct(@Body() dto: CreateProductDto) {
  return this.productsService.create(dto);
}

Imagine having ten of these endpoints in a single controller. Your files quickly become dominated by boilerplate decorators, making the actual logic hard to spot.

Let's look at how we can turn that entire mess into this:

@ApiPost({
  path: 'products',
  summary: 'Create a new product',
  secure: true,
  responseType: ProductResponseDto,
  errorStatuses: [
    { status: HttpStatus.BAD_REQUEST, message: 'Invalid product details provided.' }
  ]
})
async createProduct(@Body() dto: CreateProductDto) {
  return this.productsService.create(dto);
}

Much cleaner, right? Let's build this setup step by step.


Step 1: Setting up Swagger in main.ts

First, let's make sure NestJS is configured to boot up Swagger. In your entry point file, we set up the basic Swagger configuration.

import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Define the base Swagger options
  const config = new DocumentBuilder()
    .setTitle('API Documentation')
    .setDescription('Clean and modular API docs')
    .setVersion('1.0')
    .addBearerAuth() // Allows passing JWT tokens in Swagger UI
    .build();

  // Create the document and set up the UI route at /docs
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document);

  await app.listen(3000);
}
bootstrap();

Step 2: Defining a Generic Response Envelope

Every clean API should return a standardized response format. For example, your frontend might always expect a structure like this:

{
  "success": true,
  "status": "success",
  "message": "Resource created successfully.",
  "data": { ... }
}

To tell Swagger about this structure without manually creating a new wrapper class for every single DTO, we can write a dynamic class factory. We will place this in response.schema.ts.

import { ApiResponseProperty } from '@nestjs/swagger';
import { Type } from '@nestjs/common';

/**
 * Standard base success response envelope class.
 * This defines the consistent shape of our API success responses.
 */
export class DefaultSuccessResponse<T = undefined> {
  @ApiResponseProperty({ type: String, example: 'success' })
  status: string;

  @ApiResponseProperty({ type: String, example: 'Success' })
  message: string;

  @ApiResponseProperty({ type: Boolean, example: true })
  success: boolean;

  data?: T | null;
}

/**
 * Factory function to create success responses with a customized message.
 * It dynamically generates unique class names to prevent Swagger schema collisions.
 */
export function DefaultResponse(customMessage?: string) {
  const message = customMessage || 'Success.';
  
  class MessageSuccessResponse extends DefaultSuccessResponse {
    @ApiResponseProperty({
      type: String,
      example: message,
    })
    message: string = message;
  }

  // Swagger needs unique names for dynamic schemas.
  // We use a safe Base64 hash suffix to distinguish different custom messages.
  const className = customMessage
    ? `MessageSuccessResponse_${Buffer.from(message).toString('base64').slice(0, 6)}`
    : `MessageSuccessResponse`;

  Object.defineProperty(MessageSuccessResponse, 'name', {
    value: className.replace(/[^a-zA-Z0-9_]/g, ''),
  });

  return MessageSuccessResponse;
}

// In-memory cache to reuse generated schemas instead of recreating them constantly
const typedResponseCache = new Map<string, Type<unknown>>();

/**
 * Dynamic wrapper that encapsulates a nested data class inside our standard response envelope.
 * Very useful for typed endpoints where you want the Swagger UI to inspect the returning DTO details.
 */
export function TypedResponse<T>(
  DataClass: Type<T>,
  customMessage?: string,
): Type<DefaultSuccessResponse<T>> {
  const message = customMessage || 'Success.';
  const cacheKey = `\({DataClass.name}_\){message}`;

  if (typedResponseCache.has(cacheKey)) {
    return typedResponseCache.get(cacheKey) as Type<DefaultSuccessResponse<T>>;
  }

  const defaultResponseClass = DefaultResponse(message);

  class TypedSuccessResponse extends defaultResponseClass {
    @ApiResponseProperty({
      type: String,
      example: message,
    })
    message: string = message;

    // Dynamically reference the nested class for proper Swagger documentation
    @ApiResponseProperty({ type: () => DataClass })
    data: any = null as T;
  }

  const className = customMessage
    ? `\({DataClass.name}Response_\){Buffer.from(message).toString('base64').slice(0, 6)}`
    : `${DataClass.name}Response`;

  Object.defineProperty(TypedSuccessResponse, 'name', {
    value: className.replace(/[^a-zA-Z0-9_]/g, ''),
  });

  typedResponseCache.set(cacheKey, TypedSuccessResponse);

  return TypedSuccessResponse;
}

Step 3: Types and Custom Operation Builders

Now let's create a core set of builders in builders.ts to help us construct common decorators. These builders will construct auth settings, URL path parameters, and error lists cleanly.

Let's define our TypeScript types first in types.ts:

import { Type } from '@nestjs/common';

export interface ApiParamConfig {
  name: string;
  description: string;
  type?: 'number' | 'string';
}

export interface ErrorStatus {
  status: number;
  message: string;
}

export interface SuccessStatus {
  status: number;
  message: string;
}

export interface OperationOptions {
  path: string;
  summary: string;
  description?: string;
  responseType?: Type<any>;
  secure?: boolean;
  params?: ApiParamConfig[];
  successStatus?: SuccessStatus;
  errorStatuses?: ErrorStatus[];
}

export type HttpVerb = 'POST' | 'GET' | 'PATCH' | 'PUT' | 'DELETE';

Now let's build the supporting decorators in builders.ts:

import { HttpStatus, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiParam, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; // Replace with your actual JWT guard location
import { ApiParamConfig, ErrorStatus } from './types';

/**
 * Builds standard security decorators for protected routes.
 * Bundles the Bearer Auth tag, the Jwt guard, and standard authorization failure schemas.
 */
export function buildAuthDecorators() {
  return [
    ApiBearerAuth(),
    UseGuards(JwtAuthGuard),
    ApiResponse({
      status: HttpStatus.UNAUTHORIZED,
      description: 'Credentials are missing or expired.',
    }),
    ApiResponse({
      status: HttpStatus.FORBIDDEN,
      description: 'You do not have permission to access this resource.',
    }),
  ];
}

/**
 * Dynamically registers URL path parameters for Swagger UI documentation.
 */
export function buildParamDecorators(params: ApiParamConfig[]) {
  return params.map(({ name, description, type = 'number' }) =>
    ApiParam({ name, description, required: true, type }),
  );
}

/**
 * Maps raw HTTP error status codes and messages to their corresponding Swagger schemas.
 */
export function buildErrorResponses(...errors: ErrorStatus[]) {
  return errors.map((err) =>
    ApiResponse({
      status: err.status,
      description: err.message,
    }),
  );
}

Step 4: Crafting the Consolidated Route Decorators

With our helpers in place, we can write a centralized factory function that wraps Nest's applyDecorators. This factory will evaluate our custom options object and compile the right mixture of standard Nest and Swagger decorators.

We will place this in operations.ts.

import {
  applyDecorators,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Patch,
  Post,
  Put,
} from '@nestjs/common';
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
import {
  buildAuthDecorators,
  buildErrorResponses,
  buildParamDecorators,
} from './builders';
import { DefaultResponse, TypedResponse } from './response.schema';
import {
  ErrorStatus,
  HttpVerb,
  OperationOptions,
  SuccessStatus,
} from './types';

interface OperationConfig extends OperationOptions {
  verb: HttpVerb;
  successStatus: SuccessStatus;
  defaultDescription: string;
}

/**
 * Core operation builder that bundles the HTTP verb, Swagger documentation metadata,
 * success envelopes, parameters, and optional authentication configurations.
 */
function createOperation(config: OperationConfig) {
  const {
    path,
    verb,
    summary,
    description,
    responseType,
    secure = false,
    params = [],
    successStatus,
    defaultDescription,
    errorStatuses = [],
  } = config;

  // 1. Resolve the correct NestJS HTTP verb method decorator
  let method: MethodDecorator;
  switch (verb) {
    case 'POST':
      method = Post(path);
      break;
    case 'GET':
      method = Get(path);
      break;
    case 'PATCH':
      method = Patch(path);
      break;
    case 'PUT':
      method = Put(path);
      break;
    case 'DELETE':
      method = Delete(path);
      break;
  }

  // 2. Resolve the dynamic success response envelope class
  const DefaultSuccessResponse = DefaultResponse(successStatus.message);
  const responseClass = responseType
    ? TypedResponse(responseType, successStatus.message)
    : DefaultSuccessResponse;

  // 3. Assemble all the standard decorators
  const decorators: Array<MethodDecorator | PropertyDecorator> = [
    method,
    ApiOperation({ summary, description }),
    ApiResponse({
      status: successStatus.status,
      description: successStatus.message ?? defaultDescription,
      type: responseClass,
    }),
    HttpCode(successStatus.status),
    ...buildErrorResponses(...errorStatuses),
  ];

  // 4. Inject authentication and param documentation if required
  if (secure) {
    decorators.push(...buildAuthDecorators());
  }
  if (params.length) {
    decorators.push(...buildParamDecorators(params));
  }

  // 5. Apply all of them as a single decorator
  return applyDecorators(...decorators);
}

// --- Public Custom Decorators ---

export const ApiPost = (opts: OperationOptions) => {
  return createOperation({
    verb: 'POST',
    successStatus: {
      status: HttpStatus.CREATED,
      message: 'Resource created successfully.',
    },
    defaultDescription: 'Resource created successfully.',
    errorStatuses: [
      { status: HttpStatus.BAD_REQUEST, message: 'Invalid input data.' },
    ],
    ...opts,
  });
};

export const ApiGet = (opts: OperationOptions) => {
  return createOperation({
    verb: 'GET',
    successStatus: {
      status: HttpStatus.OK,
      message: 'Resource retrieved successfully.',
    },
    defaultDescription: 'Resource retrieved successfully.',
    ...opts,
  });
};

export const ApiPatch = (opts: OperationOptions) => {
  return createOperation({
    verb: 'PATCH',
    successStatus: {
      status: HttpStatus.OK,
      message: 'Resource updated successfully.',
    },
    defaultDescription: 'Resource updated successfully.',
    errorStatuses: [
      { status: HttpStatus.BAD_REQUEST, message: 'Invalid input data.' },
      { status: HttpStatus.NOT_FOUND, message: 'Resource not found.' },
    ],
    ...opts,
  });
};

export const ApiPut = (opts: OperationOptions) => {
  return createOperation({
    verb: 'PUT',
    successStatus: {
      status: HttpStatus.OK,
      message: 'Resource replaced successfully.',
    },
    defaultDescription: 'Resource replaced successfully.',
    errorStatuses: [
      { status: HttpStatus.BAD_REQUEST, message: 'Invalid input data.' },
      { status: HttpStatus.NOT_FOUND, message: 'Resource not found.' },
    ],
    ...opts,
  });
};

export const ApiDelete = (opts: OperationOptions) => {
  return createOperation({
    verb: 'DELETE',
    successStatus: {
      status: HttpStatus.NO_CONTENT,
      message: 'Resource deleted successfully.',
    },
    defaultDescription: 'Resource deleted successfully.',
    errorStatuses: [
      { status: HttpStatus.NOT_FOUND, message: 'Resource not found.' },
    ],
    ...opts,
  });
};

Step 5: Putting It All Together in a Controller

Now, let's write a generic ProductsController to demonstrate how beautiful, clean, and expressive our endpoint implementations become when using our custom decorators.

import { Body, Controller, HttpStatus } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiGet, ApiPost } from './common/swagger'; // Adjust path accordingly
import { CreateProductDto } from './dtos/create-product.dto';
import { ProductResponseDto } from './dtos/product-response.dto';
import { ProductsService } from './products.service';

@ApiTags('Products')
@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  // POST endpoint to register a new product
  @ApiPost({
    path: '',
    summary: 'Create a new product',
    secure: true,
    responseType: ProductResponseDto,
    successStatus: {
      status: HttpStatus.CREATED,
      message: 'Product created and stored successfully.',
    },
  })
  async create(@Body() dto: CreateProductDto) {
    return this.productsService.create(dto);
  }

  // GET endpoint to fetch a single product
  @ApiGet({
    path: ':id',
    summary: 'Get details of a product',
    secure: false,
    responseType: ProductResponseDto,
    params: [
      { name: 'id', description: 'The unique ID of the product', type: 'number' }
    ],
    errorStatuses: [
      { status: HttpStatus.NOT_FOUND, message: 'Product not found.' }
    ],
  })
  async findOne(@Body() id: number) {
    return this.productsService.findOne(id);
  }
}

Our controller methods are readable again. Junior developers looking at this can immediately grasp the route settings, secure rules, and status formats without scrolling through endless decorators.


Bonus: Building a Sleek @Transactional Decorator

In complex applications, you often run operations that involve mutating several database rows or tables in a single operation. If one database write fails, you must revert all previous operations in that unit. This is a transaction.

Typically, TypeORM forces you to pass a transaction manager or query runner down to your deep services. This is dirty and leaks infrastructure details to layers that shouldn't care about it.

Let's look at how we can implement an elegant transaction system using PostgreSQL, TypeORM, Node's built-in AsyncLocalStorage (ALS), and a custom method decorator.

Part A: What is AsyncLocalStorage?

Think of AsyncLocalStorage like thread-local storage but for asynchronous JavaScript. It allows us to propagate state (like a specific database transactional EntityManager) through an entire asynchronous execution path without passing it manually as a function parameter.

Let's create the ALS module in async-local-storage.ts:

import { AsyncLocalStorage } from 'async_hooks';
import { EntityManager } from 'typeorm';

// This ALS instance will store the active TypeORM transactional EntityManager for the current request execution path.
export const als = new AsyncLocalStorage<EntityManager>();

/**
 * Gets the current active transaction EntityManager from AsyncLocalStorage if it exists,
 * otherwise returns null so standard services fallback to using the default manager.
 */
export function getCurrentEntityManager(): EntityManager | null {
  return als.getStore() || null;
}

Part B: Creating the @Transactional Decorator

Now, let's construct the custom method decorator. We will place this in transaction.decorator.ts.

When we decorate a service method with @Transactional(), the decorator will:

  1. Intercept the service call.

  2. Grab the DataSource from the class instance.

  3. Construct a database QueryRunner to connect and start a database transaction.

  4. Mount the transaction's specific EntityManager into our AsyncLocalStorage instance.

  5. Invoke the actual, original service method inside als.run(...).

  6. Automatically commit if everything goes smoothly, or rollback if an exception is thrown.

import { DataSource } from 'typeorm';
import { als } from './async-local-storage';

/**
 * Custom method decorator that intercepts execution and wraps it in a database transaction.
 * Requires the target class instance to have a registered `dataSource` property.
 */
export function Transactional() {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ) {
    // Keep a reference to the original class method logic
    const originalMethod = descriptor.value as (...args: any[]) => unknown;

    descriptor.value = async function (
      this: { dataSource: DataSource },
      ...args: any[]
    ) {
      // CRITICAL REQUIREMENT: Grabs the injected DataSource from the class instance
      const dataSource = this.dataSource;

      if (!dataSource) {
        throw new Error(
          'DataSource not found. Make sure to inject DataSource in the service constructor.',
        );
      }

      // Initialize connection and start a TypeORM transaction
      const queryRunner = dataSource.createQueryRunner();
      await queryRunner.connect();
      await queryRunner.startTransaction();

      try {
        const entityManager = queryRunner.manager;

        // Run the original method within the ALS context, saving the dynamic transaction manager
        const result = await als.run(entityManager, async () => {
          return (await originalMethod.apply(this, args)) as unknown;
        });

        // If no errors occur during method execution, commit all changes
        await queryRunner.commitTransaction();
        return result;
      } catch (error) {
        // If anything fails in the try block, rollback immediately to prevent corrupt database states
        await queryRunner.rollbackTransaction();
        throw error;
      } finally {
        // Always release the database query runner back to the connection pool
        await queryRunner.release();
      }
    };

    return descriptor;
  };
}

Part C: Why Is DataSource Required in the Service?

You might wonder: why do we assert this.dataSource inside our decorator?

Decorators in TypeScript are executed once when the classes are loaded by the JavaScript runtime. During this loading phase, the NestJS Dependency Injection container hasn't built or injected instances yet. This means the decorator cannot simply "inject" a DataSource directly into its body.

Instead, when the method is actually called at runtime, the execution context (this) will point to our instantiated service. By ensuring the service class injects DataSource inside its constructor, our decorator can easily grab this.dataSource directly from the active runtime instance.

Let's see this in action inside a generic service:

import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './product.entity';
import { Inventory } from './inventory.entity';
import { Transactional } from './transaction.decorator';
import { getCurrentEntityManager } from './async-local-storage';

@Injectable()
export class ProductsService {
  constructor(
    // 1. We MUST inject the DataSource so the @Transactional decorator can access it at runtime.
    public readonly dataSource: DataSource,

    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,

    @InjectRepository(Inventory)
    private readonly inventoryRepository: Repository<Inventory>,
  ) {}

  /**
   * Safe helper method to fetch the correct TypeORM repository.
   * If a transaction is currently active in the ALS context, it uses the active transactional manager.
   * Otherwise, it defaults back to using the standard injected repository.
   */
  private getProductRepository(): Repository<Product> {
    const manager = getCurrentEntityManager();
    return manager ? manager.getRepository(Product) : this.productRepository;
  }

  private getInventoryRepository(): Repository<Inventory> {
    const manager = getCurrentEntityManager();
    return manager ? manager.getRepository(Inventory) : this.inventoryRepository;
  }

  @Transactional()
  async createProductAndInitializeInventory(name: string, price: number, initialStock: number) {
    // Grab the safe repositories (which automatically respect the active transaction)
    const productRepo = this.getProductRepository();
    const inventoryRepo = this.getInventoryRepository();

    // Perform database operations
    const product = productRepo.create({ name, price });
    const savedProduct = await productRepo.save(product);

    const inventory = inventoryRepo.create({ productId: savedProduct.id, quantity: initialStock });
    await inventoryRepo.save(inventory);

    // If initialStock is negative, an error is thrown, and the entire creation is safely rolled back
    if (initialStock < 0) {
      throw new Error('Stock cannot be negative');
    }

    return savedProduct;
  }
}

Summary

By leveraging custom combined decorators, we completely clear the clutter out of our NestJS controllers. Our route code goes back to being brief, highly readable, and highly maintainable, without sacrificing rich Swagger documentation.

Additionally, using Node's native AsyncLocalStorage and the custom @Transactional decorator, we can seamlessly manage complex database operations cleanly, without complicating our service signatures.

Try implementing these patterns on your next project to take your NestJS application architecture to the next level.