Organización del proyecto: modular, hexagonal y clean

Por: Artiko
nestjsbuenas-practicasarquitectura-hexagonalclean-architecturemonorepodddtypescript

Organización del proyecto: modular, hexagonal y clean

En el capítulo anterior vimos que el contenedor IoC es la columna vertebral de Nest. Pero un contenedor de dependencias no te dice cómo repartir el código: dónde vive una regla de negocio, si un Service puede hablar directo con TypeORM, qué módulo puede importar a cuál. Esas decisiones son las que determinan si tu backend crece de forma lineal o si cada feature nueva cuesta el doble que la anterior.

Este capítulo ataca un problema de calidad concreto: el acoplamiento. Un proyecto Nest típico empieza limpio y termina con controladores de 400 líneas, servicios que dependen del Repository<Entity> de TypeORM en cada método, e imports circulares que te obligan a forwardRef() por todos lados. La causa raíz casi nunca es el framework; es la estructura. Vamos a ver cómo organizar por dominio, cómo aplicar hexagonal/clean sin sobre-ingeniería, y cuándo cada enfoque paga su costo.

Dos ejes para organizar: por capa técnica vs. por dominio

Hay dos formas de agrupar archivos, y la diferencia importa más de lo que parece.

Por capa técnica agrupás todo lo que “es del mismo tipo”: todos los controladores juntos, todos los servicios juntos, todas las entidades juntas.

src/
  controllers/
    users.controller.ts
    orders.controller.ts
    payments.controller.ts
  services/
    users.service.ts
    orders.service.ts
    payments.service.ts
  entities/
    user.entity.ts
    order.entity.ts
    payment.entity.ts

Por dominio (feature modules, lo que recomienda Nest) agrupás todo lo que pertenece a una capacidad del negocio, sin importar su tipo técnico.

src/
  users/
    users.controller.ts
    users.service.ts
    user.entity.ts
    users.module.ts
  orders/
    orders.controller.ts
    orders.service.ts
    order.entity.ts
    orders.module.ts
  payments/
    ...

La diferencia no es estética. Cuando tenés que tocar la feature “pedidos”, con la organización por dominio todo lo que necesitás está en una carpeta: abrís orders/ y ahí está el flujo completo. Con la organización por capa, un cambio en pedidos te obliga a saltar entre tres o cuatro directorios y a leer archivos que también contienen users y payments, aumentando la carga cognitiva y el riesgo de tocar lo que no debías.

El criterio técnico detrás de esto es la cohesión y el acoplamiento: querés alta cohesión dentro de cada carpeta (todo lo de ahí cambia junto) y bajo acoplamiento entre carpetas (cambiar orders no toca users). Agrupar por capa maximiza el acoplamiento entre carpetas: un feature vive esparcido en cuatro lugares. Agrupar por dominio alinea la estructura de archivos con las razones de cambio del sistema, que es exactamente el Principio de Responsabilidad Única aplicado a la carpeta (lo vemos a fondo en el capítulo 3: SOLID en NestJS).

Regla práctica: organizá por dominio, no por tipo de archivo. La única excepción razonable es el código transversal genuino (un common/ con guards, decorators, filters e interceptores reutilizables que no pertenecen a ningún dominio en particular).

Dónde vive la lógica de negocio (y dónde no)

El feature module resuelve el “dónde ponés los archivos”. No resuelve el problema más caro: el acoplamiento al framework y al ORM. Un UsersService puede estar prolijamente dentro de users/ y aun así ser un desastre de mantener si cada método arranca con una query de TypeORM y termina armando la respuesta HTTP.

La regla de oro es la direccionalidad de las dependencias: la lógica de negocio no debe depender de detalles de infraestructura; la infraestructura depende de la lógica. HTTP, TypeORM, Prisma, Redis, BullMQ son detalles reemplazables. Tu regla de “un pedido no se puede confirmar si el stock es insuficiente” no lo es. Si esa regla vive enredada con @Body(), res.status() y this.repo.findOne(), no la podés testear sin levantar medio framework ni reusar fuera de HTTP.

De esto trata la arquitectura hexagonal (Alistair Cockburn), también llamada ports & adapters, muy cercana a la clean architecture de Robert Martin: poné el dominio en el centro y empujá todo lo que sea tecnología hacia afuera.

flowchart TB
  subgraph exterior["Adaptadores (infraestructura)"]
    HTTP["Controller HTTP<br/>(adaptador de entrada)"]
    CLI["Consumer de cola / CLI<br/>(adaptador de entrada)"]
    ORM["Repo TypeORM/Prisma<br/>(adaptador de salida)"]
    MAIL["Cliente de email<br/>(adaptador de salida)"]
  end
  subgraph aplicacion["Aplicación (casos de uso)"]
    UC["ConfirmarPedido<br/>UseCase / Application Service"]
    PIN["«port» PedidoRepository"]
    POUT["«port» Notificador"]
  end
  subgraph dominio["Dominio (núcleo)"]
    ENT["Pedido (entidad)<br/>invariantes + reglas"]
    VO["Value Objects<br/>Money, Email"]
  end

  HTTP --> UC
  CLI --> UC
  UC --> ENT
  UC --> PIN
  UC --> POUT
  ORM -. implementa .-> PIN
  MAIL -. implementa .-> POUT

  classDef core fill:#1e40af,stroke:#93c5fd,color:#fff
  classDef app fill:#065f46,stroke:#6ee7b7,color:#fff
  classDef infra fill:#7c2d12,stroke:#fdba74,color:#fff
  class ENT,VO core
  class UC,PIN,POUT app
  class HTTP,CLI,ORM,MAIL infra

Fijate en las flechas: los adaptadores apuntan hacia adentro. El caso de uso ConfirmarPedido conoce el puerto PedidoRepository (una interfaz), no la clase concreta de TypeORM. El adaptador de infraestructura implementa el puerto. Esa inversión es lo que Nest resuelve con elegancia gracias a su contenedor IoC y a la inyección por token, que profundizamos en el capítulo 4: Providers y DI avanzada.

Las piezas, en términos de Nest

Antipatrón → refactor: lógica de negocio en el controlador y acople al ORM

Este es el patrón que aparece en el 80% de los proyectos Nest que empiezan a doler. El controlador orquesta, calcula y persiste; el servicio, si existe, es un pasamanos anémico hacia el repositorio.

Antes (antipatrón: fat controller + acople directo al ORM)

// orders.controller.ts — el controlador HACE todo
@Controller('orders')
export class OrdersController {
  constructor(
    @InjectRepository(Order) private readonly orders: Repository<Order>,
    @InjectRepository(Product) private readonly products: Repository<Product>,
  ) {}

  @Post(':id/confirm')
  async confirm(@Param('id') id: string, @Body() body: ConfirmDto) {
    const order = await this.orders.findOne({ where: { id }, relations: ['items'] });
    if (!order) throw new NotFoundException();
    if (order.status !== 'PENDING') throw new BadRequestException('No confirmable');

    // regla de negocio incrustada en el controller HTTP
    let total = 0;
    for (const item of order.items) {
      const product = await this.products.findOne({ where: { id: item.productId } }); // N+1
      if (product.stock < item.qty) throw new BadRequestException('Sin stock');
      total += product.price * item.qty;
    }

    order.status = 'CONFIRMED';
    order.total = total;
    await this.orders.save(order);
    return { ok: true, total }; // forma HTTP mezclada con la lógica
  }
}

Problemas concretos:

Después (refactor: caso de uso + puertos + dominio)

Dominio — la regla vive acá, en TypeScript puro:

// domain/order.ts — sin Nest, sin TypeORM
export class Order {
  private constructor(
    readonly id: string,
    private _status: 'PENDING' | 'CONFIRMED' | 'CANCELLED',
    readonly items: ReadonlyArray<OrderItem>,
    private _total: number,
  ) {}

  get status() { return this._status; }
  get total() { return this._total; }

  // invariante del negocio protegida por la entidad
  confirm(stockByProduct: ReadonlyMap<string, number>): void {
    if (this._status !== 'PENDING') {
      throw new OrderNotConfirmableError(this.id, this._status);
    }
    let total = 0;
    for (const item of this.items) {
      const stock = stockByProduct.get(item.productId) ?? 0;
      if (stock < item.qty) throw new InsufficientStockError(item.productId);
      total += item.unitPrice * item.qty;
    }
    this._total = total;
    this._status = 'CONFIRMED';
  }
}

Puertos — interfaces que declaran lo que la aplicación necesita del exterior:

// application/ports/order.repository.ts
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
export const ORDER_REPOSITORY = Symbol('OrderRepository'); // token de DI

// application/ports/stock.provider.ts
export interface StockProvider {
  stockFor(productIds: string[]): Promise<ReadonlyMap<string, number>>;
}
export const STOCK_PROVIDER = Symbol('StockProvider');

Caso de uso — orquesta dominio y puertos, sin saber de HTTP ni de ORM:

// application/confirm-order.use-case.ts
@Injectable()
export class ConfirmOrderUseCase {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
    @Inject(STOCK_PROVIDER) private readonly stock: StockProvider,
  ) {}

  async execute(orderId: string): Promise<void> {
    const order = await this.orders.findById(orderId);
    if (!order) throw new OrderNotFoundError(orderId);

    const stock = await this.stock.stockFor(order.items.map((i) => i.productId)); // 1 query
    order.confirm(stock); // toda la regla vive en el dominio

    await this.orders.save(order);
  }
}

Adaptadores — infraestructura que implementa los puertos y traduce a/desde el mundo:

// infrastructure/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(@InjectRepository(OrderEntity) private readonly repo: Repository<OrderEntity>) {}

  async findById(id: string): Promise<Order | null> {
    const row = await this.repo.findOne({ where: { id }, relations: ['items'] });
    return row ? OrderMapper.toDomain(row) : null; // mapea persistencia → dominio
  }
  async save(order: Order): Promise<void> {
    await this.repo.save(OrderMapper.toPersistence(order));
  }
}

// presentation/orders.controller.ts — solo traduce HTTP ↔ caso de uso
@Controller('orders')
export class OrdersController {
  constructor(private readonly confirmOrder: ConfirmOrderUseCase) {}

  @Post(':id/confirm')
  @HttpCode(200)
  async confirm(@Param('id') id: string) {
    await this.confirmOrder.execute(id);
    return { ok: true };
  }
}

El cableado vive en el módulo, que es donde Nest inyecta la implementación concreta por token:

// orders.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([OrderEntity])],
  controllers: [OrdersController],
  providers: [
    ConfirmOrderUseCase,
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
    { provide: STOCK_PROVIDER, useClass: HttpStockProvider },
  ],
})
export class OrdersModule {}

Qué ganaste, en términos medibles:

Dos estructuras de carpetas, según la complejidad

No todo proyecto necesita cuatro capas. Aplicar hexagonal completo a un CRUD de tres tablas es sobre-ingeniería: pagás el costo de las interfaces, los mappers y los tokens sin recibir el beneficio (porque nunca vas a cambiar el ORM ni a reusar la lógica fuera de HTTP). Elegí según la complejidad del dominio.

Opción A — hexagonal por feature (dominio complejo)

Cuando el feature tiene reglas de negocio ricas, varias fuentes de entrada o alta rotación:

src/orders/
  domain/
    order.ts                 # entidad + invariantes
    order-item.ts
    errors.ts
  application/
    ports/
      order.repository.ts    # interfaz + token
      stock.provider.ts
    confirm-order.use-case.ts
    cancel-order.use-case.ts
  infrastructure/
    typeorm-order.repository.ts
    order.entity.ts          # tabla TypeORM (≠ entidad de dominio)
    order.mapper.ts
    http-stock.provider.ts
  presentation/
    orders.controller.ts
    dto/confirm-order.dto.ts
  orders.module.ts

Opción B — modular simple (dominio anémico o CRUD)

Cuando el feature es esencialmente entrada-validación-persistencia, sin reglas complejas, el trío controller/service/repository de Nest alcanza:

src/tags/
  tags.controller.ts
  tags.service.ts     # lógica mínima, puede usar el repo directo
  tag.entity.ts
  dto/create-tag.dto.ts
  tags.module.ts

El criterio de decisión:

flowchart TD
  A["¿El feature tiene reglas de<br/>negocio (invariantes, cálculos,<br/>máquinas de estado)?"] -->|No, es CRUD| B["Modular simple<br/>controller · service · repo"]
  A -->|Sí| C["¿Múltiples fuentes de entrada<br/>o vas a cambiar de ORM/proveedor?"]
  C -->|No| D["Service con lógica<br/>+ repo detrás de interfaz"]
  C -->|Sí| E["Hexagonal completo<br/>domain · application · infra · presentation"]

  classDef q fill:#1e3a5f,stroke:#93c5fd,color:#fff
  classDef a fill:#14532d,stroke:#6ee7b7,color:#fff
  class A,C q
  class B,D,E a

Lo importante: la elección es por feature, no por proyecto. Podés tener orders/ en hexagonal completo y tags/ en modular simple en el mismo repo. Empezá simple y subí de nivel un feature cuando el dolor lo justifique (aparecen reglas, aparece un segundo adaptador de entrada, el service supera las ~200 líneas). Refactorizar de B a A es barato si respetaste la frontera del módulo desde el principio.

Monorepos de Nest: apps y libs compartidas

Cuando tenés varios ejecutables (una API, un worker de colas, un gateway, un CLI) que comparten dominio, el layout de “un solo proyecto” se queda corto. Nest trae modo monorepo nativo en su CLI, sin herramientas extra.

# empezás con un proyecto estándar y agregás el segundo ejecutable:
nest generate app worker          # o: nest g app worker
# esto convierte el repo a monorepo y crea apps/worker + apps/<original>

# librería compartida (dominio, contratos, utilidades):
nest generate library core        # o: nest g lib core

Al agregar la primera app o library, Nest reestructura el workspace:

apps/
  api/          # ejecutable HTTP
  worker/       # ejecutable de colas
libs/
  core/         # dominio + casos de uso compartidos (sin framework de entrada)
  shared/       # DTOs, contratos, utilidades
nest-cli.json   # define projects, root y sourceRoot de cada uno

Las apps son los puntos de entrada (tienen main.ts y bootstrapean); las libs son código compartido que se importa vía path mapping de TypeScript (@app/core), no por ruta relativa. Con esto, el dominio vive una sola vez en libs/core y tanto la API como el worker lo consumen. Corrés cada app con nest start api / nest start worker y las construís con nest build api.

Nx es la alternativa cuando necesitás más: caché de builds, ejecución affected-only (solo reconstruye lo que cambió), grafo de dependencias visual (nx graph) y, sobre todo, boundaries forzados por lint. Con @nx/enforce-module-boundaries marcás cada lib con tags (type:domain, type:infra, scope:orders) y ESLint falla el build si una lib de dominio importa una de infraestructura, o si un feature importa el interior de otro. Eso convierte tus reglas de arquitectura en algo verificable por CI, no en un acuerdo verbal que se erosiona.

Criterio: CLI de Nest para 2-4 ejecutables con dominio compartido; Nx cuando el número de proyectos crece, los tiempos de build duelen o necesitás que las fronteras entre capas sean imposibles de violar por accidente.

Grafo de dependencias entre capas y módulos

La regla que mantiene el sistema sano es una sola: las dependencias apuntan hacia el dominio. Ninguna capa interior conoce a una exterior.

graph TD
  P["presentation<br/>(controllers, DTOs, guards)"] --> AP["application<br/>(use cases, ports)"]
  INFRA["infrastructure<br/>(repos ORM, clientes, mappers)"] --> AP
  INFRA --> DOM
  AP --> DOM["domain<br/>(entidades, VOs, reglas)"]

  DOM -.->|nunca importa| AP
  DOM -.->|nunca importa| INFRA
  AP -.->|nunca importa| INFRA

  classDef ok fill:#14532d,stroke:#6ee7b7,color:#fff
  classDef core fill:#1e40af,stroke:#93c5fd,color:#fff
  class P,AP,INFRA ok
  class DOM core

Las flechas punteadas son las prohibidas. Si domain importa algo de @nestjs/*, de TypeORM o de application, rompiste la regla y perdiste la ventaja principal (poder testear y reemplazar). Una forma barata de vigilarlo sin Nx: una regla de eslint-plugin-import con no-restricted-imports que prohíba, dentro de domain/, cualquier import de @nestjs, typeorm o ../application.

Antipatrones a erradicar

Checklist

Con la estructura resuelta, el próximo paso es afinar el diseño de cada pieza: cómo dar a cada servicio una única responsabilidad, cómo depender de abstracciones inyectables y cómo segregar interfaces. Eso es exactamente SOLID en NestJS →, donde los cinco principios dejan de ser teoría y se vuelven decisiones concretas sobre providers y módulos.