Web Development

NestJS Patterns for Scalable APIs

January 11, 2026 9 min read
NestJS Patterns for Scalable APIs

Your NestJS app works great with 5 endpoints. The code is clean, the structure makes sense, and everything fits in your head. But at 50 endpoints? 500? That’s when architecture decisions made early either save you or haunt you.

NestJS provides the building blocks -modules, pipes, filters, guards -but doesn’t prescribe how to assemble them. This guide covers three foundational patterns that determine whether your API scales gracefully or collapses under its own weight: module design, validation strategies, and centralized error handling.

What You’ll Learn

PatternProblem It Solves
Feature ModulesCode organization that scales with team size
DTO ValidationInput sanitization that catches errors early
Exception FiltersConsistent error responses across the API

These aren’t theoretical patterns -they’re extracted from production APIs handling real traffic.


Module Design for Scalability

The module system is NestJS’s killer feature. Used well, it creates natural boundaries that make large codebases navigable. Used poorly, it creates a tangled mess of circular dependencies.

The Feature Module Pattern

One module per domain. One domain per module. This sounds obvious, but the temptation to create “utility” modules that reach into everything is strong -and destructive.

graph TB
    subgraph App["Application"]
        AppModule[AppModule]
    end

    subgraph Core["Core Layer"]
        CoreModule[CoreModule]
        ConfigService[ConfigService]
        LoggerService[LoggerService]
    end

    subgraph Features["Feature Modules"]
        UsersModule[UsersModule]
        OrdersModule[OrdersModule]
        PaymentsModule[PaymentsModule]
    end

    AppModule --> CoreModule
    AppModule --> UsersModule
    AppModule --> OrdersModule
    AppModule --> PaymentsModule

    UsersModule --> CoreModule
    OrdersModule --> CoreModule
    PaymentsModule --> CoreModule

    CoreModule --> ConfigService
    CoreModule --> LoggerService

    style AppModule fill:#8b5cf6,color:#fff
    style CoreModule fill:#3b82f6,color:#fff
    style UsersModule fill:#10b981,color:#fff
    style OrdersModule fill:#10b981,color:#fff
    style PaymentsModule fill:#10b981,color:#fff

Each feature module encapsulates everything related to its domain:

src/
├── modules/
│   ├── users/
│   │   ├── users.module.ts
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── dto/
│   │   │   ├── create-user.dto.ts
│   │   │   └── update-user.dto.ts
│   │   ├── entities/
│   │   │   └── user.entity.ts
│   │   └── interfaces/
│   └── orders/
│       └── ...
├── common/
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   └── pipes/
└── config/

The rule: if you’re importing something from another feature module, pause. Either extract the shared logic into common/, or you’ve found a domain boundary that needs rethinking.

Core and Shared Modules

Two special modules handle cross-cutting concerns:

CoreModule - App-wide singletons loaded once at startup:

@Global()
@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true })],
  providers: [LoggerService, PrismaService],
  exports: [LoggerService, PrismaService],
})
export class CoreModule {}

SharedModule - Reusable utilities that feature modules import explicitly:

@Module({
  providers: [DateHelperService, SlugGeneratorService],
  exports: [DateHelperService, SlugGeneratorService],
})
export class SharedModule {}

Use @Global() sparingly. When everything is global, nothing has clear ownership.

Dynamic Modules for Configuration

When a module needs runtime configuration, use the forRoot() / forRootAsync() pattern:

@Module({})
export class DatabaseModule {
  static forRootAsync(options: DatabaseModuleAsyncOptions): DynamicModule {
    return {
      module: DatabaseModule,
      imports: options.imports || [],
      providers: [
        {
          provide: DATABASE_OPTIONS,
          useFactory: options.useFactory,
          inject: options.inject || [],
        },
        DatabaseService,
      ],
      exports: [DatabaseService],
    };
  }
}

Usage in AppModule:

@Module({
  imports: [
    DatabaseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        host: config.get('DB_HOST'),
        port: config.get('DB_PORT'),
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

Avoiding Circular Dependencies

Circular dependencies are a code smell indicating unclear domain boundaries. When Module A imports Module B which imports Module A, you have two options:

ApproachWhen to Use
forwardRef()Quick fix for deadline pressure (tech debt)
Extract shared logicProper solution -create a third module both depend on
Event-based communicationWhen modules should react to changes without tight coupling

The event-based approach is particularly powerful:

// orders.service.ts
@Injectable()
export class OrdersService {
  constructor(private eventEmitter: EventEmitter2) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.orderRepository.save(dto);

    // Other modules can listen without creating dependency
    this.eventEmitter.emit('order.created', order);

    return order;
  }
}

Validation Patterns

Validation is your first line of defense. Bad data that makes it past the controller pollutes your entire system.

DTO-Based Validation

DTOs (Data Transfer Objects) define the contract between client and server. Combined with class-validator, they become self-documenting validation rules:

import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(8)
  @Matches(/^(?=.*[A-Z])(?=.*[0-9])/, {
    message: 'Password must contain uppercase letter and number',
  })
  password: string;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  displayName?: string;
}

Enable validation globally in main.ts:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,            // Strip unknown properties
    forbidNonWhitelisted: true, // Throw on unknown properties
    transform: true,            // Auto-transform to DTO instances
    transformOptions: {
      enableImplicitConversion: true,
    },
  }),
);
OptionPurpose
whitelistSilently removes properties not in DTO
forbidNonWhitelistedRejects requests with unknown properties
transformConverts plain objects to class instances
enableImplicitConversionConverts string “123” to number 123 based on type

Custom Validators

For business logic validation -like checking if an email already exists -create custom decorators:

import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator } from 'class-validator';
import { Injectable } from '@nestjs/common';

@ValidatorConstraint({ async: true })
@Injectable()
export class IsUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private readonly userService: UserService) {}

  async validate(value: any, args: ValidationArguments) {
    const [field] = args.constraints;
    const exists = await this.userService.existsByField(field, value);
    return !exists;
  }

  defaultMessage(args: ValidationArguments) {
    return `${args.property} already exists`;
  }
}

// Decorator factory
export function IsUnique(field: string) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      constraints: [field],
      validator: IsUniqueConstraint,
    });
  };
}

Usage:

export class CreateUserDto {
  @IsEmail()
  @IsUnique('email')  // Checks database during validation
  email: string;
}

Partial Validation for Updates

NestJS provides utility types to avoid duplicating DTOs:

import { PartialType, OmitType, PickType } from '@nestjs/mapped-types';

// All fields optional except email (which shouldn't change)
export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['email'] as const),
) {}

// Only specific fields
export class UpdatePasswordDto extends PickType(CreateUserDto, ['password'] as const) {}

Error Handling Patterns

Inconsistent error responses are a common API smell. One endpoint returns { error: "Not found" }, another returns { message: "Resource missing", code: 404 }. Clients hate this.

Global Exception Filter

Create a single filter that standardizes all error responses:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: LoggerService) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';
    let code = 'INTERNAL_ERROR';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message = typeof exceptionResponse === 'string'
        ? exceptionResponse
        : (exceptionResponse as any).message;
      code = this.getErrorCode(status);
    }

    // Log for debugging
    this.logger.error(`${request.method} ${request.url}`, {
      status,
      message,
      stack: exception instanceof Error ? exception.stack : undefined,
    });

    // Consistent response format
    response.status(status).json({
      success: false,
      error: {
        code,
        message,
        timestamp: new Date().toISOString(),
        path: request.url,
      },
    });
  }

  private getErrorCode(status: number): string {
    const codeMap: Record<number, string> = {
      400: 'BAD_REQUEST',
      401: 'UNAUTHORIZED',
      403: 'FORBIDDEN',
      404: 'NOT_FOUND',
      409: 'CONFLICT',
      422: 'UNPROCESSABLE_ENTITY',
      429: 'TOO_MANY_REQUESTS',
      500: 'INTERNAL_ERROR',
    };
    return codeMap[status] || 'UNKNOWN_ERROR';
  }
}

Register it globally:

// main.ts
app.useGlobalFilters(new GlobalExceptionFilter(app.get(LoggerService)));

Custom Business Exceptions

HTTP exceptions are too generic for domain logic. Create business-specific exceptions:

export class BusinessException extends HttpException {
  constructor(
    public readonly errorCode: string,
    message: string,
    status: HttpStatus = HttpStatus.BAD_REQUEST,
  ) {
    super({ errorCode, message }, status);
  }
}

// Domain-specific exceptions
export class InsufficientBalanceException extends BusinessException {
  constructor(required: number, available: number) {
    super(
      'INSUFFICIENT_BALANCE',
      `Required ${required}, but only ${available} available`,
      HttpStatus.UNPROCESSABLE_ENTITY,
    );
  }
}

export class OrderAlreadyShippedException extends BusinessException {
  constructor(orderId: string) {
    super(
      'ORDER_ALREADY_SHIPPED',
      `Order ${orderId} has already been shipped and cannot be modified`,
      HttpStatus.CONFLICT,
    );
  }
}

Usage in services:

async cancelOrder(orderId: string) {
  const order = await this.findOne(orderId);

  if (order.status === 'shipped') {
    throw new OrderAlreadyShippedException(orderId);
  }

  // proceed with cancellation
}

Formatted Validation Errors

Customize how validation errors appear to clients:

new ValidationPipe({
  exceptionFactory: (errors) => {
    const formattedErrors = errors.map((err) => ({
      field: err.property,
      errors: Object.values(err.constraints || {}),
    }));

    return new BadRequestException({
      code: 'VALIDATION_ERROR',
      message: 'Validation failed',
      details: formattedErrors,
    });
  },
});

This produces clean, parseable error responses:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "errors": ["email must be an email"]
      },
      {
        "field": "password",
        "errors": [
          "password must be longer than or equal to 8 characters",
          "Password must contain uppercase letter and number"
        ]
      }
    ]
  }
}

Putting It All Together

graph LR
    Request[Request] --> Pipe[ValidationPipe]
    Pipe --> |Valid| Controller[Controller]
    Pipe --> |Invalid| Filter[ExceptionFilter]
    Controller --> Service[Service]
    Service --> |Success| Response[Response]
    Service --> |Error| Filter
    Filter --> ErrorResponse[Error Response]

    style Request fill:#10b981,color:#fff
    style Pipe fill:#f59e0b,color:#fff
    style Controller fill:#3b82f6,color:#fff
    style Service fill:#8b5cf6,color:#fff
    style Filter fill:#ef4444,color:#fff
    style Response fill:#10b981,color:#fff
    style ErrorResponse fill:#ef4444,color:#fff

The patterns work together:

  1. Modules organize code into maintainable domains
  2. Validation catches bad data before it reaches your logic
  3. Exception filters ensure consistent error responses when things fail

This isn’t about adding complexity -it’s about managing it. A 500-endpoint API without these patterns becomes unmaintainable. With them, each piece has a clear home, errors are predictable, and new team members can navigate the codebase without a guided tour.

Beyond the Basics

Once these foundations are solid, explore:

PatternUse Case
GuardsAuthentication and authorization
InterceptorsResponse transformation, logging, caching
MiddlewareRequest preprocessing (CORS, compression)
Custom decoratorsExtracting common parameter logic

Start with modules, validation, and errors. Build on that foundation as complexity demands -not before.


Patterns aren’t about adding complexity -they’re about managing it. Start with these foundations, and your NestJS API will scale with your product instead of against it.

Inspiration

NestJS TypeScript API Design Backend Architecture Node.js