Controllers in NestJS
Controllers represent the entry and external communication layer in a backend architecture built with NestJS. In this guide, we will explore their architectural foundations, the HTTP request lifecycle, and advanced features for designing robust, typed, and decoupled REST APIs.
To follow the practical section of this guide, you can base your project on the course repository on GitHub: Kelocoes/compunet3-20252 on the nest/intro branch.
1. Concepts and Architectural Foundations
What is a Controller?
In software architecture based on the MVC (Model-View-Controller) pattern or layered architectures for REST APIs, a Controller is the component responsible for receiving incoming client requests (HTTP Requests), parsing parameters and request bodies, invoking the appropriate business logic (usually encapsulated in Services), and returning a structured response to the client (HTTP Response).
In NestJS, a controller is a TypeScript class annotated with the @Controller() decorator. Its primary purpose is routing: associating URL routes and HTTP methods (GET, POST, PUT, PATCH, DELETE) with specific controller functions known as route handlers.
Responsibilities of each component
- HTTP Client: Sends requests to specific endpoints carrying headers, paths, query params, or JSON payloads.
- NestJS Router: Inspects the requested path and HTTP verb, directing execution to the corresponding handler in the controller.
- Controller: Extracts and validates request data (using decorators like
@Param(),@Body(),@Query()), delegates heavy work to the service, and configures the HTTP status code and response headers. - Service (
@Injectable()): Houses domain logic, computations, business rules, and transaction control. - Repository / Database: Performs physical persistence and querying of entities.
A controller must never contain heavy business logic or directly execute raw SQL queries and repository operations. Keeping controllers thin (thin controllers) and services rich (rich services) guarantees high cohesion, low coupling, and simplifies unit testing.
Parameter Mapping and HTTP Decorators
NestJS provides dedicated decorators to declaratively extract any segment of an incoming HTTP request:
| Decorator | Underlying HTTP Object | Purpose & Example |
|---|---|---|
@Controller('prefix') | Route Prefix | Sets the base URL path prefix for all endpoints in the class. |
@Get(), @Post(), @Patch(), @Delete() | HTTP Method | Defines the HTTP verb associated with the route handler. |
@Param('key') | req.params[key] | Captures route path parameters from the URL (e.g., /users/:id). |
@Query('key') | req.query[key] | Captures query string parameters (e.g., /products?category=tech). |
@Body() | req.body | Extracts the JSON payload sent in the request body. |
@Headers('key') | req.headers[key] | Retrieves individual HTTP headers or the entire headers object. |
@HttpCode(status) | Status Code | Overrides the default HTTP response status code (e.g., 201, 204). |
@Res() | Native Response Object | Injects the underlying platform response (Express/Fastify), breaking platform independence if used unsafely. |
Route Parameters (@Param) and Query Parameters (@Query)
import { Controller, Get, Param, Query } from '@nestjs/common';
@Controller('users')
export class UsersController {
// GET /users/42
@Get(':id')
findOne(@Param('id') id: string) {
return `Returns user with id: ${id}`;
}
// GET /users?role=admin&limit=10
@Get()
findAll(@Query('role') role: string, @Query('limit') limit: string) {
return `Filtering users by role: ${role}, limit: ${limit}`;
}
}
Request Body (@Body) and DTOs
When receiving structured payloads in POST, PUT, or PATCH requests, @Body() is combined with a Data Transfer Object (DTO) class:
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
@Post()
@HttpCode(HttpStatus.CREATED) // Explicitly returns HTTP 201
create(@Body() createUserDto: CreateUserDto) {
return `User created: ${createUserDto.name}`;
}
}
Request Headers (@Headers)
To extract security tokens, API keys, or client metadata:
import { Controller, Get, Headers } from '@nestjs/common';
@Controller('auth')
export class AuthController {
@Get('profile')
getProfile(@Headers('authorization') authHeader: string) {
return `Received Authorization header: ${authHeader}`;
}
}
Common headers in REST APIs:
| Header | Purpose | Example Value |
|---|---|---|
Authorization | Authentication token (JWT, Bearer token) | Bearer eyJhbGciOiJIUz... |
Content-Type | MIME type of request body | application/json |
User-Agent | Client identification string | Mozilla/5.0 ... |
Accept | Target response format | application/json |
X-Request-ID | Unique distributed request traceability ID | 123e4567-e89b-12d3-a456... |
Wildcard Routes
NestJS supports pattern-based routing using asterisks (*):
import { Controller, Get, Param } from '@nestjs/common';
@Controller('files')
export class FilesController {
// Matches /files/docs/report.pdf or any nested subpath
@Get('*')
getFile(@Param() params: string[]) {
return `Requested path: ${params[0]}`;
}
// Intermediate wildcard: /files/download/reports/2026/details
@Get('download/*/details')
getFileDetails(@Param() params: string[]) {
return `Subpath details: ${params[0]}`;
}
}
HTTP Responses and Exception Handling
By default, NestJS returns JSON with status 200 OK (or 201 CREATED for POST). When errors occur, NestJS includes a built-in Exception Filters layer that catches standard HTTP exceptions and serializes them into a consistent JSON response.
import { BadRequestException } from '@nestjs/common';
throw new BadRequestException('Invalid form inputs', {
cause: new Error('DTO validation failure'),
description: 'Email address is already in use',
});
The client receives a clean, standardized error response:
{
"message": "Invalid form inputs",
"error": "Email address is already in use",
"statusCode": 400
}
Standard HTTP Exceptions in NestJS
| Exception in NestJS | Status Code | Description & Use Case |
|---|---|---|
BadRequestException | 400 | Malformed client request syntax or invalid parameters. |
UnauthorizedException | 401 | Missing, expired, or invalid credentials. |
ForbiddenException | 403 | Valid credentials, but insufficient permissions for the resource. |
NotFoundException | 404 | Target resource does not exist in the database. |
MethodNotAllowedException | 405 | HTTP verb not allowed on the requested route. |
NotAcceptableException | 406 | Content negotiation cannot satisfy Accept headers. |
RequestTimeoutException | 408 | Server timed out waiting for request processing. |
ConflictException | 409 | Conflict with current entity state (e.g., unique key violation). |
GoneException | 410 | Resource once existed but has been permanently deleted. |
PayloadTooLargeException | 413 | Request payload exceeds configured maximum size. |
UnsupportedMediaTypeException | 415 | Unsupported media type provided in Content-Type. |
UnprocessableEntityException | 422 | Semantic validation errors in complex payloads. |
InternalServerErrorException | 500 | Unhandled error or unexpected server failure. |
NotImplementedException | 501 | Route or feature not yet supported. |
BadGatewayException | 502 | Invalid response received from an upstream service. |
ServiceUnavailableException | 503 | Server overloaded or undergoing maintenance. |
GatewayTimeoutException | 504 | Upstream service failed to respond within time limit. |
2. Practical Guide: Decoupled Exception Architecture & Controller Implementation
In this hands-on section, we will implement decoupled custom domain exceptions and a complete UsersController wired to its service and TypeORM repository.
Project structure:
src
├── common
│ └── exceptions
│ ├── http
│ │ ├── role-not-found.exception.ts
│ │ ├── user-not-found.exception.ts
│ └── index.ts
└── users
├── dto
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── entities
│ └── user.entity.ts
├── users.controller.ts
└── users.service.ts
Create Custom Domain Exceptions
We create classes extending NotFoundException to standardize application-specific error messages.
import { NotFoundException } from '@nestjs/common';
/**
* Custom exception thrown when a requested role does not exist.
*/
export class RoleNotFoundException extends NotFoundException {
constructor(roleIdOrName: number | string) {
super({
error: 'Role Not Found',
message: `Role with ID or name '${roleIdOrName}' does not exist in the system.`,
});
}
}
import { NotFoundException } from '@nestjs/common';
/**
* Custom exception thrown when a user record is not found in the database.
*/
export class UserNotFoundException extends NotFoundException {
constructor(userId: number, internalCode?: string) {
super({
error: 'User Not Found',
message: `User with ID ${userId} was not found.`,
code: internalCode,
});
}
}
Export all exceptions through a barrel file:
export * from './http/role-not-found.exception';
export * from './http/user-not-found.exception';
Implement Business Logic in the Service Layer
The UsersService manages TypeORM persistence and throws descriptive exceptions whenever records are missing.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { RolesService } from '../auth/services/roles.service';
import {
RoleNotFoundException,
UserNotFoundException,
} from '../common/exceptions';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly rolesService: RolesService,
) {}
/**
* Creates a new user after verifying that the assigned role exists.
*/
async create(createUserDto: CreateUserDto): Promise<User> {
const role = await this.rolesService.findByName(createUserDto.roleName);
if (!role) {
throw new RoleNotFoundException(createUserDto.roleName);
}
const newUser = this.userRepository.create({
...createUserDto,
role,
});
return await this.userRepository.save(newUser);
}
/**
* Returns all registered users.
*/
async findAll(): Promise<User[]> {
return await this.userRepository.find();
}
/**
* Finds a user by ID or throws UserNotFoundException if not found.
*/
async findOne(id: number): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new UserNotFoundException(id);
}
return user;
}
/**
* Updates an existing user record.
*/
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const userExist = await this.userRepository.findOne({ where: { id } });
if (!userExist) {
throw new UserNotFoundException(id, 'ERR_USER_UPDATE_404');
}
await this.userRepository.update(id, updateUserDto);
return this.findOne(id);
}
/**
* Deletes a user by ID.
*/
async remove(id: number): Promise<void> {
const result = await this.userRepository.delete(id);
if (!result.affected || result.affected === 0) {
throw new UserNotFoundException(id);
}
}
}
Build the Complete Controller with Semantic HTTP Codes
The controller handles /users endpoints, converts numeric params, and returns semantic status codes (200 OK, 201 CREATED, 204 NO CONTENT).
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
InternalServerErrorException,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
/**
* POST /users -> Creates a new resource (201 Created)
*/
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
/**
* GET /users -> Lists all users (200 OK)
*/
@Get()
@HttpCode(HttpStatus.OK)
findAll() {
return this.usersService.findAll();
}
/**
* GET /users/:id -> Returns a single user (200 OK)
*/
@Get(':id')
@HttpCode(HttpStatus.OK)
findOne(@Param('id') id: string) {
// Convert string parameter to number using the unary + operator
return this.usersService.findOne(+id);
}
/**
* PATCH /users/:id -> Partially updates a user (200 OK)
*/
@Patch(':id')
@HttpCode(HttpStatus.OK)
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
) {
try {
return await this.usersService.update(+id, updateUserDto);
} catch (error) {
// If it's a domain HTTP exception, rethrow it directly
if (error instanceof Error && 'status' in error) {
throw error;
}
throw new InternalServerErrorException('Failed to update user', {
cause: error,
description: 'Unexpected database failure while persisting user changes.',
});
}
}
/**
* DELETE /users/:id -> Deletes user with no content returned (204 No Content)
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string): Promise<void> {
await this.usersService.remove(+id);
}
}