Arquitectura y filosofía de NestJS
Arquitectura y filosofía de NestJS
Antes de optimizar una sola query o endurecer un solo endpoint, tenés que entender por qué NestJS está armado como está. Casi todos los problemas de mantenibilidad que vas a ver en un backend Nest —lógica de negocio pegada al controlador, servicios imposibles de testear sin levantar media aplicación, módulos que se importan entre sí en círculos— nacen de no respetar la arquitectura que el framework te ofrece “gratis”. Este capítulo desarma esa arquitectura: el contenedor de Inversión de Control (IoC), el sistema de módulos y la separación por capas. No es teoría decorativa: cada pieza tiene consecuencias directas sobre qué tan desacoplado y testeable es tu código.
Trabajamos sobre NestJS 11 (Node.js + TypeScript). Donde la dirección de NestJS 12 (migración a ESM, Vitest como runner por defecto, Standard Schema para validación) cambie una recomendación, lo marcamos.
La filosofía: opinado en estructura, agnóstico en negocio
NestJS toma una postura muy definida sobre cómo se organiza una aplicación: módulos que encapsulan features, controladores que reciben requests, providers que contienen lógica, y un contenedor que ensambla todo. Pero es deliberadamente agnóstico sobre qué decisiones de negocio tomás: no te impone un ORM, ni una estrategia de validación, ni una forma de modelar el dominio.
Esa dualidad es la clave de todo el curso:
- Opinado en estructura significa que si dos equipos usan Nest, sus proyectos se parecen. La curva de entrada a un código ajeno es baja porque el andamiaje es siempre el mismo.
- Agnóstico en negocio significa que la calidad de la lógica es responsabilidad tuya. Nest no te va a impedir meter una transacción de base de datos dentro de un controlador; simplemente te da las herramientas para no hacerlo.
Esa misma filosofía se ve en el core framework-agnóstico. Nest no está atado a Express: corre sobre un adaptador HTTP intercambiable. Por defecto usa @nestjs/platform-express, pero podés cambiar a @nestjs/platform-fastify sin tocar tus controladores, guards ni interceptores, porque estos hablan con las abstracciones de Nest (ExecutionContext, ArgumentsHost), no con req/res de Express directamente.
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
El costo de acoplarte al Request de Express (por ejemplo, tipando un parámetro como @Req() req: express.Request y tocando req.raw) es perder esa portabilidad. Cada vez que llegás al objeto nativo de la plataforma, atás ese código a Express o a Fastify. La buena práctica es mantenerte en las abstracciones de Nest y usar decoradores (@Body(), @Param(), @Query()) en vez del request crudo.
Nest 12 (dirección): el gran cambio estructural es la migración a ESM (módulos ECMAScript en vez de CommonJS), junto con Vitest como test runner por defecto y soporte de Standard Schema para validación. El modelo de módulos, providers e IoC que ves acá no cambia; lo que cambia es el empaquetado y el tooling alrededor. Volvemos sobre validación en el capítulo 5.
El contenedor IoC: la columna vertebral
Inversión de Control es la idea de que una clase no crea sus propias dependencias, sino que las recibe desde afuera. Inyección de dependencias (DI) es el mecanismo concreto por el que ese “afuera” —el contenedor de Nest— entrega las instancias ya construidas.
Compará el antes y el después. Sin DI, cada clase es responsable de instanciar lo que necesita:
// ANTIPATRÓN: la clase construye sus propias dependencias
export class OrdersService {
private readonly repo = new OrdersRepository(new DatabaseConnection());
private readonly mailer = new Mailer(new SmtpClient('smtp://...'));
async place(order: Order) {
await this.repo.save(order);
await this.mailer.send(order.customerEmail, 'Confirmación');
}
}
Este código está acoplado a implementaciones concretas. OrdersService conoce el constructor exacto de OrdersRepository, de DatabaseConnection y del Mailer. Para testearlo tenés que abrir una conexión real y un cliente SMTP real, o hacer un jest.mock intrusivo del módulo. Si mañana la conexión cambia de constructor, se rompe acá.
Con DI, la clase declara qué necesita y el contenedor lo provee:
// MEJOR: la clase declara sus dependencias, el contenedor las resuelve
import { Injectable } from '@nestjs/common';
@Injectable()
export class OrdersService {
constructor(
private readonly repo: OrdersRepository,
private readonly mailer: Mailer,
) {}
async place(order: Order) {
await this.repo.save(order);
await this.mailer.send(order.customerEmail, 'Confirmación');
}
}
Tres consecuencias técnicas concretas:
- Testeable: en un test unitario pasás dobles (
mock/stub) por el constructor sin tocar la red ni la base. ElTestingModulede Nest te deja sobrescribir cualquier provider (overrideProvider(OrdersRepository).useValue(fakeRepo)). - Desacoplado:
OrdersServiceno sabe cómo se construyeOrdersRepository. Si el repo pasa a necesitar tres dependencias más,OrdersServiceno se entera. - Instancia única compartida: el contenedor construye
OrdersRepositoryuna sola vez (scope singleton por defecto) y la reutiliza en todos los que la piden. No hay N conexiones creadas por N servicios.
Cómo resuelve el contenedor
Cuando arranca la aplicación, Nest recorre el grafo de módulos, lee los metadatos que dejaron los decoradores (@Injectable(), @Module()) y construye un grafo de dependencias. Para instanciar un provider, primero resuelve —recursivamente— todo lo que ese provider pide en su constructor, y así hacia abajo hasta llegar a hojas sin dependencias. Después va subiendo, inyectando las instancias ya creadas.
flowchart TD
subgraph Arranque["Bootstrap: NestFactory.create(AppModule)"]
C[Contenedor IoC]
end
C -->|"1. pide OrdersController"| OC[OrdersController]
OC -->|"2. necesita OrdersService"| OS[OrdersService]
OS -->|"3. necesita OrdersRepository"| OR[OrdersRepository]
OS -->|"3. necesita Mailer"| M[Mailer]
OR -->|"4. necesita DatabaseConnection"| DB[DatabaseConnection]
DB -.->|"5. instancia (hoja)"| OR
OR -.->|"6. inyecta"| OS
M -.->|"6. inyecta"| OS
OS -.->|"7. inyecta"| OC
style C fill:#1e293b,color:#fff
style DB fill:#0f766e,color:#fff
El identificador con el que el contenedor busca cada dependencia se llama token. Cuando escribís private readonly repo: OrdersRepository, el token es la clase misma (OrdersRepository), gracias a la metadata de tipos que emite TypeScript con emitDecoratorMetadata. Esto tiene una implicación importante: la resolución por clase depende de que el tipo sea una clase concreta, no una interfaz. Las interfaces de TypeScript no existen en tiempo de ejecución, así que no pueden ser tokens.
Por eso, cuando querés inyectar contra una abstracción (una interfaz), usás un token explícito —normalmente un string o symbol— y @Inject():
// Puerto (abstracción) — no existe en runtime, es solo un contrato
export interface NotificationPort {
send(to: string, message: string): Promise<void>;
}
// Token para el contenedor
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');
@Injectable()
export class OrdersService {
constructor(
@Inject(NOTIFICATION_PORT)
private readonly notifications: NotificationPort,
) {}
}
Y en el módulo asociás el token a una implementación concreta:
@Module({
providers: [
OrdersService,
{ provide: NOTIFICATION_PORT, useClass: EmailNotificationAdapter },
],
})
export class OrdersModule {}
Esto es el Principio de Inversión de Dependencias de SOLID materializado en Nest: OrdersService depende de NotificationPort (abstracción), no de EmailNotificationAdapter (detalle). Podés cambiar el adaptador a SMS o a una cola sin tocar el servicio. Profundizamos en custom providers y tokens en el capítulo 4 y en SOLID aplicado en el capítulo 3.
Constructor injection vs. property injection
La forma canónica es inyección por constructor. Es explícita (ves todas las dependencias de un vistazo), permite marcar los campos readonly y falla en tiempo de construcción si algo no se puede resolver. La inyección por propiedad (@Inject() sobre un campo) existe para casos donde no controlás el constructor —por ejemplo, herencia—, pero oculta las dependencias y complica los tests. Preferí siempre constructor.
Módulos: la unidad de encapsulación
Un módulo es una clase con @Module() que agrupa un conjunto cohesivo de capacidades. Es la frontera de encapsulación del contenedor: lo que un módulo declara en providers es privado suyo salvo que lo liste explícitamente en exports.
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
@Module({
controllers: [OrdersController], // manejo de transporte HTTP
providers: [OrdersService, OrdersRepository], // lógica y datos, privados al módulo
exports: [OrdersService], // lo único visible para quien importe este módulo
})
export class OrdersModule {}
Las cuatro llaves del decorador:
controllers: los controladores que atienden requests de este feature.providers: todo lo inyectable del módulo (servicios, repositorios, factories). Es el ámbito privado.imports: otros módulos cuyos exports querés consumir acá.exports: el subconjunto de tus providers que exponés al exterior. Todo lo que no exportás es inaccesible desde afuera, aunque exista.
Esta encapsulación es lo que evita que tu aplicación se convierta en una bola de barro. Si OrdersModule no exporta OrdersRepository, ningún otro módulo puede inyectarlo, y eso te garantiza que el acceso a la persistencia de órdenes pase siempre por OrdersService. La frontera del módulo es también la frontera de tu invariante de negocio.
flowchart LR
subgraph Orders["OrdersModule"]
OC2[OrdersController]
OS2[OrdersService]
OR2[OrdersRepository]
OC2 --> OS2 --> OR2
end
subgraph Billing["BillingModule"]
BS[BillingService]
end
Orders -->|exports: OrdersService| Billing
BS -.->|"puede inyectar OrdersService"| OS2
BS -.->|"NO puede inyectar OrdersRepository<br/>(no está exportado)"| OR2
style OR2 fill:#7f1d1d,color:#fff
style OS2 fill:#166534,color:#fff
Antipatrón: el módulo que exporta todo
// ANTIPATRÓN: exportar la implementación completa rompe la encapsulación
@Module({
providers: [OrdersService, OrdersRepository, OrdersMapper, PricingCalculator],
exports: [OrdersService, OrdersRepository, OrdersMapper, PricingCalculator],
})
export class OrdersModule {}
Al exportar todo, cualquier módulo puede saltarse OrdersService e ir directo al OrdersRepository, insertando órdenes sin pasar por las validaciones de negocio. La superficie pública del módulo se vuelve enorme y ya no podés refactorizar los internos sin miedo a romper consumidores. Exportá solo el contrato que otros módulos legítimamente necesitan, normalmente el servicio de aplicación o un puerto.
Feature modules vs. core/shared
La organización sana distingue tres tipos de módulos:
- Feature modules: uno por dominio o caso de uso (
OrdersModule,UsersModule,BillingModule). Contienen la lógica de ese feature y solo exportan su fachada. - Shared module: providers reutilizables sin estado de negocio (helpers, un cliente HTTP configurado, utilidades de fecha). Se importa donde haga falta y exporta lo que comparte.
- Core module: infraestructura transversal que se instancia una sola vez (configuración, logger, conexión a base, guards globales). Suele importarse solo en
AppModule.
Tratamos la estructura física del proyecto —carpetas por dominio, arquitectura hexagonal, monorepos— en detalle en el capítulo 2. Acá lo importante es el concepto: un módulo por responsabilidad, con una superficie exportada mínima.
Módulos globales: úsalos con moderación
El decorador @Global() hace que los exports de un módulo estén disponibles en toda la aplicación sin necesidad de importarlo en cada módulo consumidor.
import { Module, Global } from '@nestjs/common';
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
Suena cómodo, y por eso es un antipatrón fácil de sobreusar. El problema técnico es que rompe la explicitud del grafo de dependencias: cuando un módulo usa ConfigService sin importarlo, ya no podés saber leyendo su imports de qué depende. La documentación de Nest lo dice sin vueltas: hacer todo global no es una buena decisión de diseño; los imports son el mecanismo por el que la API de un módulo queda explícita y controlada.
Reservá @Global() para infraestructura verdaderamente transversal que casi todos los módulos consumen y que se registra una vez en el arranque: configuración, logger, quizás el módulo de base de datos. Para todo lo demás, importá explícitamente. Un buen olfato: si dudás si algo debería ser global, no lo hagas global.
Dynamic modules: forRoot y forFeature
Un módulo normal es estático: sus providers están fijos. Un dynamic module es un módulo que se configura al importarlo, devolviendo su definición (providers, exports) en función de las opciones que le pasás. El patrón se materializa en dos métodos estáticos por convención:
forRoot(options): configuración global y única del módulo, se llama una vez enAppModule(conexión a base, credenciales, opciones globales).forFeature(entities): registro por feature de recursos específicos que ese módulo necesita (por ejemplo, los repositorios de las entidades de este dominio). Se llama en cada feature module.
Así se ve la definición de un dynamic module con forRoot:
import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';
@Module({
providers: [Connection],
exports: [Connection],
})
export class DatabaseModule {
static forRoot(entities = [], options?): DynamicModule {
const providers = createDatabaseProviders(options, entities);
return {
module: DatabaseModule,
providers,
exports: providers,
};
}
}
Y el consumo, configurándolo en el módulo raíz:
@Module({
imports: [DatabaseModule.forRoot([User])],
})
export class AppModule {}
Es exactamente el patrón que ya usás cuando escribís ConfigModule.forRoot({ isGlobal: true }), JwtModule.registerAsync(...) o TypeOrmModule.forFeature([Order]). Un método estático que recibe opciones y devuelve un DynamicModule con providers construidos a medida. La convención forRootAsync/registerAsync existe para cuando las opciones dependen de otra dependencia inyectada (típicamente ConfigService), usando useFactory internamente —tema que retomamos en el capítulo 4.
La arquitectura por capas
Nest empuja hacia una separación en tres responsabilidades. No es una regla que el framework te obligue a cumplir —podrías meter todo en el controlador y compilaría igual— pero respetarla es la diferencia entre un backend que escala y uno que se pudre.
flowchart TD
Cliente([Cliente HTTP]) -->|request| Controller
subgraph Capas["Aplicación NestJS"]
Controller["Controller<br/><small>transporte: rutas, DTOs,<br/>status codes</small>"]
Service["Service / Provider<br/><small>negocio: reglas, orquestación,<br/>transacciones</small>"]
Repo["Repositorio<br/><small>datos: acceso a persistencia</small>"]
Controller -->|"llama métodos de dominio"| Service
Service -->|"pide/guarda datos"| Repo
end
Repo --> DB[("Base de datos")]
style Controller fill:#1e3a8a,color:#fff
style Service fill:#166534,color:#fff
style Repo fill:#854d0e,color:#fff
Cada capa tiene un contrato claro:
- Controller (transporte): traduce entre HTTP y el dominio. Recibe la request, valida el DTO de entrada, delega en un servicio y mapea el resultado a una respuesta. No contiene reglas de negocio. Un controlador sano tiene métodos de dos o tres líneas.
- Service / Provider (negocio): donde vive la lógica. Orquesta repositorios, aplica reglas, maneja transacciones. No conoce HTTP: no sabe de status codes ni de
Request. Esto lo hace reutilizable desde un controlador, un consumidor de cola o un comando CLI. - Repositorio (datos): encapsula el acceso a la persistencia. Expone métodos con vocabulario de dominio (
findPendingByCustomer) y esconde el ORM. Si mañana migrás de TypeORM a Prisma, el cambio queda contenido acá. Ampliamos persistencia y el patrón repositorio en el capítulo 8.
Antipatrón: el “fat controller”
// ANTIPATRÓN: lógica de negocio y acceso a datos dentro del controlador
@Controller('orders')
export class OrdersController {
constructor(private readonly dataSource: DataSource) {}
@Post()
async create(@Body() body: any) {
// validación manual
if (!body.items?.length) {
throw new BadRequestException('Sin ítems');
}
// cálculo de negocio en el controlador
let total = 0;
for (const item of body.items) {
const product = await this.dataSource
.getRepository(Product)
.findOneBy({ id: item.productId }); // N+1: una query por ítem
total += product.price * item.quantity;
}
// acceso a datos directo
const order = this.dataSource.getRepository(Order).create({ total });
await this.dataSource.getRepository(Order).save(order);
return order;
}
}
Los problemas son concretos, no estéticos:
- Lógica de negocio inalcanzable desde otro transporte: si mañana querés crear una orden desde un worker de cola, tenés que duplicar todo este código porque vive dentro de un método HTTP.
- N+1 queries: el
forhace una query por ítem. Con 50 ítems, son 50 viajes a la base en vez de uno conWHERE id IN (...). Complejidad O(n) en round-trips donde debería ser O(1). - Intestable sin infraestructura: para testear la regla “el total es la suma de precio × cantidad” necesitás una base real, porque la regla está enredada con
DataSource. @Body() body: any: sin DTO ni validación tipada; cualquier payload malformado entra.
Refactorizado a capas:
// MEJOR: el controlador solo transporta
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@Body() dto: CreateOrderDto): Promise<OrderResponse> {
return this.orders.place(dto);
}
}
// MEJOR: el negocio vive en el servicio, sin saber de HTTP
@Injectable()
export class OrdersService {
constructor(
private readonly orders: OrdersRepository,
private readonly products: ProductsRepository,
) {}
async place(dto: CreateOrderDto): Promise<OrderResponse> {
const ids = dto.items.map((i) => i.productId);
const products = await this.products.findByIds(ids); // 1 query, no N+1
const priceById = new Map(products.map((p) => [p.id, p.price]));
const total = dto.items.reduce(
(sum, item) => sum + priceById.get(item.productId)! * item.quantity,
0,
);
return this.orders.save({ total, items: dto.items });
}
}
Ahora la regla del total es una función pura testeable con dobles del repositorio, el N+1 desaparece (una query con IN), y place() es invocable desde cualquier transporte. El controlador quedó en una línea. Esta separación es la base sobre la que se apoyan SOLID (capítulo 3) y todo el testing (capítulo 13).
Cómo encaja todo
graph TD
App["AppModule<br/><small>raíz: importa core + features</small>"]
App --> Core["CoreModule / @Global()<br/><small>Config, Logger, DB.forRoot()</small>"]
App --> Orders["OrdersModule<br/><small>feature</small>"]
App --> Users["UsersModule<br/><small>feature</small>"]
Orders --> OContr["OrdersController"]
Orders --> OServ["OrdersService"]
Orders --> ORepo["OrdersRepository"]
Core -.->|"provee ConfigService, Logger,<br/>Connection a todo el árbol"| Orders
Core -.-> Users
OServ -->|"inyecta puerto vía token"| Port["NotificationPort<br/><small>abstracción</small>"]
style App fill:#1e293b,color:#fff
style Core fill:#4c1d95,color:#fff
style Port fill:#0f766e,color:#fff
El contenedor IoC lee este grafo en el arranque, resuelve cada dependencia respetando las fronteras de los módulos (solo lo exportado cruza), y te entrega una aplicación ensamblada donde cada pieza depende de abstracciones y ninguna construyó sus propias dependencias. Esa es la columna vertebral: IoC + módulos + capas. Todo lo que sigue en el curso son técnicas para aprovecharla mejor.
Checklist
- Ningún provider hace
newde sus dependencias: todas entran por el constructor. - Usás inyección por constructor con campos
readonly, no property injection. - Inyectás contra abstracciones (puertos con token
symbol/string) donde la implementación pueda cambiar, no contra clases concretas de infraestructura. - Cada módulo exporta solo su fachada (servicio de aplicación o puerto), no sus repositorios ni internos.
-
@Global()está reservado a infraestructura transversal (config, logger, DB) y no se usa por comodidad. - La configuración global va por
forRoot()y el registro por feature porforFeature(), siguiendo la convención de dynamic modules. - Los controladores no tienen lógica de negocio ni acceso directo a la persistencia: delegan en servicios.
- Los servicios no conocen HTTP (nada de status codes ni
Request), así son reutilizables desde colas o CLI. - El acceso a datos está encapsulado en repositorios con vocabulario de dominio.
- No tocás el
Request/Responsenativos de Express salvo necesidad real: te mantenés en las abstracciones de Nest para no perder portabilidad Express/Fastify.
Con la columna vertebral clara, el siguiente paso es bajar esto a carpetas y archivos concretos: cómo estructurar el proyecto por dominios, dónde aplicar arquitectura hexagonal y qué poner (y qué no) en cada capa. Seguimos en Organización del proyecto →.