Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Chapter 3: Controllers & Routing

Controllers are the entry point for handling client requests in NestJS. Think of them as the “reception desk” of your application—they receive requests, ask the right service for help, and send back a response. This chapter explores routing, request/response handling, decorators, middleware, guards, interceptors, error handling, and best practices for building robust APIs.

3.1 What is a Controller?

A controller is responsible for receiving incoming requests, delegating work to services, and returning responses. Controllers define the routes and HTTP methods for your API.

Controller Responsibilities

Controllers handle HTTP-specific concerns:
  • Route definition: Map URLs to handler methods
  • Request extraction: Get data from params, query, body, headers
  • Response formatting: Return data in the correct format
  • Error handling: Throw appropriate HTTP exceptions
  • Validation: Ensure incoming data is valid (via DTOs and pipes)
What Controllers Should NOT Do:
  • Business logic (delegate to services)
  • Database access (delegate to repositories/services)
  • Complex data transformation (delegate to services)
  • Authentication logic (use guards)
Analogy:
Think of a controller as a call center operator. When a customer calls (HTTP request arrives), the operator does not fix the product themselves. They listen to the request, look up the customer’s account (extract params, query, body), route the call to the right department (delegate to a service), and then relay the department’s answer back to the customer (send the response). If the operator starts doing repair work on the phone, the whole system breaks down. That is exactly what happens when you put business logic in controllers — it becomes untestable, unreusable, and tangled with HTTP concerns.

Basic Controller Structure

import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')  // Base route: /users
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()  // GET /users
  findAll() {
    return this.usersService.findAll();
  }
}
Key Components:
  • @Controller('users') - Defines the base route for all methods
  • Constructor injection - Services are injected via constructor
  • Route decorators - @Get(), @Post(), etc. define HTTP methods
  • Handler methods - Process requests and return responses
Diagram: Request Flow
HTTP Request → Controller → Service → Repository/Database → Response

3.2 Routing in NestJS

Routes are defined using decorators like @Get, @Post, @Put, @Delete, @Patch, etc. The path can include parameters, query strings, and wildcards.

HTTP Method Decorators

NestJS provides decorators for all HTTP methods:
@Controller('users')
export class UsersController {
  @Get()           // GET /users
  findAll() { }

  @Get(':id')       // GET /users/:id
  findOne() { }

  @Post()           // POST /users
  create() { }

  @Put(':id')       // PUT /users/:id
  update() { }

  @Patch(':id')     // PATCH /users/:id
  partialUpdate() { }

  @Delete(':id')     // DELETE /users/:id
  remove() { }
}

Route Parameters

Extract dynamic segments from the URL:
@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findOne(Number(id));
}
Multiple Parameters:
@Get(':userId/posts/:postId')
findUserPost(
  @Param('userId') userId: string,
  @Param('postId') postId: string,
) {
  return this.postsService.findOne(userId, postId);
}
All Parameters:
@Get(':id')
findOne(@Param() params: { id: string }) {
  return this.usersService.findOne(Number(params.id));
}
With Pipes for Validation:
import { ParseIntPipe } from '@nestjs/common';

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  // id is already a number, no need to convert
  return this.usersService.findOne(id);
}

Query Parameters

Extract data from the query string:
@Get()
findByQuery(@Query('role') role: string) {
  return this.usersService.findByRole(role);
}
Multiple Query Parameters:
@Get()
findAll(
  @Query('page') page: number,
  @Query('limit') limit: number,
  @Query('sort') sort: string,
) {
  return this.usersService.findAll({ page, limit, sort });
}
All Query Parameters:
@Get()
findAll(@Query() query: { page?: number; limit?: number; sort?: string }) {
  return this.usersService.findAll(query);
}
With DTO for Validation:
export class QueryDto {
  @IsOptional()
  @IsInt()
  @Min(1)
  page?: number;

  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number;
}

@Get()
findAll(@Query() query: QueryDto) {
  return this.usersService.findAll(query);
}

Request Body

Extract data from the request body:
@Post()
create(@Body() createDto: CreateUserDto) {
  return this.usersService.create(createDto);
}
Partial Body:
@Post()
create(@Body('name') name: string) {
  // Only extract 'name' from body
  return this.usersService.create({ name });
}
Multiple Body Properties:
@Post()
create(
  @Body('name') name: string,
  @Body('email') email: string,
) {
  return this.usersService.create({ name, email });
}

Headers

Extract data from request headers:
@Get()
findAll(@Headers('authorization') auth: string) {
  return this.usersService.findAll();
}
All Headers:
@Get()
findAll(@Headers() headers: Record<string, string>) {
  const auth = headers['authorization'];
  return this.usersService.findAll();
}

Request Object

Access the full request object when needed:
import { Request } from 'express';

@Get()
findAll(@Req() req: Request) {
  // Access full request object
  return this.usersService.findAll();
}

Response Object

Control the response directly:
import { Response } from 'express';

@Get()
findAll(@Res() res: Response) {
  return res.status(200).json({ data: [] });
}
Warning: Using @Res() bypasses NestJS’s built-in response handling entirely. This means interceptors will not transform your response, exception filters may not catch errors correctly, and NestJS cannot automatically serialize your return value to JSON. Only use @Res() when you need low-level control (like streaming files or setting custom cookies). For everything else, return data from the handler and let NestJS serialize it.

Status Codes

Set custom HTTP status codes:
import { HttpCode } from '@nestjs/common';

@Post()
@HttpCode(201)  // Returns 201 Created instead of 200
create(@Body() createDto: CreateUserDto) {
  return this.usersService.create(createDto);
}
Common Status Codes:
@Post()
@HttpCode(HttpStatus.CREATED)  // 201
create() { }

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)  // 204
remove() { }

Route Wildcards

Use wildcards for flexible routing:
@Get('ab*cd')  // Matches abcd, ab_cd, ab123cd, etc.
findWildcard() {
  return 'This route uses a wildcard';
}

3.3 Request Lifecycle

Understanding the request lifecycle helps you know where to place your logic and how different components interact.

Complete Request Lifecycle

This is one of the most important diagrams in the entire course. Memorize this order, because when something goes wrong in production, knowing exactly where in this pipeline your code runs is the difference between a 5-minute fix and a 5-hour debugging session.
  1. Incoming Request - HTTP request arrives at the server
  2. Middleware - Global and route-specific middleware runs (think Express middleware: logging, CORS, body parsing)
  3. Guards - Authentication and authorization checks (“Should this request proceed at all?”)
  4. Interceptors (Before) - Pre-processing (logging, request timing, cache lookup)
  5. Pipes - Validation and transformation of input data (DTOs validated here)
  6. Controller - Route handler method executes (thin — just delegates to service)
  7. Service - Business logic executes (the real work happens here)
  8. Interceptors (After) - Post-processing (response wrapping, cache storage)
  9. Exception Filters - Handle any exceptions thrown during the pipeline (format error responses)
  10. Response - HTTP response sent to client
Diagram: Full Request Lifecycle
HTTP Request

Global Middleware

Route Middleware

Guards (Authentication/Authorization)

Interceptors (Before)

Pipes (Validation/Transformation)

Controller Handler

Service (Business Logic)

Interceptors (After - Response Transformation)

Exception Filters (if error)

HTTP Response

Execution Order Example

// 1. Middleware
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: Function) {
    console.log('1. Middleware');
    next();
  }
}

// 2. Guard
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    console.log('2. Guard');
    return true;
  }
}

// 3. Interceptor (Before)
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    console.log('3. Interceptor (Before)');
    return next.handle();
  }
}

// 4. Pipe
export class ValidationPipe implements PipeTransform {
  transform(value: any) {
    console.log('4. Pipe');
    return value;
  }
}

// 5. Controller
@Controller('users')
@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
export class UsersController {
  @Get()
  @UsePipes(ValidationPipe)
  findAll() {
    console.log('5. Controller');
    return [];
  }
}

Middleware vs Guards vs Interceptors vs Pipes — The Definitive Comparison

This is the table you will refer back to most often. These four concepts are the most commonly confused parts of NestJS, and getting them wrong means putting code in the wrong layer.
AspectMiddlewareGuardsInterceptorsPipes
Primary JobRaw request/response processingAccess control (yes/no)Wrap handler execution (before + after)Validate/transform individual values
Has Access Toreq, res, nextExecutionContext (handler metadata, class)ExecutionContext + handler’s Observable returnThe specific value being transformed
Can Read Route Metadata?NoYes (via Reflector)Yes (via Reflector)No
Can Modify Response?Yes (via res)No (only allow/deny)Yes (via RxJS map operator)No (only transforms input)
Can Short-Circuit?Yes (skip next())Yes (return false)Yes (return cached Observable)Yes (throw validation error)
Execution Order1st2nd3rd (before) + 7th (after)4th
Scope OptionsGlobal, route-specificGlobal, controller, methodGlobal, controller, methodGlobal, controller, method, param
DI SupportClass-based onlyYesYesYes
Can Be Async?YesYesYes (RxJS)Yes
Express/Fastify Aware?Yes (platform-specific)No (uses ExecutionContext)No (uses ExecutionContext)No (value-level)
Decision Framework — Where Does My Cross-Cutting Logic Go?
"I need to log every request and its duration"
  --> Can I do it without NestJS context? YES --> Middleware (simple logging)
  --> Do I need to see the response? YES --> Interceptor (timing + response logging)

"I need to check if the user is authenticated"
  --> Guard (canActivate returns true/false)

"I need to check if the user has the 'admin' role"
  --> Guard with @Roles() metadata (uses Reflector to read route metadata)

"I need to validate the request body against a DTO"
  --> Pipe (ValidationPipe with class-validator)

"I need to wrap all responses in { data: ..., timestamp: ... }"
  --> Interceptor (uses RxJS map to transform the handler's return value)

"I need to add a correlation ID header to every response"
  --> Middleware (modifies res before handler runs) OR Interceptor (modifies after)

"I need to handle CORS, body parsing, or compression"
  --> Middleware (these are transport-level concerns)
Common Mistake: Using middleware for authentication when you need route metadata (like roles). Middleware cannot read @SetMetadata() decorators because it runs before NestJS resolves the route handler. If you need to check roles or permissions, use a guard.

3.4 Validation & DTOs

Data Transfer Objects (DTOs) define the shape of data for requests and responses. Combined with validation, they ensure your API only accepts well-formed requests.

Why Use DTOs?

  • Type Safety: TypeScript knows the shape of your data
  • Validation: Ensure data meets requirements
  • Documentation: DTOs document your API contract
  • Transformation: Can transform data automatically
  • Security: Prevent invalid or malicious data

Basic DTO

// dto/create-user.dto.ts
export class CreateUserDto {
  name: string;
  email: string;
  age: number;
}

DTO with Validation

Install validation packages:
npm install class-validator class-transformer
import { IsString, IsEmail, IsInt, Min, Max, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(18)
  @Max(120)
  @IsOptional()
  age?: number;
}

Common Validation Decorators

import {
  IsString,
  IsNumber,
  IsBoolean,
  IsEmail,
  IsUrl,
  IsDate,
  IsArray,
  IsOptional,
  IsNotEmpty,
  MinLength,
  MaxLength,
  Min,
  Max,
  Matches,
  IsEnum,
} from 'class-validator';

export class UserDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsNumber()
  @Min(0)
  @Max(100)
  score: number;

  @IsOptional()
  @IsUrl()
  website?: string;

  @IsEnum(['admin', 'user', 'guest'])
  role: string;

  @Matches(/^[A-Z]{2}$/)
  countryCode: string;
}

Global Validation Pipe

Apply validation globally in main.ts:
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,              // Strip properties that don't have decorators
      forbidNonWhitelisted: true,   // Throw error if non-whitelisted properties exist
      transform: true,              // Automatically transform payloads to DTO instances
      transformOptions: {
        enableImplicitConversion: true,  // Enable implicit type conversion
      },
    }),
  );
  
  await app.listen(3000);
}

Custom Validation

Create custom validators:
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';

export function IsStrongPassword(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isStrongPassword',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/.test(value);
        },
        defaultMessage(args: ValidationArguments) {
          return 'Password must be at least 8 characters with uppercase, lowercase, number, and special character';
        },
      },
    });
  };
}

// Usage
export class CreateUserDto {
  @IsString()
  @IsStrongPassword()
  password: string;
}
Tip: Always validate input to prevent security issues and bugs. Validation should happen at the controller level using DTOs and pipes.

3.5 Middleware

Middleware is executed before the route handler. Use it for logging, authentication, request transformation, CORS, and other cross-cutting concerns.

What is Middleware?

Middleware functions have access to:
  • Request object
  • Response object
  • Next function (to pass control to next middleware)
They can:
  • Execute code before/after the route handler
  • Modify request/response objects
  • End the request-response cycle
  • Call the next middleware in the stack

Functional Middleware

Simple middleware as a function:
import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
}

Class-Based Middleware

More powerful, can inject dependencies:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
    next();
  }
}

Middleware with Dependencies

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { ConfigService } from '../config/config.service';

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  constructor(private configService: ConfigService) {}

  use(req: Request, res: Response, next: NextFunction) {
    const apiKey = req.headers['x-api-key'];
    const validKey = this.configService.get('API_KEY');
    
    if (apiKey !== validKey) {
      return res.status(401).json({ message: 'Unauthorized' });
    }
    
    next();
  }
}

Registering Middleware

Register in module using configure method:
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { LoggerMiddleware } from './middleware/logger.middleware';

@Module({})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('users');  // Apply to all /users routes
  }
}

Route-Specific Middleware

configure(consumer: MiddlewareConsumer) {
  consumer
    .apply(LoggerMiddleware)
    .forRoutes(
      { path: 'users', method: RequestMethod.GET },
      { path: 'users/:id', method: RequestMethod.GET },
    );
}

Excluding Routes

configure(consumer: MiddlewareConsumer) {
  consumer
    .apply(LoggerMiddleware)
    .exclude(
      { path: 'users', method: RequestMethod.GET },
      'users/(.*)',
    )
    .forRoutes('*');
}

Multiple Middleware

configure(consumer: MiddlewareConsumer) {
  consumer
    .apply(LoggerMiddleware, AuthMiddleware)
    .forRoutes('*');
}

Global Middleware

Apply middleware globally in main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './middleware/logger.middleware';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(logger);  // Global middleware
  await app.listen(3000);
}
Tip: Use middleware for cross-cutting concerns that apply to many routes. For route-specific logic, use guards or interceptors.

3.6 Guards

Guards determine whether a request should be handled by the route. They run after middleware but before the route handler. Use them for authentication and authorization.

What are Guards?

Guards are like bouncers at a club. Their single job is to decide: “Should this request get in, or should I turn it away?” They run after middleware but before any other processing, which makes them ideal for authentication and authorization. If a guard says no, the request never reaches your controller — saving you from duplicating permission checks in every route handler. Guards return:
  • true - Request proceeds to the next step in the pipeline
  • false - Request is denied (NestJS automatically throws ForbiddenException)

Basic Guard

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return Boolean(request.headers['authorization']);
  }
}

Using Guards

Controller-level:
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
  @Get()
  findAll() {
    return [];
  }
}
Method-level:
@Controller('users')
export class UsersController {
  @Get()
  @UseGuards(AuthGuard)
  findAll() {
    return [];
  }
}
Global Guard:
// main.ts
app.useGlobalGuards(new AuthGuard());

Execution Context

The ExecutionContext provides access to:
  • Request object
  • Response object
  • Next function
  • Handler (controller method)
  • Class (controller class)
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();
    const handler = context.getHandler();
    const controller = context.getClass();
    
    // Access request data
    const token = request.headers['authorization'];
    
    return Boolean(token);
  }
}

Role-Based Guard

import { Injectable, CanActivate, ExecutionContext, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

// Decorator to set roles
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
    
    if (!requiredRoles) {
      return true;  // No roles required
    }
    
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}

// Usage
@Controller('users')
@UseGuards(RolesGuard)
export class UsersController {
  @Delete(':id')
  @Roles('admin')
  remove(@Param('id') id: string) {
    return this.usersService.remove(id);
  }
}

Async Guards

Guards can be async:
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = request.headers['authorization'];
    
    if (!token) {
      return false;
    }
    
    const user = await this.authService.validateToken(token);
    request.user = user;
    
    return Boolean(user);
  }
}
Tip: Use guards for permission checks, roles, and authentication logic. They’re perfect for protecting routes and controlling access.

3.7 Interceptors

Interceptors can transform the result returned from a function, extend basic method behavior, or handle cross-cutting concerns like logging, caching, or response shaping.

What are Interceptors?

Interceptors are like wrapping paper around a gift. They wrap your route handler, letting you run code before and after execution. Unlike middleware (which only sees the raw request/response), interceptors can see and transform the handler’s return value using RxJS observables. This makes them powerful for cross-cutting concerns that need to act on the result. Think of the logging interceptor pattern: “Start timer, let the handler run, stop timer, log the duration.” Middleware cannot do this because it does not know when the handler finishes — interceptors can, because they wrap the handler in an Observable pipeline. Interceptors have access to:
  • Execution context (which controller, which method, HTTP or WebSocket, etc.)
  • Call handler (to invoke the route handler and get an Observable of the result)
  • Full RxJS operator chain (map, tap, catchError, timeout, etc.)
They can:
  • Execute code before/after method execution (timing, logging)
  • Transform the result (wrap in { data: ..., timestamp: ... } envelope)
  • Transform exceptions (normalize error formats)
  • Short-circuit the handler entirely (caching — return cached data without calling the handler)

Basic Interceptor

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');
    
    const now = Date.now();
    return next.handle().pipe(
      tap(() => console.log(`After... ${Date.now() - now}ms`)),
    );
  }
}

Response Transformation Interceptor

Wrap responses in a consistent format:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

Error Transformation Interceptor

Transform errors into consistent format:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        return throwError(() => ({
          success: false,
          error: err.message,
          statusCode: err.status || 500,
        }));
      }),
    );
  }
}

Caching Interceptor

Cache responses. This is a simplified in-memory example to illustrate the pattern — in production, use Redis or NestJS’s built-in @nestjs/cache-manager module for distributed caching.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  // WARNING: In-memory cache is per-process. If you run multiple instances
  // behind a load balancer, each instance has its own cache -- leading to
  // inconsistent responses. Use Redis for production caching.
  private cache = new Map();

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const key = request.url;
    
    // Cache hit: return stored data without calling the handler at all.
    // This is the "short-circuit" power of interceptors.
    if (this.cache.has(key)) {
      return of(this.cache.get(key));
    }
    
    // Cache miss: call the handler, then store the result.
    return next.handle().pipe(
      tap(data => {
        this.cache.set(key, data);
        // Clear cache after 5 minutes to prevent serving stale data
        setTimeout(() => this.cache.delete(key), 5 * 60 * 1000);
      }),
    );
  }
}

Timeout Interceptor

Add timeout to requests:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { timeout, catchError } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000),  // 5 second timeout
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException());
        }
        return throwError(() => err);
      }),
    );
  }
}

Using Interceptors

Controller-level:
@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController { }
Method-level:
@Get()
@UseInterceptors(TransformInterceptor)
findAll() {
  return [];
}
Global Interceptor:
// main.ts
app.useGlobalInterceptors(new TransformInterceptor());
Tip: Use interceptors for logging, response formatting, caching, and error transformation. They’re powerful for cross-cutting concerns.

3.8 Error Handling

NestJS provides built-in exception filters and HTTP exceptions for consistent error handling.

Built-in HTTP Exceptions

import {
  BadRequestException,
  UnauthorizedException,
  NotFoundException,
  ForbiddenException,
  NotAcceptableException,
  RequestTimeoutException,
  ConflictException,
  GoneException,
  HttpVersionNotSupportedException,
  PayloadTooLargeException,
  UnsupportedMediaTypeException,
  UnprocessableEntityException,
  InternalServerErrorException,
  NotImplementedException,
  BadGatewayException,
  ServiceUnavailableException,
  GatewayTimeoutException,
} from '@nestjs/common';

@Get(':id')
findOne(@Param('id') id: string) {
  const user = this.usersService.findOne(id);
  if (!user) {
    throw new NotFoundException('User not found');
  }
  return user;
}

Custom Exception Messages

throw new BadRequestException('Invalid input');
throw new NotFoundException({ message: 'User not found', error: 'Not Found' });

Exception Filters

Create custom exception filters:
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';

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

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}
Using Exception Filters:
@Controller('users')
@UseFilters(AllExceptionsFilter)
export class UsersController { }
Global Exception Filter:
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

3.9 Edge Cases in Request Handling

These are situations that catch experienced developers off guard. Edge Case 1: Multiple decorators on the same parameter You can stack pipes on a parameter, but they execute right-to-left (innermost first). @Param('id', ParseIntPipe, CustomValidationPipe) runs ParseIntPipe first, then passes the result to CustomValidationPipe. If ParseIntPipe converts “42” to 42, CustomValidationPipe receives the number 42, not the string “42”. Edge Case 2: @Res() disables automatic response handling The moment you inject @Res() into a handler, NestJS stops managing the response. Your interceptors will not see the return value, and if you forget to call res.json() or res.send(), the request hangs indefinitely. Use @Res({ passthrough: true }) if you need both @Res() access and NestJS’s automatic response handling.
// Hangs forever -- NestJS does not send the response because @Res() is injected
@Get()
findAll(@Res() res: Response) {
  return this.usersService.findAll();  // Return value is IGNORED
}

// Fixed -- passthrough lets NestJS handle the return value
@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  res.header('X-Custom', 'value');
  return this.usersService.findAll();  // NestJS sends this as JSON
}
Edge Case 3: Validation errors from nested DTOs If your DTO contains a nested object, class-validator does not validate nested objects by default. You must add @ValidateNested() and @Type(() => NestedDto) from class-transformer to trigger recursive validation.
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)   // Without this, the nested object is not validated
  shippingAddress: AddressDto;
}
Edge Case 4: Guard ordering with multiple @UseGuards() When you apply multiple guards, they execute left-to-right. If the first guard rejects, subsequent guards never run. This matters for auth flows: put JwtAuthGuard (authentication) before RolesGuard (authorization), because checking roles without a valid user makes no sense.
@UseGuards(JwtAuthGuard, RolesGuard)   // Auth first, then roles
@Delete(':id')
@Roles('admin')
remove(@Param('id') id: string) { ... }
Edge Case 5: File upload with @Body() and @UploadedFile() When handling multipart form data (file uploads), @Body() contains the text fields and @UploadedFile() contains the file. But class-validator cannot validate @Body() in multipart requests the same way it validates JSON — all values arrive as strings. You need to transform them manually or use a custom pipe.

3.9 Best Practices

Following best practices ensures your controllers are maintainable and scalable.

Keep Controllers Thin

Controllers should only handle HTTP concerns:
// Bad: Business logic in controller
@Post()
create(@Body() dto: CreateUserDto) {
  if (dto.email.includes('@')) {
    // validation logic
  }
  // business logic
  return { ... };
}

// Good: Controller delegates to service
@Post()
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto);
}

Use DTOs for All Input

Always validate input using DTOs:
@Post()
create(@Body() createDto: CreateUserDto) {
  return this.usersService.create(createDto);
}

Organize Routes by Feature

Group related routes in feature modules:
// users/users.controller.ts
@Controller('users')
export class UsersController { }

// orders/orders.controller.ts
@Controller('orders')
export class OrdersController { }

Use Appropriate HTTP Methods

@Get()      // Retrieve resources
@Post()     // Create resources
@Put()      // Replace resources
@Patch()    // Partially update resources
@Delete()   // Delete resources

Return Consistent Response Shapes

Use interceptors to format responses consistently:
// All responses will be: { success: true, data: ... }
@UseInterceptors(TransformInterceptor)
@Controller('users')
export class UsersController { }

Handle Errors Gracefully

Use built-in exceptions:
if (!user) {
  throw new NotFoundException('User not found');
}

Document Your API

Use Swagger for API documentation:
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('users')
@Controller('users')
export class UsersController {
  @Get()
  @ApiOperation({ summary: 'Get all users' })
  @ApiResponse({ status: 200, description: 'Returns all users' })
  findAll() {
    return this.usersService.findAll();
  }
}

Use Pipes for Transformation

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  // id is already a number
  return this.usersService.findOne(id);
}

3.10 Summary

You’ve learned how to build robust, maintainable APIs using controllers and routing: Key Concepts:
  • Controllers: Handle HTTP requests and responses
  • Routing: Define routes using decorators
  • DTOs: Validate and transform input data
  • Middleware: Cross-cutting concerns before route handlers
  • Guards: Authentication and authorization
  • Interceptors: Response transformation and logging
  • Error Handling: Consistent error responses
Best Practices:
  • Keep controllers thin
  • Use DTOs for validation
  • Organize routes by feature
  • Use appropriate HTTP methods
  • Handle errors gracefully
  • Document your API
Next Chapter: Learn about providers, services, repository patterns, and domain-driven design in depth.

Interview Deep-Dive

Strong Answer:
  • Middleware runs first and has access to the raw Express/Fastify req, res, next. It does not know which NestJS route handler will execute. Use it for transport-level concerns: request logging, CORS, body parsing, rate limiting at the IP level. If you need to read NestJS route metadata (like @Roles('admin')), middleware cannot do it — use a guard instead.
  • Guards run second and answer one question: “Should this request proceed?” They have access to the ExecutionContext, which knows the target controller and handler. This lets them read decorator metadata via Reflector. Use guards for authentication (is the JWT valid?) and authorization (does this user have the admin role?). Putting auth logic in middleware means you cannot use @Roles() decorators, and putting it in interceptors means unauthorized requests waste time on pre-processing before being rejected.
  • Pipes run after guards and transform/validate individual parameters. ParseIntPipe converts a string route param to a number. ValidationPipe validates a DTO against class-validator decorators. Putting validation in the service layer means invalid data travels further into your application before being rejected, and error messages are less HTTP-specific.
  • Interceptors wrap the handler and can execute code both before and after. They see the handler’s return value via RxJS operators. Use them for response transformation (wrapping in a standard envelope), timing/logging, caching (short-circuit the handler if cache hit), and error mapping. Putting response transformation in the controller means every controller method has to repeat the wrapping logic.
  • The real-world consequence of putting logic in the wrong layer: I once worked on a project where authentication was in middleware. When we needed to add a @Public() decorator to skip auth on certain routes (like health checks), we could not — middleware does not have access to handler metadata. We had to refactor all auth logic into a guard, which touched every module in the application.
Follow-up: If you apply both a global interceptor and a controller-level interceptor, what is the execution order?Global interceptors run first (outermost), then controller-level, then method-level. On the response side, the order reverses (method-level response transformation runs first, then controller-level, then global). This is the “onion model” — each interceptor wraps the next one. A global logging interceptor would log the total request time including all inner interceptor processing, while a method-level caching interceptor would only cache the result of that specific handler.
Strong Answer:
  • This is a global exception filter problem. NestJS has a built-in exception filter that formats HttpException subclasses into a standard shape by default. But if some routes throw raw errors, return custom objects, or use @Res() to bypass NestJS’s response handling, you get inconsistent formats.
  • The fix: create a global AllExceptionsFilter that catches every exception (use @Catch() with no arguments) and formats it into a consistent shape. Register it in main.ts with app.useGlobalFilters(new AllExceptionsFilter()).
  • The filter should handle three cases: (1) HttpException subclasses — extract the status and message from the exception. (2) Known error types (database errors, validation errors) — map them to appropriate HTTP status codes. (3) Unknown errors — return 500 with a generic message, log the actual error for debugging.
  • The important detail: if you register the filter globally in main.ts using app.useGlobalFilters(), it cannot inject dependencies (no DI). If your filter needs to inject a logger service, register it as a provider in AppModule using APP_FILTER: { provide: APP_FILTER, useClass: AllExceptionsFilter }. This gives you DI support.
  • I also add a correlationId to every error response so the frontend can include it in bug reports, and the backend team can search logs by that ID.
Follow-up: How do exception filters interact with interceptors? If an interceptor has a catchError operator and there is also an exception filter, which one catches the error?Interceptors’ catchError runs first because interceptors wrap the handler. If the interceptor catches the error and returns a new observable (like a fallback value), the exception filter never sees the error. If the interceptor re-throws, the exception filter catches it. This ordering matters for retry logic — an interceptor can retry a failed database call three times, and only if all retries fail does it re-throw, which the exception filter then formats into a user-friendly error response.
Strong Answer:
  • NestJS supports four versioning strategies out of the box: URI (/v1/users), Header (X-API-Version: 1), Media Type (Accept: application/vnd.myapi.v1+json), and Custom. You enable versioning in main.ts: app.enableVersioning({ type: VersioningType.URI }).
  • URI versioning is the most common and the simplest to implement. It is visible in the URL, easy to test with curl, and easy to route in load balancers. The downside is URL proliferation — every version creates a new set of URLs. Use @Controller({ path: 'users', version: '1' }) or @Version('1') on individual methods.
  • Header versioning keeps URLs clean but is harder to test (you need to set headers in every request) and harder to cache (CDNs and proxies do not cache based on headers by default).
  • The real-world trade-off is between maintainability and backward compatibility. At one company, we used URI versioning and maintained v1 and v2 simultaneously for 18 months. The cost was maintaining two sets of controllers, two sets of DTOs, and two sets of tests. The lesson: version at the route level, not the module level. Both v1 and v2 controllers can call the same service — only the request/response shape changes.
  • My recommendation: use URI versioning for external APIs (it is the most widely understood), and do not version internal microservice APIs (just coordinate deployments).
Follow-up: How do you deprecate and eventually remove an old API version without breaking clients?Add a Deprecation response header to all v1 responses (Deprecation: true, Sunset: 2026-06-01) so clients can detect it programmatically. Log all v1 requests to identify which clients are still using the old version. Send direct communication to those clients with a migration timeline. After the sunset date, return 410 Gone for v1 endpoints instead of silently dropping them. The key is giving clients months of warning and making the migration path clear with a documented changelog of breaking changes between versions.
Strong Answer:
  • This is an authorization guard that needs access to both the authenticated user (from the JWT) and the resource being requested (from the route parameter). The tricky part is that the guard runs before the controller handler, so you need to decide whether to query the database in the guard.
  • The implementation: the guard injects the UsersService and reads the :id parameter from the request. It then compares request.user.id (set by the JWT guard) with the requested id. If they match, the user owns the resource.
  • For simple ownership checks (user ID matches route param), the guard can be generic: read a metadata key that specifies which route param contains the owner ID, then compare it to the authenticated user ID. Use @SetMetadata('ownerParam', 'id') and read it with Reflector.
  • For complex ownership checks (e.g., “can this user edit this comment on this post?”), the guard needs to query the database. Inject the repository and call comment = await commentRepo.findOne(commentId), then check comment.authorId === user.id. This adds a database query to every request, so consider caching.
  • The important design decision: should the guard throw 403 Forbidden or 404 Not Found? If a user requests a resource they do not own, returning 403 reveals that the resource exists (information leakage). Returning 404 is more secure — the user does not even know the resource exists. For public APIs, I prefer 404. For internal admin tools, 403 is more helpful for debugging.
Follow-up: How do you handle the case where an admin should be able to access any resource, bypassing the ownership check?Add a role check at the top of the guard: if (user.roles.includes('admin')) return true. Or use NestJS’s Reflector to compose multiple decorators: @Roles('admin') on the method allows admins through the RolesGuard, and @OwnerOnly('id') handles ownership for non-admin users. The execution order matters — put RolesGuard before OwnershipGuard in the @UseGuards() array so admins short-circuit before the ownership database query runs.