NestJS Controller Standards
Controllers handle HTTP requests. They validate input, call services, transform errors to HTTP exceptions, and return response DTOs. Keep them thin - business logic belongs in services.
Core Principles
Automatic observability Global interceptors handle tracing, metrics, and response enrichment. No manual instrumentation needed in controllers.
Controllers are thin Validate input → call service → handle errors → return DTO. That's it. No business logic.
Always use DTOs Return instantiated DTO classes, not plain objects. TypeScript validation and Swagger docs depend on this.
Transform service errors to HTTP exceptions Catch custom service errors and convert to appropriate HTTP exceptions (NotFoundException, BadRequestException, etc).
Automatic Observability
Global interceptors run on every request (configured in main.ts):
1. ContextInterceptor - Stores requestId, userId, traceId, spanId in AsyncLocalStorage 2. TracingInterceptor - Extracts OpenTelemetry trace context 3. MetricsInterceptor - Records HTTP metrics (rate, duration, errors) 4. ResponseInterceptor - Adds requestId, duration, timestamp to responses
Controllers don't need any metrics or tracing code. Just call services and return DTOs.
Basic Structure
Controllers receive HTTP requests, delegate to services, and return DTOs.
import { Controller, Get, Post, Param, Body, Query, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOkResponse, ApiParam, ApiBody } from '@nestjs/swagger';
@ApiTags('Users')
@Controller({ version: '1', path: 'users' })
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOkResponse({ type: UserReadManyResponseDTO })
async readMany(@Query() query: UserReadManyQueryDTO): Promise<UserReadManyResponseDTO> {
const { total, results } = await this.userService.readMany({
email: query.email,
name: query.name,
inIds: query.inIds,
ninIds: query.ninIds,
skip: query.skip,
limit: query.limit,
sortKey: query.sortKey,
sortValue: query.sortValue,
createdAtFrom: query.createdAtFrom,
createdAtTo: query.createdAtTo,
updatedAtFrom: query.updatedAtFrom,
updatedAtTo: query.updatedAtTo,
});
return new UserReadManyResponseDTO({ query, total, results });
}
@Get(':id')
@ApiParam({ name: 'id', type: String })
@ApiOkResponse({ type: UserResponseDTO })
async readOne(@Param('id') id: string): Promise<UserResponseDTO> {
try {
const result = await this.userService.readOne({ id });
return new UserResponseDTO({ result });
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new NotFoundException(error.message);
}
throw error;
}
}
@Post()
@ApiBody({ type: CreateUserBodyDTO })
@ApiOkResponse({ type: UserResponseDTO })
async createOne(@Body() body: CreateUserBodyDTO): Promise<UserResponseDTO> {
try {
const result = await this.userService.createOne({
name: body.name,
email: body.email,
});
return new UserResponseDTO({ result });
} catch (error) {
if (error instanceof DuplicateEmailError) {
throw new BadRequestException(error.message);
}
throw error;
}
}
}
Error Handling
Wrap service calls in try-catch and convert custom errors to HTTP exceptions.
try {
const result = await this.service.readOne({ id });
return new ResponseDTO({ result });
} catch (error) {
// Convert known service errors to HTTP exceptions
if (error instanceof NotFoundError) {
throw new NotFoundException(error.message);
}
if (error instanceof ValidationError) {
throw new BadRequestException(error.message);
}
// Unknown errors propagate as 500 Internal Server Error
throw error;
}
Common HTTP exceptions:
NotFoundException(404) - Resource not foundBadRequestException(400) - Invalid inputUnauthorizedException(401) - Authentication requiredForbiddenException(403) - Permission deniedConflictException(409) - Resource conflict (duplicate)
Binary Responses
For files, images, or other binary data, use @Res() and send directly.
import { Res } from '@nestjs/common';
import { Response } from 'express';
@Get(':id/icon.png')
async readIcon(
@Param('id') id: string,
@Res() res: Response,
): Promise<void> {
try {
const iconBuffer = await this.service.readIcon({ id });
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(iconBuffer);
} catch (error) {
if (error instanceof IconNotFoundError) {
throw new NotFoundException(error.message);
}
throw error;
}
}
Swagger Documentation
All endpoints need Swagger decorators for API documentation.
@ApiTags('Users') // Groups endpoints in Swagger UI
@Controller({ version: '1', path: 'users' })
@Get(':id')
@ApiParam({ name: 'id', type: String, description: 'User ID', example: '123' })
@ApiOkResponse({ type: UserResponseDTO, description: 'User found' })
@Post()
@ApiBody({ type: CreateUserBodyDTO })
@ApiOkResponse({ type: UserResponseDTO, description: 'User created' })
Anti-Patterns
❌ Don't put business logic in controllers Controllers orchestrate, services contain logic.
// Bad - business logic in controller
@Post()
async createUser(@Body() body: CreateUserBodyDTO) {
// Validation, database queries, transformations...
// All of this belongs in a service!
const user = await this.repository.save({
name: body.name,
email: body.email.toLowerCase(),
createdAt: new Date(),
});
return user;
}
// Good - delegate to service
@Post()
async createUser(@Body() body: CreateUserBodyDTO) {
const result = await this.userService.createOne(body);
return new UserResponseDTO({ result });
}
❌ Don't return plain objects Always return instantiated DTO classes.
// Bad - plain object
@Get(':id')
async readOne(@Param('id') id: string) {
const user = await this.service.readOne({ id });
return { user }; // Plain object - breaks Swagger
}
// Good - instantiated DTO
@Get(':id')
async readOne(@Param('id') id: string) {
const result = await this.service.readOne({ id });
return new UserResponseDTO({ result });
}
❌ Don't skip error handling Catch custom service errors and convert to HTTP exceptions.
// Bad - no error handling
@Get(':id')
async readOne(@Param('id') id: string) {
const result = await this.service.readOne({ id });
return new ResponseDTO({ result });
// UserNotFoundError becomes 500 instead of 404!
}
// Good - explicit error handling
@Get(':id')
async readOne(@Param('id') id: string) {
try {
const result = await this.service.readOne({ id });
return new ResponseDTO({ result });
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new NotFoundException(error.message);
}
throw error;
}
}
❌ Don't use @Res() for JSON responses Only use for binary data. JSON responses should return DTOs.
// Bad - manual response
@Get(':id')
async readOne(@Param('id') id: string, @Res() res: Response) {
const user = await this.service.readOne({ id });
res.json(user); // Bypasses response interceptor!
}
// Good - return DTO
@Get(':id')
async readOne(@Param('id') id: string) {
const result = await this.service.readOne({ id });
return new UserResponseDTO({ result });
}
Other common mistakes:
- ❌ Forgetting Swagger decorators (
@ApiOkResponse,@ApiParam, etc.) - ❌ Not versioning controllers (
version: '1'is required) - ❌ Using generic names (
@ApiTags('API')instead of@ApiTags('Users')) - ❌ Forgetting
@ApiTags()at class level