Arquitectura y filosofía de NestJS

Por: Artiko
nestjsbuenas-practicasarquitecturainyeccion-de-dependenciasmodulosiocsolid

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:

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:

  1. Testeable: en un test unitario pasás dobles (mock/stub) por el constructor sin tocar la red ni la base. El TestingModule de Nest te deja sobrescribir cualquier provider (overrideProvider(OrdersRepository).useValue(fakeRepo)).
  2. Desacoplado: OrdersService no sabe cómo se construye OrdersRepository. Si el repo pasa a necesitar tres dependencias más, OrdersService no se entera.
  3. Instancia única compartida: el contenedor construye OrdersRepository una 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:

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:

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:

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:

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:

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

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 →.