Segundo post de la serie NestJS! Si no has visto el primero, pásate antes porque ahí montamos todo el entorno con Docker y explicamos la arquitectura base de NestJS. Hoy toca lo que más vas a usar en tu día a día: los Controllers.
Un controller es la primera línea de tu API. Es lo que recibe las peticiones HTTP, extrae los datos que vienen en la URL, en el body, en los headers… y le pasa todo eso a los services para que hagan el trabajo. Vamos a ver todos los decoradores, todas las formas de recibir datos y cómo organizar las rutas para que tu API quede limpia y profesional.
EA, vamos al lío.

1. ¿Qué es un Controller?
Ya lo vimos por encima en el post anterior, pero vamos a croquetar un poquito más. Un 💡 Controller Clase decorada con @Controller() que se encarga de recibir peticiones HTTP entrantes, extraer datos de la request y devolver respuestas. Define las rutas de tu API y delega la lógica de negocio a los Services.
Más info →
es una clase que agrupa rutas relacionadas. Si tienes una entidad users, tu UsersController tendrá las rutas para crear, leer, actualizar y borrar usuarios.
Un controller NO debería tener lógica de negocio. Su trabajo es recibir la petición, extraer los datos, llamar al service correspondiente y devolver la respuesta, e ya!
// src/users.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
findAll(): string {
return 'Aquí van todos los usuarios';
}
}
El decorador @Controller('users') hace dos cosas:
- Marca la clase como un controller de NestJS.
- Establece el prefijo de ruta
/users. Todas las rutas que definas dentro de este controller empezarán por/users.
Ojito cuidao que si estamos trasteando con el anterior código que hicimos en el post 1 de esta serie, vamos a tener que añadir este nuevo controller en el
AppModulede la app, encontrollers, si no, por mucho controller que hagas, si no lo añades, la app no lo procesará.
@Module({
imports: [],
controllers: [AppController, UsersController],
providers: [AppService],
})
export class AppModule {}
Más o menos, asíN.
2. Decoradores de métodos HTTP
NestJS tiene un decorador para cada método HTTP, cada uno mapea un método de la clase a un tipo de petición concreta.
| Decorador | Método HTTP | Uso típico |
|---|---|---|
@Get() | GET | Obtener recursos |
@Post() | POST | Crear un recurso nuevo |
@Put() | PUT | Reemplazar un recurso completo |
@Patch() | PATCH | Actualizar parcialmente un recurso |
@Delete() | DELETE | Eliminar un recurso |
@Options() | OPTIONS | Preflight CORS |
@Head() | HEAD | Como GET pero sin body |
@All() | TODOS | Responde a cualquier método |
Los que vas a usar casi siempre @Get(), @Post(), @Patch() y @Delete(). Los demás están ahí por si los necesitas en casos muy concretos pero igual nunca los llegas a usar.
Combinando prefijo + ruta
La ruta final es la combinación del prefijo del controller con la ruta del decorador:
@Controller('users') // Prefijo: /users
export class UsersController {
@Get() // GET /users
findAll(): string {
return 'Lista de usuarios';
}
@Get('active') // GET /users/active
findActive(): string {
return 'Usuarios activos';
}
@Post() // POST /users
create(): string {
return 'Crea un usuario';
}
}
El nombre del método findAll, findActive, create le da absolutamente igual a NestJS. Podrías llamarlos papita() y sarvesita() y funcionaría igual, lo que importa es el decorador que lleva encima. A pesar de que el nombre sea en este sentido irrelevante, lo mejor es ponerles un nombre bien clarito para dejar documentado el código y entendible, así uno ya se imagina que hace, nombres descriptivos ahorran comentarios!
3. Parámetros de ruta con @Param()
Cuando necesitas identificar un recurso concreto, hay que usar parámetros dinámicos en la ruta. Se definen con : y se extraen con @Param(), amo a verlo:
// GET /users/42
@Get(':id')
findOne(@Param('id') id: string): string {
return `Usuario con id ${id}`; // Usuario con id 42
}
El parámetro siempre llega como string. Si necesitas un number, puedes usar un Pipe Pipe Clase que transforma o valida los datos de entrada antes de que lleguen al handler del controller. NestJS incluye pipes como ParseIntPipe, ParseUUIDPipe, ValidationPipe, etc. de transformación que viene nativo con NestJS:
import { ParseIntPipe } from '@nestjs/common';
// GET /users/42 → id será de tipo number
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): string {
return `Usuario con id ${id}`;
}
Si alguien hace GET /users/abc, el ParseIntPipe devuelve automáticamente un error 400 Bad Request con un mensaje claro, no tienes que validar tú a mano el parámetro.
Múltiples parámetros
// GET /users/42/posts/7
@Get(':userId/posts/:postId')
findUserPost(
@Param('userId', ParseIntPipe) userId: number,
@Param('postId', ParseIntPipe) postId: number,
): string {
return `Post ${postId} del usuario ${userId}`; // Post 42 del usuario 7
}
Objeto de parámetros completo
Si prefieres recibir todos los parámetros juntos como un objeto:
@Get(':id')
findOne(@Param() params: { id: string }): string {
return `Usuario con id ${params.id}`;
}
Normalmente es más limpio extraer cada parámetro por separado con @Param('nombre'), pero la opción está ahí aunque no se recomienda usar.
4. Query strings con @Query()
Los query parameters query parameters Parámetros que van después del ? en la URL. Ejemplo: /users?page=1&limit=10. Se usan para filtrar, paginar o configurar la respuesta sin cambiar la ruta. son los que van después del ? en la URL, se extraen con @Query():
// GET /users?page=2&limit=10
@Get()
findAll(
@Query('page') page: string,
@Query('limit') limit: string,
): string {
return `Página ${page}, límite ${limit}`;
}
Los query parameters también llegan como string. Para transformarlos a number, puedes usar pipes igual que con @Param:
// GET /users?page=2&limit=10
@Get()
findAll(
@Query('page', ParseIntPipe) page: number,
@Query('limit', ParseIntPipe) limit: number,
): string {
return `Página ${page}, mostrando ${limit} resultados`;
}
Query object completo
Si tienes muchos query parameters, puedes tiparlos con una interfaz y recibirlos todos de golpe:
interface FindUsersQuery {
page?: string;
limit?: string;
sort?: string;
order?: 'asc' | 'desc';
}
@Get()
findAll(@Query() query: FindUsersQuery): string {
return `Filtros: ${JSON.stringify(query)}`;
}
Veremos en el post de DTOs y Validación cómo validar estos query parameters con class-validator para que no te cuelen cualquier cosa.
5. Request body con @Body()
Para crear o actualizar recursos, los datos llegan en el body de la petición. Se extraen con @Body():
@Post()
create(@Body() body: { name: string; email: string }): string {
return `Creado usuario ${body.name}`;
}
Pero oye, eso de tipar el body inline está feo y no escala, aquí es donde entran los DTOs.
DTOs: Data Transfer Objects
Un 💡 DTO Data Transfer Object. Clase que define la forma exacta de los datos que viajan entre el cliente y el servidor. En NestJS se usan clases (no interfaces) porque las clases existen en runtime y permiten la validación automática con decoradores.
Más info →
es una clase que define la forma de los datos que entran en la API. ¿Por qué una clase y no una interfaz? Porque las interfaces de TypeScript desaparecen después de compilar a JavaScript. No existen en runtime runtime El momento en que el código se ejecuta, después de haber sido compilado. A diferencia de compile time (cuando TypeScript comprueba tipos), en runtime solo existe JavaScript puro. , las clases sí. Y NestJS necesita la información del tipo en runtime para funcionalidades como validación automática con ValidationPipe y los Pipes.
// dto/create-user.dto.ts
export class CreateUserDto {
name: string;
email: string;
age: number;
}
Y en el controller:
import { type CreateUserDto } from './dto/create-user.dto';
@Post()
create(@Body() createUserDto: CreateUserDto): string {
return `Creado usuario ${createUserDto.name} con email ${createUserDto.email}`;
}
Ahora el body está tipado tt y si intentas acceder a createUserDto.direccion, TypeScript te avisa de que esa propiedad no existe. Y más adelante, cuando añadamos class-validator, este DTO también servirá para validar automáticamente que los datos que llegan tienen la forma correcta.
Extraer un campo concreto del body
Si solo necesitas un campo:
@Post()
create(@Body('name') name: string): string {
return `Creado usuario ${name}`;
}
Aunque normalmente es mejor usar el DTO completo porque en un proyecto real vas a necesitar más de un campo.
6. Headers con @Headers()
Para leer cabeceras HTTP de la petición:
import { Headers } from '@nestjs/common';
// Leer un header concreto
@Get()
findAll(@Headers('authorization') auth: string): string {
return `Token: ${auth}`;
}
// Leer todos los headers
@Get('debug')
debug(@Headers() headers: Record<string, string>): Record<string, string> {
return headers;
}
Esto es útil para tokens de autenticación, idioma, versión de la API, etc. Pero para auth no lo hagas así a mano, ya veremos en el post de Authentication cómo usar Guards y Passport para que sea automático.
7. Status codes con @HttpCode()
Por defecto, NestJS devuelve 200 para todo excepto POST que devuelve 201, si necesitas cambiarlo se hace así:
import { HttpCode, HttpStatus } from '@nestjs/common';
@Post()
@HttpCode(HttpStatus.NO_CONTENT) // 204
create(@Body() createUserDto: CreateUserDto): void {
// No devolvemos nada, status 204
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number): void {
// Recurso eliminado, 204 No Content
}
NestJS tiene el enum HttpStatus con todos los códigos HTTP para que no tengas que recordar los números de memoria, que está de pm. Puedes usar HttpStatus.OK (200), HttpStatus.CREATED (201), HttpStatus.NO_CONTENT (204), HttpStatus.BAD_REQUEST (400), etc.
8. Headers de respuesta con @Header()
Para añadir cabeceras a la respuesta (no confundir con @Headers() que lee las de la petición):
import { Header } from '@nestjs/common';
@Post()
@Header('Cache-Control', 'no-store')
@Header('X-Custom-Header', 'mi-valor')
create(@Body() createUserDto: CreateUserDto): CreateUserDto {
return createUserDto;
}
El decorador @Header() (singular, sin ‘s’) establece una cabecera en la respuesta. Es útil para cache control, cabeceras personalizadas, CORS manual, etc.
9. Redirects con @Redirect()
Si necesitas redirigir a otra URL, lo puedes hacer con el decorator @Redirect así:
import { Redirect } from '@nestjs/common';
@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
redirectToDocs(): void {}
Y si necesitas un redirect dinámico dependiendo de alguna condición, devuelves un objeto con url y statusCode:
@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
redirectToDocs(@Query('version') version: string) {
if (version === '9') {
return { url: 'https://docs.nestjs.com/v9/', statusCode: 301 };
}
// Si no devuelves nada, usa el redirect por defecto del decorador
}
10. Tabla de decoradores de parámetros
Esta tabla es una referencia de los parámetros de Express a NestJS. Guárdala porque igual la consultas alguna vez:
| Decorador NestJS | Equivalente en Express |
|---|---|
@Req() / @Request() | req |
@Res() / @Response() | res |
@Param(key?) | req.params / req.params[key] |
@Body(key?) | req.body / req.body[key] |
@Query(key?) | req.query / req.query[key] |
@Headers(name?) | req.headers / req.headers[name] |
@Ip() | req.ip |
@Session() | req.session |
@HostParam() | req.hosts |
Si vienes de Express, esta tabla es útil. Cada req.body, req.params, req.query que hacías en Express ahora tiene su propio decorador que extrae exactamente lo que necesitas de forma limpia y tipada.
11. El objeto Response: dos formas de responder
NestJS te da dos formas de enviar respuestas. La primera es la estándar y la que deberías usar siempre que puedas:
Forma estándar (la buena)
Simplemente devuelves un valor desde el método:
@Get()
findAll(): User[] {
return this.usersService.findAll();
// NestJS serializa a JSON automáticamente, status 200
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
return this.usersService.findOne(id);
// Objeto → JSON automático
}
@Post()
create(@Body() dto: CreateUserDto): User {
return this.usersService.create(dto);
// POST → status 201 por defecto
}
- Si devuelves un objeto o array → se serializa a JSON automáticamente.
- Si devuelves un string → se envía tal cual como texto.
- Si devuelves una Promise → se resuelve automáticamente.
- Status por defecto: 200 (o 201 para POST).
Forma library-specific (la que hay que evitar)
Inyectando el objeto Response de Express directamente:
import { Res } from '@nestjs/common';
import { Response } from 'express';
@Get()
findAll(@Res() res: Response): void {
res.status(200).json(this.usersService.findAll());
}
- La forma estándar (devolver valores). Es más limpia, más testeable y funciona con Interceptors y serialización automática
- @Res({ passthrough: true }) si necesitas acceso al response para cookies o headers pero quieres que NestJS siga manejando la respuesta
- @Res() sin passthrough. Te obliga a manejar la respuesta manualmente, pierdes interceptors, serialización y NestJS se desentiende
- Mezclar @Res() con return. Si usas @Res(), NestJS no procesa el valor de retorno y la petición se queda colgada
12. Handlers asíncronos
En el día a día, probablemente tus controllers van a llamar a services que hacen consultas a base de datos, peticiones HTTP, etc, todo asíncrono. NestJS lo maneja de forma nativa:
Con async/await (lo más común)
@Get()
async findAll(): Promise<User[]> {
return this.usersService.findAll();
// NestJS resuelve la Promise y envía el resultado
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
return this.usersService.findOne(id);
}
Con Observables (RxJS)
import { Observable } from 'rxjs';
@Get()
findAll(): Observable<User[]> {
return this.usersService.findAll();
// NestJS se suscribe y envía el último valor emitido
}
Vale me parece genial nene, pero ¿qué es un Observable?
Si vienes de async/await y ves Observable por primera vez, es normal quedarse un poco loco, vamos a explicarlo paque se entienda.
Una Promise es como pedir una pizza por teléfono. Llamas, haces el pedido, y en algún momento te llega una pizza, una sola y se acabó. Eso es una Promise: un valor futuro que llega una vez.
// Promise: una pizza, un valor, se acabó
const pizza: Promise<Pizza> = pedirPizza();
const miPizza = await pizza; // Llega UNA pizza
Un Observable es como suscribirte a un canal de YouTube. Te suscribes y te van llegando vídeos cuando el canal los sube. Pueden llegar 0, 1, 5, 100 o infinitos vídeos a lo largo del tiempo y puedes desuscribirte cuando quieras.
// Observable: un canal de YouTube, múltiples valores a lo largo del tiempo
import { Observable } from 'rxjs';
const canal: Observable<Video> = suscribirseAlCanal();
canal.subscribe({
next: (video) => console.log('¡Nuevo vídeo!', video), // Cada vez que sube uno
error: (error) => console.log('El canal petó', error), // Si el canal explota
complete: () => console.log('El canal cerró'), // Si el canal se cierra
});
| Concepto | Promise | Observable |
|---|---|---|
| Analogía | Pedir una pizza | Suscribirte a un canal |
| ¿Cuántos valores? | Uno solo | 0, 1, 100 o infinitos |
| ¿Se puede cancelar? | No (una vez pedida, viene) | Sí (te desuscribes) |
| ¿Cuándo se ejecuta? | Inmediatamente al crearla | Solo cuando alguien se suscribe ( lazy lazy Un Observable no hace nada hasta que alguien llama a .subscribe(). Es como un grifo: el agua solo fluye si lo abres. ) |
| Sintaxis | await promise | observable.subscribe() |
¿Y por qué NestJS usa Observables? Porque internamente su pipeline (interceptors, guards, filters) está construido con RxJS. Cuando tu handler devuelve una Promise, NestJS la convierte a Observable por debajo. Cuando devuelves un Observable directamente, NestJS se suscribe automáticamente, coge el valor y lo manda como respuesta HTTP.
¿Cuándo vas a usar Observables en vez de Promises? En la práctica, casi nunca en los controllers. El async/await es más limpio y más fácil de leer. Los Observables brillan en los Interceptors y cuando trabajas con WebSockets o eventos en tiempo real. En los controllers, usa async/await y no te compliques.
Las dos formas son bien eh, en la práctica, async/await es lo que usa el 95% del mundo porque es más fácil de leer. Los Observables tienen sentido cuando trabajas con streams o eventos reactivos.
13. Montando un CRUD real: el módulo Users
Vamos a montar algo real dentro de nuestro proyecto Docker del post anterior, vamos a crear un módulo users completo.
13.1. Generar con el CLI
nest g resource te genera toda la estructura del CRUD y en la última línea puedes ver que actualiza el AppModule automáticamente para importar el UsersModule, eso es lo bonito del CLI.
13.2. La entidad User
Empezamos definiendo cómo es un usuario. Editamos el archivo entities/user.entity.ts:
// src/users/entities/user.entity.ts
export class User {
id: number;
name: string;
email: string;
age: number;
active: boolean;
}
De momento es una clase simple, cuando lleguemos al post de TypeORM le pondremos los decoradores de base de datos.
13.3. Los DTOs
El DTO de creación define qué datos necesitas para crear un usuario:
// src/users/dto/create-user.dto.ts
export class CreateUserDto {
name: string;
email: string;
age: number;
}
Y el de actualización usa PartialType para hacer todos los campos opcionales, esto viene del paquete @nestjs/mapped-types:
// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
💡 PartialType Helper de @nestjs/mapped-types que crea una nueva clase con todas las propiedades de la clase original pero marcadas como opcionales (?). Perfecto para DTOs de actualización donde no necesitas enviar todos los campos.
Más info →
es genial porque no tienes que repetir los campos. Si mañana añades un campo phone al CreateUserDto, automáticamente estará disponible (y será opcional) en UpdateUserDto.
13.4. El Service
El service se encarga de la lógica. De momento simulamos una base de datos con un array en memoria:
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
private users: User[] = [];
private idCounter: number = 1;
create(createUserDto: CreateUserDto): User {
const user: User = {
id: this.idCounter++,
...createUserDto,
active: true,
};
this.users.push(user);
return user;
}
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find((u) => u.id === id);
if (!user) {
throw new NotFoundException(`Usuario con id ${id} no encontrado`);
}
return user;
}
update(id: number, updateUserDto: UpdateUserDto): User {
const user = this.findOne(id);
Object.assign(user, updateUserDto);
return user;
}
remove(id: number): void {
const index = this.users.findIndex((u) => u.id === id);
if (index === -1) {
throw new NotFoundException(`Usuario con id ${id} no encontrado`);
}
this.users.splice(index, 1);
}
}
Mira el NotFoundException. Es una excepción que viene con NestJS de forma nativa y automáticamente devuelve un 404 con un mensaje JSON limpio:
{
"statusCode": 404,
"message": "Usuario con id 99 no encontrado",
"error": "Not Found"
}
No tienes que montar tu propio sistema de errores porque NestJS tiene excepciones para cada status code: BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), ConflictException (409)… Ya veremos esto más en profundidad en el post de Exception Filters.
13.5. El Controller completo
Ahora sí, el controller que junta todo:
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { type User } from './entities/user.entity';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto): User {
return this.usersService.create(createUserDto);
}
@Get()
findAll(): User[] {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
return this.usersService.findOne(id);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
): User {
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number): void {
this.usersService.remove(id);
}
}
Cada método hace exactamente una cosa:
POST /users→ Crea un usuario y devuelve 201 (por defecto para POST).GET /users→ Lista todos los usuarios.GET /users/:id→ Busca un usuario por id. Si no existe, 404 automático gracias alNotFoundException.PATCH /users/:id→ Actualiza parcialmente un usuario.DELETE /users/:id→ Elimina un usuario y devuelve 204 No Content.
Todos los id se transforman a number con ParseIntPipe. Todos los tipos de retorno están bien definidos y no hay un solo any en todo el código.
13.6. El Module
El módulo conecta el controller con el service:
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Y el AppModule importa este módulo (el CLI ya lo hizo por nosotros):
// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}
13.7. Probando el CRUD
Con el contenedor corriendo, probamos todos los endpoints:
CRUD completo y tipado, con manejo de errores y corriendo en Docker y no hemos escrito ni una línea de configuración de Express.
14. Rutas con wildcards
Para casos puntuales donde necesitas que una ruta capture cualquier path:
@Get('assets/*')
serveAssets(): string {
// Captura: GET /users/assets/foto.jpg
// Captura: GET /users/assets/docs/manual.pdf
return 'Sirviendo assets';
}
El * captura cualquier cosa que venga después. No es algo que vayas a usar a diario, pero es bien saber que existe.
15. Recapitulando
Define el controller y su prefijo de ruta. Todas las rutas del controller empiezan por ese prefijo.
@Get, @Post, @Put, @Patch, @Delete mapean métodos de la clase a endpoints HTTP concretos.
Extraen datos de la URL, query string y body de forma tipada. Con Pipes se validan y transforman automáticamente.
Clases (no interfaces) para definir la forma de los datos. Existen en runtime para validación automática.
Transforma strings a numbers automáticamente y devuelve 400 si el valor no es válido.
Devuelve valores directamente. NestJS serializa a JSON, gestiona el status code y los headers.
En el próximo post vamos a meternos con los Providers y la Inyección de Dependencias, que es lo que hace que todo este tinglao funcione bien. Veremos custom providers, scopes, useFactory, useValue y cómo NestJS resuelve las dependencias.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Qué decorador establece el prefijo de ruta para todas las rutas de un controller?
2. ¿Qué status code devuelve NestJS por defecto en una petición POST?
3. ¿Por qué los DTOs en NestJS deben ser clases y no interfaces de TypeScript?
4. ¿Qué hace ParseIntPipe cuando recibe un valor que no es un número?
5. ¿Qué problema tiene usar @Res() sin passthrough en un handler?