La arquitectura hexagonal se explica casi siempre con el mismo diagrama de círculos concéntricos, y casi nunca con código. Cuando por fin te sientas a escribirla, las preguntas que tienes son mucho más aburridas: qué carpetas creo, dónde va este archivo, y por qué mi servicio no puede importar el repositorio.
Esto es cómo la aplico en NestJS.
El problema que resuelve#
Un módulo de NestJS típico empieza así:
@Injectable()
export class ExpenseService {
constructor(private readonly prisma: PrismaService) {}
async approve(id: string) {
const expense = await this.prisma.expense.findUnique({ where: { id } });
if (expense.status !== 'PENDING') throw new BadRequestException();
return this.prisma.expense.update({
where: { id },
data: { status: 'APPROVED' },
});
}
}
Funciona. Y en tres meses tiene cuatrocientas líneas, seis dependencias inyectadas y una regla de negocio —"solo se aprueba lo pendiente"— repartida entre este servicio, un guard y el frontend.
El problema no es Prisma. El problema es que la regla de negocio vive pegada a la forma de guardar los datos. Cuando cambies de base, o quieras probar la regla sin levantar una, descubrirás cuánto costaba esa cercanía.
Las tres capas, en carpetas#
En cada módulo creo exactamente esto:
modules/treasury/
├── domain/ entidades, value objects, reglas, errores
├── application/ casos de uso y puertos (interfaces)
└── infrastructure/ controladores, repositorios, listeners
La regla que lo sostiene entero es una sola:
domain/no importa nada.application/importadomain/.infrastructure/importa las dos. Nunca al revés.
Todo lo demás son consecuencias de esa frase.
domain/ no conoce el mundo#
Aquí no hay decoradores de NestJS, ni Prisma, ni @Injectable. Solo TypeScript.
La regla de antes vive en la entidad:
export class Expense extends AggregateRoot {
private constructor(
private readonly props: ExpenseProps,
id?: UniqueEntityId,
) {
super(id);
}
approve(): Result<void> {
if (this.props.status !== ExpenseStatus.PENDING) {
return Result.fail({
code: TREASURY_ERROR_CODES.EXPENSE_NOT_PENDING,
message: 'Solo se puede aprobar un gasto pendiente',
});
}
this.props.status = ExpenseStatus.APPROVED;
return Result.ok();
}
}
Fíjate en que no lanza excepciones: devuelve un Result. Una regla de negocio
incumplida no es un error del programa, es un resultado posible — y tratarla
como excepción obliga a envolver medio código en try/catch para distinguir
"el gasto no estaba pendiente" de "la base de datos se cayó".
application/ orquesta y declara lo que necesita#
El caso de uso no sabe de dónde salen los datos. Solo declara un puerto:
export interface ExpenseRepository {
findById(id: string): Promise<Expense | null>;
save(expense: Expense): Promise<void>;
}
export const EXPENSE_REPOSITORY = Symbol('ExpenseRepository');
Y lo consume:
@Injectable()
export class ApproveExpenseUseCase implements IUseCase<Input, void> {
constructor(
@Inject(EXPENSE_REPOSITORY)
private readonly expenses: ExpenseRepository,
) {}
async execute({ expenseId }: Input): Promise<Result<void>> {
const expense = await this.expenses.findById(expenseId);
if (!expense) {
return Result.fail({ code: TREASURY_ERROR_CODES.EXPENSE_NOT_FOUND });
}
const approved = expense.approve();
if (approved.isFailure) return approved;
await this.expenses.save(expense);
return Result.ok();
}
}
Este caso de uso se prueba sin base de datos, sin NestJS y sin HTTP. Le pasas un doble del repositorio y verificas la regla. Los tests tardan milisegundos.
infrastructure/ conecta con la realidad#
Aquí sí aparecen Prisma, los controladores y los decoradores:
@Injectable()
export class PrismaExpenseRepository implements ExpenseRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<Expense | null> {
const row = await this.prisma.expense.findUnique({ where: { id } });
return row ? ExpenseMapper.toDomain(row) : null;
}
async save(expense: Expense): Promise<void> {
const data = ExpenseMapper.toPersistence(expense);
await this.prisma.expense.upsert({
where: { id: data.id },
create: data,
update: data,
});
}
}
Y el módulo ata el puerto con su implementación:
@Module({
providers: [
ApproveExpenseUseCase,
{ provide: EXPENSE_REPOSITORY, useClass: PrismaExpenseRepository },
],
})
export class TreasuryModule {}
La parte que nadie cuenta: la regla se erosiona sola#
Todo lo anterior es la parte fácil. La difícil es que dentro de dos meses, alguien con prisa —tú, un martes— va a importar el repositorio de Prisma desde un caso de uso. Funcionará. Nadie lo notará en la revisión. Y la frontera habrá dejado de existir sin que nada avise.
Un acuerdo de equipo no sobrevive a un martes con prisa. Por eso la regla se automatiza:
// .dependency-cruiser.cjs
{
name: 'domain-no-importa-application-ni-infra',
severity: 'error',
from: { path: 'src/modules/[^/]+/domain' },
to: { path: 'src/modules/[^/]+/(application|infrastructure)' },
}
Ahora cruzar la frontera rompe el build. La arquitectura deja de depender de que todos se acuerden, que es lo único que la mantiene viva a los seis meses.
Cuándo NO compensa#
Esto tiene un costo real y conviene decirlo:
| Situación | ¿Compensa? |
|---|---|
| CRUD sin reglas de negocio | No. Estás escribiendo tres capas para un SELECT |
| Prototipo para validar una idea | No. Vas a tirar el código |
| Dominio con reglas y estados | Sí |
| Varios equipos sobre el mismo código | Sí, y bastante |
| Persistencia que puede cambiar | Sí |
Un módulo de catálogo que solo lista y edita registros no necesita puertos: los mapeadores y las interfaces solo añaden archivos que leer. La hexagonal paga cuando hay reglas que proteger, no cuando hay tablas que exponer.
Yo la aplico por módulo, no por proyecto. En el mismo sistema conviven módulos con las tres capas y módulos que son un controlador y un servicio, y está bien así: la arquitectura se elige por el problema que tiene delante, no por coherencia estética.
Lo que me llevó más tiempo entender#
Que el objetivo no es cambiar de base de datos. Casi nadie cambia de base de datos.
El objetivo es poder leer una regla de negocio sin leer cómo se guarda, y poder probarla sin levantar nada. Eso se cobra cada semana, en cada lectura de código y en cada test que tarda milisegundos en lugar de segundos. Lo de cambiar la persistencia es un efecto colateral agradable que probablemente no uses.