CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/nestjs-best-practices

NestJS patterns -- modules, DI, exception filters, validation pipes, guards, interceptors, testing, config

98

1.36x
Quality

89%

Does it follow best practices?

Impact

100%

1.36x

Average score across 12 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/nestjs-best-practices/

name:
nestjs-best-practices
description:
NestJS patterns -- modules, controllers, services, dependency injection, exception filters, validation pipes, guards, interceptors, custom providers, testing, and configuration. Use when building or reviewing NestJS APIs, setting up a new NestJS project, or migrating from Express to NestJS.
keywords:
nestjs, nest.js, nestjs module, nestjs controller, nestjs service, exception filter, nestjs pipe, nestjs guard, nestjs interceptor, nestjs dependency injection, nestjs validation, class-validator, nestjs testing, nestjs config, nestjs custom provider, forwardRef, nestjs middleware
license:
MIT

NestJS Best Practices

Patterns for NestJS -- modules, dependency injection, exception filters, validation, guards, interceptors, custom providers, testing, and configuration.


1. Module Structure

src/
├── main.ts                        # Bootstrap + global pipes/filters/interceptors
├── app.module.ts                  # Root module
├── menu/
│   ├── menu.module.ts             # Feature module
│   ├── menu.controller.ts         # HTTP layer
│   ├── menu.service.ts            # Business logic
│   └── dto/
│       └── menu-item.dto.ts       # Request/response shapes
├── orders/
│   ├── orders.module.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   └── dto/
│       ├── create-order.dto.ts
│       └── order-response.dto.ts
├── common/
│   ├── filters/
│   │   └── all-exceptions.filter.ts
│   ├── guards/
│   │   └── auth.guard.ts
│   └── interceptors/
│       └── logging.interceptor.ts
└── config/
    └── config.module.ts

Each feature is a module with its own controller, service, and DTOs. Cross-cutting concerns go in common/. Configuration goes in config/.


2. Validation with Pipes -- class-validator DTOs

// dto/create-order.dto.ts
import { IsString, IsArray, IsInt, Min, Max, ValidateNested, MinLength } from 'class-validator';
import { Type } from 'class-transformer';

class OrderItemDto {
  @IsInt() @Min(1) menuItemId: number;
  @IsString() size: string;
  @IsInt() @Min(1) @Max(20) quantity: number;
}

export class CreateOrderDto {
  @IsString() @MinLength(1) customerName: string;
  @IsArray() @ValidateNested({ each: true }) @Type(() => OrderItemDto)
  items: OrderItemDto[];
}

IMPORTANT: Nested DTO validation requires BOTH @ValidateNested AND @Type

// WRONG -- missing @Type means nested objects are NOT validated
@IsArray() @ValidateNested({ each: true })
items: OrderItemDto[];

// RIGHT -- @Type from class-transformer enables nested transformation and validation
@IsArray() @ValidateNested({ each: true }) @Type(() => OrderItemDto)
items: OrderItemDto[];

Controller uses validated DTO

@Post()
async create(@Body() dto: CreateOrderDto) {
  // dto is already validated -- invalid requests return 400 automatically
  return { data: await this.ordersService.create(dto) };
}

IMPORTANT: Always wrap controller responses in a { data: ... } envelope

// WRONG -- returning raw result
@Post()
async create(@Body() dto: CreateOrderDto) {
  return this.ordersService.create(dto);
}

// RIGHT -- wrap in data envelope for consistent API shape
@Post()
async create(@Body() dto: CreateOrderDto) {
  return { data: await this.ordersService.create(dto) };
}

// RIGHT -- GET endpoints too
@Get()
async findAll() {
  return { data: await this.ordersService.findAll() };
}

@Get(':id')
async findOne(@Param('id') id: string) {
  return { data: await this.ordersService.findOne(id) };
}

Enable ValidationPipe globally in main.ts

// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

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

  // Global validation -- whitelist strips unknown properties, transform auto-converts types
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
    forbidNonWhitelisted: true,
  }));

  // Global exception filter for consistent error envelope
  app.useGlobalFilters(new AllExceptionsFilter());

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

Key ValidationPipe options:

  • whitelist: true -- strips properties not in the DTO (prevents mass assignment)
  • transform: true -- auto-converts query/path params from strings to their DTO types
  • forbidNonWhitelisted: true -- returns 400 if unknown properties are sent (stricter than whitelist alone)

3. Exception Filters -- Consistent Error Envelope

WRONG -- inconsistent error shapes

// WRONG: returns { statusCode, timestamp, path } -- no consistent envelope
@Catch()
export class BadExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;
    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: ctx.getRequest().url,
    });
  }
}

RIGHT -- { error: { code, message } } envelope

// common/filters/all-exceptions.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      response.status(status).json({
        error: { code: exception.name, message: exception.message },
      });
    } else {
      // IMPORTANT: Always log unknown errors before returning 500
      console.error('Unhandled exception:', exception);
      response.status(500).json({
        error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
      });
    }
  }
}

Key rules for exception filters:

  • Use @Catch() with NO arguments to catch ALL exception types (not just HttpException)
  • Always return the shape { error: { code: string, message: string } } for BOTH HttpException and unknown errors
  • Always call console.error('Unhandled exception:', exception) for non-HttpException errors before returning the 500 response
  • Register globally in main.ts: app.useGlobalFilters(new AllExceptionsFilter())

4. Service Layer -- Dependency Injection

@Injectable()
export class OrdersService {
  constructor(
    private readonly menuService: MenuService,
    private readonly pricingService: PricingService,
    private readonly notificationsService: NotificationsService,
  ) {}

  async create(dto: CreateOrderDto): Promise<Order> {
    const total = await this.pricingService.calculateTotal(dto.items);
    const order = { ...dto, total, id: Date.now().toString() };
    await this.notificationsService.sendConfirmation(order);
    return order;
  }
}

WRONG -- manual instantiation

// WRONG: Creating dependencies with new -- bypasses DI, breaks testing
@Injectable()
export class OrdersService {
  private pricingService = new PricingService();
}

RIGHT -- constructor injection

// RIGHT: NestJS injects via constructor -- testable, swappable
@Injectable()
export class OrdersService {
  constructor(private readonly pricingService: PricingService) {}
}

Key rules:

  • Controllers handle HTTP only -- delegate ALL business logic to services
  • Services receive dependencies through constructor injection (never new)
  • NEVER inject Request or Response objects into services -- this couples them to HTTP and makes them untestable
  • Mark all services with @Injectable()

5. Custom Providers -- useFactory, useClass, useValue

When you need more control over how a provider is created, use custom provider syntax:

// config/config.module.ts
@Module({
  providers: [
    // useValue -- provide a static value or config object
    {
      provide: 'APP_CONFIG',
      useValue: { apiUrl: 'https://api.example.com', timeout: 5000 },
    },
    // useFactory -- create a provider with async logic or other dependencies
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: async (configService: ConfigService) => {
        const connection = await createConnection(configService.get('DATABASE_URL'));
        return connection;
      },
      inject: [ConfigService],
    },
    // useClass -- swap implementations (e.g., for testing or environment-specific behavior)
    {
      provide: 'LOGGER',
      useClass: process.env.NODE_ENV === 'production' ? ProductionLogger : DevLogger,
    },
  ],
  exports: ['APP_CONFIG', 'DATABASE_CONNECTION', 'LOGGER'],
})
export class ConfigModule {}

Inject token-based providers using @Inject():

@Injectable()
export class OrdersService {
  constructor(
    @Inject('APP_CONFIG') private readonly config: AppConfig,
    @Inject('DATABASE_CONNECTION') private readonly db: Connection,
  ) {}
}

6. Circular Dependencies -- forwardRef

When two modules depend on each other, use forwardRef to break the cycle:

// orders/orders.module.ts
@Module({
  imports: [forwardRef(() => MenuModule)],
  providers: [OrdersService],
  exports: [OrdersService],
})
export class OrdersModule {}

// menu/menu.module.ts
@Module({
  imports: [forwardRef(() => OrdersModule)],
  providers: [MenuService],
  exports: [MenuService],
})
export class MenuModule {}

Also use forwardRef at the injection point:

@Injectable()
export class OrdersService {
  constructor(
    @Inject(forwardRef(() => MenuService))
    private readonly menuService: MenuService,
  ) {}
}

Prefer restructuring over forwardRef -- if two services depend on each other, consider extracting shared logic into a third service. Only use forwardRef when the circular dependency is truly unavoidable.


7. Guards, Interceptors, and Middleware -- Execution Order

NestJS processes requests in this exact order:

Middleware → Guards → Interceptors (before) → Pipes → Route Handler → Interceptors (after) → Exception Filters

Guards -- access control (return true/false)

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly authService: AuthService) {}

  canActivate(context: ExecutionContext): boolean | Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];
    if (!token) return false;
    return this.authService.validateToken(token);
  }
}

// Apply to a controller or method
@UseGuards(AuthGuard)
@Controller('orders')
export class OrdersController { ... }

Interceptors -- cross-cutting concerns (logging, caching, response transform)

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const start = Date.now();

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - start;
        console.log(`${request.method} ${request.url} ${duration}ms`);
      }),
    );
  }
}

Middleware -- raw request/response processing

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const correlationId = req.headers['x-correlation-id'] || randomUUID();
    req['correlationId'] = correlationId;
    res.setHeader('x-correlation-id', correlationId);
    next();
  }
}

// Register in module
@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(CorrelationIdMiddleware).forRoutes('*');
  }
}

When to use which:

  • Middleware: request/response header manipulation, CORS, body parsing, request logging at the raw HTTP level
  • Guards: authentication, authorization, role checks -- anything that decides whether a request should proceed
  • Interceptors: response transformation, caching, timing/logging, wrapping responses in envelopes

8. Testing with Test.createTestingModule

import { Test, TestingModule } from '@nestjs/testing';

describe('OrdersService', () => {
  let service: OrdersService;
  let pricingService: jest.Mocked<PricingService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        {
          provide: PricingService,
          useValue: {
            calculateTotal: jest.fn().mockResolvedValue(25.00),
          },
        },
        {
          provide: NotificationsService,
          useValue: {
            sendConfirmation: jest.fn().mockResolvedValue(undefined),
          },
        },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
    pricingService = module.get(PricingService) as jest.Mocked<PricingService>;
  });

  it('should create an order with calculated total', async () => {
    const dto = { customerName: 'Alice', items: [{ menuItemId: 1, size: 'L', quantity: 2 }] };
    const result = await service.create(dto);
    expect(result.total).toBe(25.00);
    expect(pricingService.calculateTotal).toHaveBeenCalledWith(dto.items);
  });
});

Key testing rules:

  • Use Test.createTestingModule to create an isolated module for unit tests
  • Mock dependencies with useValue providing jest.fn() stubs
  • Use module.get<T>(Token) to retrieve the service under test
  • Test controllers separately from services -- mock the service in controller tests

9. Configuration with ConfigModule

// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
    }),
    // Other feature modules...
  ],
})
export class AppModule {}

Access config in services via ConfigService:

@Injectable()
export class DatabaseService {
  constructor(private readonly configService: ConfigService) {}

  getConnectionString(): string {
    return this.configService.get<string>('DATABASE_URL');
  }
}

Key rules:

  • Always set isGlobal: true on ConfigModule so all modules can access ConfigService without re-importing
  • Use configService.get<string>('KEY') with a type parameter for type safety
  • Never read process.env directly in services -- always use ConfigService for testability

Checklist

  • One module per feature (menu, orders, etc.)
  • Controllers have no business logic -- delegate to services
  • DTOs with class-validator decorators for all request bodies
  • Nested DTOs use BOTH @ValidateNested({ each: true }) AND @Type(() => NestedDto)
  • Global ValidationPipe with whitelist: true and transform: true
  • Global exception filter using @Catch() with no arguments (catches ALL exceptions)
  • Exception filter returns { error: { code, message } } envelope for all errors
  • Exception filter calls console.error for non-HttpException errors before returning 500
  • Controller endpoints wrap responses in { data: result } envelope
  • Services injected via constructor (not instantiated with new)
  • No Request/Response objects in services
  • Guards for auth, interceptors for cross-cutting, middleware for raw HTTP
  • Tests use Test.createTestingModule with mocked providers
  • ConfigModule with isGlobal: true, services use ConfigService (not process.env)

Verifiers

  • module-structure -- Organize code into NestJS modules with controllers and services
  • validation-and-dtos -- Validate request bodies with class-validator DTOs and global ValidationPipe
  • exception-handling -- Consistent error envelope with global catch-all exception filter
  • dependency-injection -- Constructor injection, service separation, no Request/Response in services
  • guards-interceptors -- Guards for auth, interceptors for logging/timing, correct execution order
  • testing-patterns -- Test.createTestingModule with mocked providers

skills

nestjs-best-practices

tile.json