Por qué tu servicio NestJS se vuelve inmantenible (y cómo la arquitectura hexagonal lo arregla)
Controladores que saben demasiado, entidades llenas de decoradores ORM y tests que exigen base de datos. Guía práctica de puertos y adaptadores en NestJS.

Empiezas un proyecto NestJS con buenas intenciones. Seis meses después, users.service.ts importa TypeORM, envía correos, hashea contraseñas y publica eventos en Kafka. Cambiar la base de datos parece una operación a corazón abierto.
No es un problema de equipo. Es un problema de arquitectura — y la arquitectura hexagonal (puertos y adaptadores) es una de las respuestas más efectivas cuando ya no alcanza el clásico controller → service → entity.
Este artículo sigue el modelo del NestJS Enterprise Starter, con el nivel de detalle para aplicarlo en tu propio código esta semana.
El costo real de una capa de servicios "simple"
Cuando la lógica de negocio, HTTP y persistencia viven en la misma clase:
- Los tests necesitan Postgres — feedback lento, tests omitidos, regresiones en producción.
- Acoplamiento al framework — los decoradores de TypeORM se convierten en tu "modelo de dominio."
- Controladores obesos — validación, hashing y efectos secundarios terminan en el
@Post().
La arquitectura hexagonal traza una frontera: el centro son tus reglas de negocio; todo lo demás es un plugin reemplazable.
La imagen que sí ayuda
Imagina tu aplicación como un hexágono:
- Adaptadores primarios empujan trabajo hacia adentro: REST, GraphQL, CLI, consumidores de cola.
- Adaptadores secundarios son invocados por el núcleo: PostgreSQL, Redis, Stripe, SendGrid.
- Puertos son interfaces TypeScript que define el núcleo — "necesito guardar un usuario", no "uso TypeORM."
HTTP → Application (casos de uso) → Dominio
↑
El repositorio TypeORM implementa UserRepositoryPort
La regla es simple: las dependencias apuntan hacia adentro. core/ nunca importa @nestjs/common ni typeorm.
Una estructura de carpetas que puedes adoptar mañana
src/
├── core/
│ ├── domain/
│ └── ports/output/persistence/
├── application/
│ └── commands/user/
├── adapters/
│ ├── primary/http/
│ └── secondary/persistence/typeorm/
└── modules/user/
Los módulos NestJS son solo cableado: enlazan UserRepositoryPort → UserRepository. Los handlers dependen del puerto; el módulo elige el adaptador.
Puertos: contratos, no implementaciones
El dominio declara lo que necesita:
export abstract class UserRepositoryPort {
abstract findByEmail(email: string): Promise<User | null>;
abstract create(payload: CreateUserPayload): Promise<User>;
}
Sin @Injectable(), sin Repository<UserOrmEntity>. El handler pide una capacidad; NestJS inyecta la implementación en runtime.
Adaptadores: donde viven los frameworks
La entidad TypeORM queda en adapters/secondary:
@Entity('users')
export class UserOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
// ...
}
Un mapper traduce ORM → dominio:
static toDomain(orm: UserOrmEntity): User {
return { id: orm.id, email: orm.email, name: orm.name, ... };
}
Los handlers nunca ven UserOrmEntity. Si migras a Prisma, reescribes un adaptador — no cuarenta casos de uso.
El controlador traduce, no decide
Mal:
@Post()
async create(@Body() dto: CreateUserDto) {
const hash = await bcrypt.hash(dto.password, 10);
return this.repo.save({ ...dto, password: hash });
}
Bien:
@Post()
create(@Body() dto: CreateUserDto) {
return this.commandBus.execute(new CreateUserCommand(dto));
}
Reglas de contraseña, emails duplicados y correos de bienvenida van en un command handler en la capa de aplicación.
Lo que ganas en el día a día
| Beneficio | Cómo se siente en la práctica |
|---|---|
| Tests rápidos | Handlers con mocks jest.fn() — sin Docker |
| Infra intercambiable | Nueva cola o BD = nuevo adaptador + provider |
| Onboarding | Todos saben dónde va cada pieza |
| Reutilización | El mismo handler desde HTTP, cron o worker BullMQ |
Errores que repito en cada auditoría
- Entidades ORM en
core/— muévelas a adapters; mapea a tipos planos. - Inyectar
UserRepositoryconcreto en handlers — inyectaUserRepositoryPort. - "Solo esta vez" en el controlador — nunca se queda en una vez.
Siguiente paso
Este diseño encaja con CQRS (lecturas y escrituras separadas) y con puertos para colas (BullMQ detrás de IQueueService). En esta serie cubrimos esos patrones con el mismo criterio de dependencias.
Si estás construyendo una API NestJS que debe sobrevivir más de un ciclo de contratación, empieza sacando un módulo (Users) a puertos y adaptadores. La diferencia se nota en la primera semana de tests.
Recibe artículos por email
Sin spam — solo un aviso cuando publique algo nuevo sobre backend, cloud y arquitectura.
Un email cuando salga un artículo. Puedes darte de baja cuando quieras.
Artículos relacionados

Cómo construir una aplicación SaaS Multi-Tenant en NestJS sin duplicar tu código
Si agregar un nuevo cliente implica desplegar una nueva aplicación o copiar una base de datos completa, probablemente tu arquitectura SaaS no está preparada para escalar. Aprende cómo implementar multi-tenancy en NestJS de forma limpia y mantenible

Tu API no necesita más servicios, necesita eventos
Si cada nueva funcionalidad te obliga a modificar cinco servicios distintos, probablemente tengas un problema de acoplamiento. Aprende cómo Event-Driven Architecture ayuda a desacoplar módulos y escalar aplicaciones NestJS.

CQRS en NestJS: deja de mezclar lecturas y escrituras en el mismo servicio
Cuando tu UserService atiende POST y GET, optimizar un lado rompe el otro. Comandos, consultas y handlers con @nestjs/cqrs, sin humo.