Skip to main content

What are controllers in NestJS?

Controllers are classes in NestJS that handle incoming HTTP requests and return responses. Decorators map URLs and HTTP methods to the right handler function.

Theory

TL;DR

  • Controllers are the HTTP entry point: receive a request, extract data, call a service, return a response
  • @Controller('users') sets the route prefix; @Get(), @Post() map HTTP methods to class methods
  • Controllers own HTTP concerns (routing, status codes, headers); services own business logic
  • Rule: if it touches HTTP, it goes in a controller; if it is business logic, it goes in a service

Quick example

typescript
import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { UsersService } from './users.service'; @Controller('users') // all routes start with /users export class UsersController { constructor(private usersService: UsersService) {} @Get(':id') // GET /users/123 getUser(@Param('id') id: string) { return this.usersService.findById(id); // delegates to service } @Post() // POST /users createUser(@Body() userData: CreateUserDto) { return this.usersService.create(userData); // controller does not do the work itself } } // Request in → Controller extracts data → Service does the work → Response out

The controller receives the request, pulls out the data it needs (@Param, @Body), and hands it to the service. That is the whole job.

Controller vs service

Controllers are the HTTP boundary layer. They translate HTTP requests into service calls and service results back into HTTP responses. Think of a controller like a receptionist: takes your request, routes it to the right department (service), and delivers the answer back. The receptionist does not do the actual work.

Services contain business logic and can run from any context: HTTP, WebSockets, scheduled jobs, CLI commands. Controllers are tied to HTTP. Services are not.

When to use

  • Routing - map URLs and HTTP methods to handler functions
  • Input extraction - pull data from @Param(), @Query(), @Body(), @Headers()
  • Response control - set status codes with @HttpCode(), manage headers with @Res()
  • Guards and interceptors - apply auth or logging at the route level
  • NOT for database queries, calculations, or any domain logic

How it works internally

When a request arrives, NestJS matches the URL and HTTP method to a controller method using the decorators you defined. The framework extracts path params, query strings, and body data automatically, then calls the method. Whatever the method returns gets serialized to JSON and sent back with status 200 by default. Use @HttpCode(HttpStatus.CREATED) to return 201, or @Res() to take manual control of the response object.

Common mistakes

Business logic in the controller:

typescript
// Wrong: controller queries the database and calculates fields @Get(':id') getUser(@Param('id') id: string) { const user = this.db.query('SELECT * FROM users WHERE id = ?', [id]); return { ...user, age: new Date().getFullYear() - user.birthYear }; } // Right: controller delegates everything to the service @Get(':id') getUser(@Param('id') id: string) { return this.usersService.getUserWithEnrichment(id); }

Logic in controllers cannot be reused from WebSockets or scheduled jobs, and it is hard to test in isolation.

Missing return or await on async calls:

typescript
// Wrong: fires the query but responds immediately @Get(':id') getUser(@Param('id') id: string) { this.usersService.findById(id); // no return return { status: 'ok' }; // client gets the wrong response } // Right @Get(':id') async getUser(@Param('id') id: string) { return this.usersService.findById(id); }

Returning raw database objects:

typescript
// Wrong: leaks passwordHash, internalNotes, etc. @Get(':id') getUser(@Param('id') id: string) { return this.usersService.findById(id); } // Right: use a response DTO to control what gets exposed @Get(':id') @UseInterceptors(ClassSerializerInterceptor) getUser(@Param('id') id: string): UserResponseDto { return this.usersService.findById(id); }

In most projects I have seen, returning raw database entities from the controller is where data exposure bugs start. Response DTOs are cheap to add and prevent a lot of problems.

Real-world usage

  • REST APIs - every route in a NestJS app is a controller method
  • GraphQL - resolvers replace controllers; @Resolver() takes the same role
  • Microservices - @MessagePattern() and @EventPattern() replace @Get()/@Post()
  • Fastify adapter - controllers work identically; NestJS abstracts the HTTP layer
  • AWS Lambda - each controller route can map to a separate Lambda handler

Follow-up questions

Q: What is the difference between a controller and a service?
A: Controllers handle HTTP: routing, status codes, request/response formatting. Services contain business logic and can run from any context. A controller calls a service; a service never calls a controller.

Q: How do you handle errors in a controller?
A: Throw NestJS exceptions like BadRequestException, NotFoundException, or ConflictException. NestJS catches them automatically and returns the right HTTP status. Do not let raw JavaScript errors reach the client.

Q: What is the difference between @Body(), @Param(), and @Query()?
A: @Body() reads the request body (POST/PUT data). @Param() reads URL path parameters like :id in /users/:id. @Query() reads query string values like ?page=1&limit=10.

Q: How do you apply logging or auth across all controllers without repeating code?
A: Use interceptors and guards. Register them globally in main.ts with app.useGlobalInterceptors() and app.useGlobalGuards(). Controllers stay clean; shared concerns live in one place.

Examples

Basic: CRUD controller for a resource

typescript
import { Controller, Get, Post, Delete, Param, Body, Query, HttpCode, HttpStatus } from '@nestjs/common'; import { ProductsService } from './products.service'; import { CreateProductDto } from './dto/create-product.dto'; @Controller('api/products') export class ProductsController { constructor(private productsService: ProductsService) {} @Get() // GET /api/products?category=electronics&limit=10 listProducts( @Query('category') category?: string, @Query('limit') limit = 20 ) { return this.productsService.findAll({ category, limit }); // Returns: { data: [...], total: 150 } } @Get(':id') // GET /api/products/42 getProduct(@Param('id') id: string) { return this.productsService.findById(parseInt(id)); // Returns: { id: 42, name: 'Laptop', price: 999 } } @Post() @HttpCode(HttpStatus.CREATED) // POST /api/products → returns 201 createProduct(@Body() dto: CreateProductDto) { return this.productsService.create(dto); // Returns: { id: 43, name: 'Mouse', price: 25, createdAt: '...' } } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) // DELETE /api/products/42 → returns 204 with no body deleteProduct(@Param('id') id: string) { this.productsService.delete(parseInt(id)); } }

@HttpCode(HttpStatus.CREATED) overrides the default 200 status. @HttpCode(HttpStatus.NO_CONTENT) tells NestJS to return 204 with no response body. Both are HTTP concerns that belong exactly here.

Intermediate: Webhook handler with custom headers

typescript
import { Controller, Post, Body, Param, Headers, BadRequestException } from '@nestjs/common'; @Controller('api/orders') export class OrdersController { constructor(private ordersService: OrdersService) {} @Post(':id/webhook') // POST /api/orders/123/webhook async handleWebhook( @Param('id') orderId: string, @Body() payload: any, @Headers('x-webhook-signature') signature: string ) { // Validate the signature before trusting the payload if (!this.verifySignature(payload, signature)) { throw new BadRequestException('Invalid webhook signature'); } await this.ordersService.processWebhook(parseInt(orderId), payload); return { status: 'processed', orderId: parseInt(orderId) }; } private verifySignature(payload: any, signature: string): boolean { return true; // signature verification logic here } }

@Headers('x-webhook-signature') pulls a specific header from the request. The controller validates the signature (HTTP concern), then passes the payload to the service (business logic). Clean split between the two layers.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?