Organización del proyecto: modular, hexagonal y clean
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
- Dominio: entidades y value objects con las invariantes del negocio. TypeScript puro, sin decoradores de Nest ni de TypeORM. No importa nada de
@nestjs/*. - Aplicación: casos de uso (o application services) que orquestan el dominio y los puertos. Un caso de uso = una intención del usuario (“confirmar pedido”, “registrar usuario”). Definen los puertos (interfaces) que necesitan.
- Puertos: interfaces TypeScript. Los driving ports (de entrada) son las firmas de los casos de uso; los driven ports (de salida) son lo que el dominio necesita del mundo (persistencia, mensajería, reloj).
- Adaptadores / infraestructura: implementaciones concretas de los puertos de salida (repositorio TypeORM, cliente SMTP) y adaptadores de entrada (controllers HTTP, consumers de BullMQ). Acá viven los decoradores del framework y del ORM.
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:
- La regla de negocio (“solo un pedido PENDING se confirma”, “no confirmar sin stock”, cálculo del total) vive en un handler HTTP. No la podés ejecutar desde una cola, un cron o un test sin construir un
Requestfalso. - Acople directo a TypeORM:
Repository<Order>es un detalle de infraestructura infiltrado en la capa que decide el negocio. Cambiar a Prisma o Drizzle te obliga a reescribir la lógica, no solo la persistencia. - N+1 queries: el
findOnedeProductdentro delfordispara una query por ítem. Un pedido de 30 ítems son 31 queries en vez de 1 con unIN (...). - Intestable: para probar la regla del stock necesitás una base de datos real o mockear el
Repositoryde TypeORM entero.
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:
- Testeabilidad: el
ConfirmOrderUseCasese testea inyectando dos objetos fake que implementan las interfaces; cero base de datos, cero HTTP. El dominio (Order.confirm) se testea connew Order(...)yexpect. - N+1 eliminado:
stockForpide todos los productos en una query conIN (...). Pasás de O(n) queries a O(1). - ORM reemplazable: cambiar TypeORM por Prisma toca solo
TypeOrmOrderRepository; el dominio, el caso de uso y el controller no se enteran (Principio de Inversión de Dependencias en acción). - Reutilización: el mismo
ConfirmOrderUseCaselo llama un consumer de BullMQ o un comando de CLI sin cambiar una línea.
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
- Fat controller: el controlador decide reglas de negocio, calcula y persiste. Es la razón número uno por la que la lógica no se puede testear ni reusar. El controller solo traduce HTTP ↔ caso de uso.
- Servicio anémico: un
Serviceque solo reenvía llamadas al repositorio (return this.repo.find()) sin agregar lógica. Es una capa de indirección sin valor; o le das responsabilidad real (orquestación, reglas) o el controller usa el repo directo en un CRUD. - God service: el opuesto. Un
UsersServicede 900 líneas que hace autenticación, envío de emails, facturación y reporting. Viola SRP; partilo por caso de uso o por sub-dominio. - ORM en el dominio:
@Entity(),@Column()oRepository<T>dentro de la lógica de negocio. Acopla tu regla a un detalle de persistencia. La entidad de dominio y la entidad de TypeORM son cosas distintas; un mapper las traduce. - Imports circulares entre módulos:
OrdersModuleimportaUsersModuley viceversa, y terminás poniendoforwardRef()para que compile. Es un síntoma de fronteras mal trazadas: extraé lo compartido a un tercer módulo (olibs/core), o invertí la dependencia con un puerto (evento de dominio en vez de llamada directa). ElforwardRef()es una curita, no una solución. - Organización por capa técnica en un proyecto que ya tiene varios dominios: dispersa cada feature en cuatro carpetas y sube el acoplamiento entre ellas.
Checklist
- El código está agrupado por dominio (feature modules), no por tipo técnico (
controllers/,services/). - Los controladores solo traducen HTTP ↔ caso de uso/servicio; no contienen reglas de negocio ni cálculos.
- La lógica de negocio vive en casos de uso o en la entidad de dominio, no en el controller ni acoplada al ORM.
- La persistencia está detrás de un puerto (interfaz) inyectado por token; la implementación concreta (TypeORM/Prisma) se cablea en el módulo.
- El dominio no importa nada de
@nestjs/*ni del ORM; las dependencias apuntan hacia adentro. - Elegiste la estructura por feature según su complejidad: hexagonal donde hay reglas ricas, modular simple donde es CRUD.
- No hay N+1 escondido en loops que consultan el repositorio por ítem.
- No hay imports circulares ni
forwardRef()tapando fronteras mal trazadas. - Si hay varios ejecutables, usás monorepo (CLI de Nest o Nx) con el dominio compartido en una
lib, y las fronteras verificadas por lint. - Ningún god service ni servicio anémico: cada provider tiene una responsabilidad clara.
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.