Saltar al contenido
Ver todas las notas
arquitecturanestjsdddtypescript

Arquitectura hexagonal en NestJS sin morir en el intento

La hexagonal se explica siempre con diagramas de círculos y casi nunca con código. Esto es cómo la aplico en NestJS, qué carpetas creo, qué regla la sostiene de verdad y en qué momento deja de compensar.

· 8 min de lectura

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/ importa domain/. 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 negocioNo. Estás escribiendo tres capas para un SELECT
Prototipo para validar una ideaNo. Vas a tirar el código
Dominio con reglas y estados
Varios equipos sobre el mismo código, y bastante
Persistencia que puede cambiar

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.

¿Tienes un sistema con este tipo de problemas?

Cuéntame en qué estás y te digo cómo lo abordaría yo.

Hablemos de tu sistema