Cómo estructurar una aplicación Node.js de gran tamaño con NestJS
Organiza una aplicación NestJS grande por funcionalidades con módulos, controladores, servicios, validación DTO, guards, configuración y pruebas. Aclara dependencias con imports y exports explícitos.
Una aplicación NestJS de gran tamaño debe organizarse por funcionalidad: cada dominio (usuarios, pedidos, facturación) tiene su propia carpeta de módulo con su controlador, servicio, DTO y pruebas, y los módulos solo comparten código mediante exports e imports explícitos.
Si heredas una API Express que no deja de crecer, los síntomas te resultarán familiares. Un único archivo de rutas se encarga de la autenticación, la validación y las escrituras en la base de datos, y process.env se lee en una docena de sitios. Nadie tiene claro qué helper se puede modificar sin riesgo.
En este artículo construimos una pequeña API de usuarios y pedidos y colocamos cada pieza en su sitio: módulos, controladores, providers, validación, guards, configuración, un módulo compartido y una prueba unitaria. Si todavía estás eligiendo framework, Is There a Rails for JavaScript? compara las alternativas.
Puntos clave
- Un módulo de NestJS mantiene sus providers privados por defecto. Otro módulo solo puede inyectar uno si el módulo propietario lo exporta y el módulo consumidor importa ese módulo.
- Organizar por funcionalidad mantiene el módulo, el controlador, el servicio, los DTO y las pruebas de cada dominio en una sola carpeta, de modo que un cambio en pedidos se queda dentro de
src/orders/. - Con
whitelistactivado,ValidationPipeelimina las propiedades que no tienen decorador de validación. Si se añadeforbidNonWhitelisted, esas peticiones fallan en lugar de procesarse. - Un guard que devuelve
falsehace que Nest lance unaForbiddenException, por lo que las comprobaciones de autenticación residen en una sola clase y no en cada handler. - Una importación circular sin resolver entre módulos detiene Nest durante el arranque. Extraer el concepto compartido es una solución mejor que
forwardRef().
El problema: una aplicación Express sin reglas
Express no impone reglas sobre dónde va el código, así que una aplicación en crecimiento acaba organizada según cómo se escribieron sus primeros handlers. La mayoría de los proyectos empiezan como describe una guía para configurar Node.js con TypeScript y Express: un app.ts, una carpeta de rutas y algo de middleware. Al cabo de unos meses, un handler tiene este aspecto:
app.post('/orders', (req, res) => {
if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
const { userId, items } = req.body;
if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
const user = users.find((u) => u.id === userId);
if (!user) return res.sendStatus(404);
orders.push({ id: orders.length + 1, userId, items });
res.status(201).json(orders.at(-1));
});
Ese handler mezcla cinco responsabilidades. NestJS asigna a cada una un lugar fijo:
| Costumbre en Express | Construcción en NestJS |
|---|---|
| Archivo de rutas | Controlador |
| Módulo helper con lógica de negocio | Provider (servicio) |
| Comprobación de autenticación en middleware o handler | Guard |
Lecturas de process.env | ConfigService |
Comprobaciones manuales de req.body | DTO + pipe de validación |
Todo esto tiene un coste. Para una API con unas pocas rutas, los decoradores, los módulos y el contenedor de DI de NestJS son una ceremonia innecesaria. Empiezan a compensar cuando varias personas modifican una funcionalidad cada vez sin leer el resto del código.
¿Cómo estructurar un proyecto NestJS de gran tamaño?
El núcleo de una estructura de proyecto NestJS mantenible es una carpeta por funcionalidad, cada una con su propio módulo. Con esta organización, cambiar cómo funcionan los pedidos implica abrir un único directorio, no rebuscar entre árboles separados de controllers/, services/ y dto/.
src/
├── users/
│ ├── dto/create-user.dto.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── users.service.spec.ts
│ └── users.module.ts
├── orders/
│ ├── dto/create-order.dto.ts
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ ├── orders.service.spec.ts
│ └── orders.module.ts
├── shared/
│ ├── api-key.guard.ts
│ ├── audit-log.service.ts
│ └── shared.module.ts
├── app.module.ts
└── main.ts
Los módulos de NestJS encapsulan sus providers por defecto. OrdersService necesita UsersService, así que UsersModule tiene que exportarlo:
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';
@Module({
imports: [SharedModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';
@Module({
imports: [UsersModule, SharedModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
El grafo de dependencias va en una sola dirección: OrdersModule → UsersModule → SharedModule. Todo lo que UsersModule no exporta permanece interno, y eso es lo que permite refactorizar una funcionalidad con seguridad.
¿Qué son los controladores, los providers y la inyección de dependencias en NestJS?
Un controlador asigna rutas HTTP a métodos. Un provider es cualquier clase marcada con @Injectable() que Nest puede instanciar y entregar a otras clases. La inyección de dependencias significa que una clase declara lo que necesita como parámetros del constructor y el framework le proporciona las instancias. Ese mismo mecanismo permite que una prueba pase un objeto falso en lugar de la dependencia real.
// users/users.service.ts
import { Injectable } from '@nestjs/common';
export interface User { id: number; email: string }
@Injectable()
export class UsersService {
private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];
findOne(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
@Injectable()
export class OrdersService {
private readonly orders: Array<CreateOrderDto & { id: number }> = [];
constructor(
private readonly users: UsersService,
private readonly audit: AuditLogService,
) {}
create(dto: CreateOrderDto) {
if (!this.users.findOne(dto.userId)) {
throw new NotFoundException(`User ${dto.userId} not found`);
}
const order = { id: this.orders.length + 1, ...dto };
this.orders.push(order);
this.audit.record('order.created', order.id);
return order;
}
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';
@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.create(dto);
}
}
El controlador convierte una petición en una única llamada al servicio. La regla «el usuario debe existir» reside en el servicio, donde cualquier otro llamador puede reutilizarla y una prueba puede alcanzarla. Las excepciones HTTP integradas, como NotFoundException, se transforman en la respuesta de error correspondiente.
¿Cómo funcionan los DTO y los pipes de validación en NestJS?
Un DTO (data transfer object, objeto de transferencia de datos) es una clase que describe la forma del cuerpo de una petición. Combinado con un pipe de validación, rechaza la entrada incorrecta antes de que se ejecute el controlador. Si dispones de un payload de ejemplo, el conversor de JSON a TypeScript genera el tipo inicial, al que después añades decoradores de class-validator:
// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';
export class CreateOrderDto {
@IsInt()
userId!: number;
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
items!: string[];
}
Registra el pipe una sola vez en main.ts:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.listen(3000);
}
bootstrap();
La documentación de validación define qué hace cada opción. whitelist elimina las propiedades que no tienen decorador. forbidNonWhitelisted convierte esa eliminación silenciosa en una respuesta de error. transform convierte el payload en una instancia de la clase DTO. ValidationPipe depende de class-validator y class-transformer.
NestJS 12, publicado en agosto de 2026, añadió un segundo enfoque: StandardSchemaValidationPipe, que valida esquemas de Zod, Valibot o ArkType pasados como @Body({ schema }). Por sí sola, la opción schema solo registra el esquema en el parámetro. La validación se produce una vez registrado el pipe, por ejemplo con app.useGlobalPipes(new StandardSchemaValidationPipe()).
Si no hay ningún pipe registrado, el frontend te lo hará saber. Las reproducciones de sesión (session replays) de interfaces respaldadas por una API muestran juntos la entrada del usuario, la petición mal formada y el aviso de error genérico, lo que deja claro que la corrección corresponde a la frontera del DTO.
¿Cómo gestionan los guards y la configuración la autenticación y las variables de entorno?
Un guard es una clase @Injectable() que implementa CanActivate. Se ejecuta antes del handler de la ruta y decide si la petición continúa. Si devuelve false, Nest lanza una ForbiddenException (403). En combinación con @nestjs/config, el guard lee su secreto desde ConfigService en lugar de process.env:
// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
}
}
Leer request.headers funciona tanto con el adaptador de Express como con el de Fastify. En AppModule, valida el entorno al iniciar para que una clave ausente provoque un fallo durante el arranque y no en la primera petición. Desde NestJS 12, validationSchema acepta cualquier objeto Standard Schema, como un esquema de Zod. Las versiones anteriores esperaban un esquema de Joi:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: z.object({ API_KEY: z.string().min(1) }),
}),
UsersModule,
OrdersModule,
],
})
export class AppModule {}
La configuración es el único caso en el que un flag global está justificado, porque casi todos los módulos la leen.
Un módulo compartido y pruebas con dependencias sustituidas
El código que se utiliza en varias funcionalidades va en un módulo compartido que lo exporta. Cada funcionalidad importa ese módulo, de modo que todas las dependencias aparecen en el array imports. Marcar los módulos con @Global() oculta ese cableado:
// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';
@Module({
providers: [AuditLogService, ApiKeyGuard],
exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}
La DI demuestra su valor en las pruebas. Test.createTestingModule crea un módulo reducido en el que se sustituyen los providers reales por objetos simples:
// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
describe('OrdersService', () => {
it('throws when the user does not exist', async () => {
const moduleRef = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: { findOne: () => undefined } },
{ provide: AuditLogService, useValue: { record: () => {} } },
],
}).compile();
const service = moduleRef.get(OrdersService);
expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
});
});
Desde NestJS 12, nest new configura Vitest para proyectos ESM y Jest para proyectos CommonJS. Con Vitest, importa describe, it y expect desde vitest, salvo que tengas los globales activados. Para pruebas de integración contra una base de datos real, el artículo sobre cómo ejecutar NestJS con Docker Compose explica la configuración.
¿Cuáles son los errores más comunes en bases de código NestJS de gran tamaño?
La mayoría de los problemas estructurales en las aplicaciones NestJS de gran tamaño proceden de tres patrones:
- Dependencias circulares entre módulos. Si
UsersModuleyOrdersModulese importan mutuamente, Nest falla durante el arranque porque no puede crear la instancia del módulo.forwardRef()resuelve el ciclo, pero trasladar el concepto compartido a su propio módulo lo elimina. - Un
AppModulesobredimensionado. El módulo raíz debe importar los módulos de funcionalidad y la configuración global, no declarar por sí mismo decenas de controladores y providers. - Lógica en los controladores. En cuanto un controlador toma decisiones, esas reglas no pueden reutilizarse ni probarse unitariamente sin HTTP. Limita cada método del controlador a una única llamada al servicio.
Conclusión
Una aplicación NestJS de gran tamaño sigue siendo manejable cuando cada funcionalidad reside en su propio módulo, los únicos vínculos entre módulos son exportaciones explícitas y los controladores se mantienen ligeros. Elige una ruta de tu API actual, como POST /orders. Traslada sus reglas a un servicio, las comprobaciones del cuerpo a un DTO y la autenticación a un guard, y después repite ese patrón funcionalidad a funcionalidad.
Preguntas frecuentes
¿La lógica de autenticación en NestJS debe ir en un middleware o en un guard?
Coloca la autenticación específica de cada ruta en un guard. El middleware se ejecuta antes de que Nest seleccione el handler de la ruta, por lo que solo ve la petición y la respuesta en bruto. No puede ver el controlador, el handler ni los metadatos de los decoradores. Los guards se ejecutan cuando todo el middleware ha terminado y antes de cualquier interceptor o pipe, y reciben un ExecutionContext que expone el handler y su clase. El middleware sigue siendo adecuado para tareas que afectan a todas las peticiones, como el registro de logs.
¿Qué provoca el error Nest can't resolve dependencies?
El error indica que Nest no encuentra un provider para uno de los parámetros de un constructor en el módulo mencionado en el mensaje. Normalmente, ese provider no figura en el array providers del módulo ni lo exporta ningún módulo de su array imports. Para solucionarlo, exporta el provider desde el módulo al que pertenece e importa ese módulo. Comprueba también que ninguna clase marcada con @Injectable() se encuentre en un array imports.
¿Por qué una cadena numérica en el cuerpo de una petición falla con @IsInt aunque transform esté activado?
Con transform establecido en true, ValidationPipe convierte el cuerpo JSON en una instancia de la clase DTO, pero no cambia el tipo de los valores primitivos. Un userId enviado como '30' sigue siendo una cadena y no supera @IsInt. Puedes enviar un número real desde el cliente o establecer enableImplicitConversion en true dentro de transformOptions. La conversión implícita tiene su propia trampa: cualquier cadena no vacía, incluida 'false', se convierte en true en un campo booleano.
¿Cómo valido objetos anidados o arrays de objetos en un DTO de NestJS?
Añade a la propiedad @ValidateNested() de class-validator y @Type(() => ItemDto) de class-transformer. Sin @Type, el valor anidado sigue siendo un objeto simple, por lo que los decoradores de validación de ItemDto nunca se ejecutan. Para un array de objetos, usa @ValidateNested({ each: true }) junto con el mismo decorador @Type. La clase DTO anidada necesita sus propios decoradores de class-validator, y whitelist se aplica también a sus propiedades.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k