02 · Arquitectura Hexagonal
Objetivo: comprender por qué y cuándo emplear el patrón, más allá del simple how-to.
1. Orígenes y motivación
La Arquitectura Hexagonal surge en 2005 de la mano de Alistair Cockburn como respuesta a un reto constante: las reglas de negocio cambian con menor frecuencia que las tecnologías que las soportan. En entornos tradicionales, un monolito o una aplicación estratificada veían cómo cada actualización de framework, controlador de BD o cliente HTTP obligaba a modificar el núcleo de la aplicación. Cockburn propuso aislar la lógica de negocio en un “hexágono” rodeado de puertos (interfaces) y adaptadores (implementaciones), de modo que:
El dominio permanece limpio, enfocado únicamente en las invariantes y reglas de negocio.
Los cambios tecnológicos (nueva versión de la base de datos, migración de REST a gRPC, etc.) se implementan en adaptadores sin tocar el núcleo.
El equipo gana confianza para refactorizar, sabiendo que el dominio está desacoplado y cubierto por tests rápidos.
Este enfoque también se conoció como “Ports & Adapters” y ha inspirado arquitecturas como Onion y Clean Architecture. A diferencia de éstas, Hexagonal enfatiza la simetría entre entradas y salidas del dominio, definiendo claramente cómo fluye la información hacia dentro y hacia fuera.
Lecturas clave:
Cockburn, “Ports and Adapters Architecture” – https://alistair.cockburn.us/hexagonal-architecture/
Uncle Bob, “The Clean Architecture” – https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
Martin Fowler, “Microservices and Hexagonal Architecture” – https://martinfowler.com/articles/microservices.html
Diagrama Conceptual:
2. Principios clave
Independencia del dominio
El código de negocio no importa módulos de infraestructura.
Refactors de infra sin tocar reglas de negocio; tests muy rápidos.
Puertos explícitos
Interfaces que definen entradas y salidas del dominio.
Contratos claros; evita acoplamientos ocultos.
Adaptadores intercambiables
Múltiples implementaciones de un mismo puerto.
Cambiar Mongo→Postgres o HTTP→gRPC sin modificar el dominio.
Inversión de dependencias (DIP)
El dominio declara abstracciones; la capa externa las implementa.
El dominio dicta la lógica, no la tecnología.
3. Comparativa de patrones
Núcleo
Dominio + Aplicación
Solo Dominio
Entidades + Casos de Uso
Estructura
Asimétrica (Ports & Adapters)
Capas concéntricas
Capas concéntricas + políticas
Enfoque
Aislar tecnología
Pureza de dominio
Casos de uso bien definidos
Curva de inicio
Moderada
Elevada
Elevada
Ideal para
Microservicios flexibles
Dominios muy ricos
Sistemas monolito/multi-UI
4. Beneficios en Microservicios Node.js
Evolutividad: inyectar nuevos adaptadores (REST, gRPC, CLI) sin tocar el core.
Testabilidad: la mayor parte de la lógica se prueba en memoria, sin arranque de contenedores.
Observabilidad clara: logs y métricas se configuran en adaptadores, manteniendo el dominio “limpio”.
Despliegue incremental: puedo desplegar un nuevo adapter sin downtime para el dominio.
5. Ejemplo práctico: arrancando un servicio hexagonal
A continuación un skeleton de inventory-service que ya respeta ports & adapters:
project/services/inventory-service/
├── src/
│ ├── domain/
│ │ ├── model/
│ │ │ └── ProductInventory.ts
│ │ └── ports/
│ │ ├── InventoryRepositoryPort.ts
│ │ └── MessagingPort.ts
│ ├── application/
│ │ └── use-cases/
│ │ └── ReserveStockUseCase.ts
│ ├── infrastructure/
│ │ ├── http/
│ │ │ └── routes.ts
│ │ ├── postgres/
│ │ │ └── InventoryRepositoryPostgres.ts
│ │ └── rabbitmq/
│ │ └── RabbitMessagingAdapter.ts
│ └── main.ts
└── tsconfig.json
5.1 Definiendo un puerto
// src/domain/ports/InventoryRepositoryPort.ts
import { ProductInventory } from '../model/ProductInventory';
export interface InventoryRepositoryPort {
findBySku(sku: string): Promise<ProductInventory | null>;
save(inventory: ProductInventory): Promise<void>;
}
5.2 Implementando un adapter Postgres
// src/infrastructure/postgres/InventoryRepositoryPostgres.ts
import { InventoryRepositoryPort } from '../../domain/ports/InventoryRepositoryPort';
import { ProductInventory } from '../../domain/model/ProductInventory';
import { prisma } from '../prisma';
export class InventoryRepositoryPostgres implements InventoryRepositoryPort {
async findBySku(sku: string): Promise<ProductInventory | null> {
const row = await prisma.inventory.findUnique({ where: { sku } });
return row
? new ProductInventory(row.sku, row.available)
: null;
}
async save(inventory: ProductInventory): Promise<void> {
await prisma.inventory.upsert({
where: { sku: inventory.sku },
update: { available: inventory.available },
create: { sku: inventory.sku, available: inventory.available },
});
}
}
5.3 Contenedor de inyección (awilix)
// src/application/container.ts
/**
* Configuración del contenedor de inyección de dependencias
* usando Awilix. Aquí definimos cómo se construyen y resuelven
* las diferentes piezas de nuestra aplicación.
*/
import { createContainer, asClass } from 'awilix';
// Adaptador que implementa el puerto de persistencia en Postgres
import { InventoryRepositoryPostgres } from '../infrastructure/postgres/InventoryRepositoryPostgres';
// Caso de uso que encapsula la lógica de reservar stock
import { ReserveStockUseCase } from './use-cases/ReserveStockUseCase';
// Creamos una instancia del contenedor de Awilix
export const container = createContainer({
// Modo de inyección: CLASSIC utiliza constructor injection
injectionMode: 'CLASSIC'
});
container.register({
// Registro del repositorio de inventario:
// - Clave 'inventoryRepo' será la que use ReserveStockUseCase
// - asClass indica que Awilix instanciará la clase
// - scoped(): se crea una nueva instancia por resolución
inventoryRepo: asClass(InventoryRepositoryPostgres).scoped(),
// Registro del caso de uso:
// - Clave 'reserveStockUseCase' para resolverlo en las rutas o tests
// - Se inyectará el inventoryRepo automáticamente al constructor
reserveStockUseCase: asClass(ReserveStockUseCase).scoped(),
});
5.4 Use Case y ruta HTTP
// src/application/use-cases/ReserveStockUseCase.ts
import { InventoryRepositoryPort } from '../../domain/ports/InventoryRepositoryPort';
export class ReserveStockUseCase {
constructor(private readonly repo: InventoryRepositoryPort) {}
async execute(sku: string, qty: number): Promise<void> {
const inv = await this.repo.findBySku(sku);
if (!inv) throw new Error('Product not found');
inv.reserve(qty);
await this.repo.save(inv);
}
}
// src/infrastructure/http/routes.ts
import { FastifyInstance } from 'fastify';
import { container } from '../../application/container';
import { ReserveStockUseCase } from '../../application/use-cases/ReserveStockUseCase';
export async function registerRoutes(app: FastifyInstance) {
const useCase = container.resolve<ReserveStockUseCase>('reserveStockUseCase');
app.post('/inventory/:sku/reserve', async (req, reply) => {
await useCase.execute(req.params.sku, req.body.qty);
reply.status(204).send();
});
}
6. Síntomas de mal implementación
Adapters con lógica de negocio: rutas que validan reglas complejas o calculan totales.
Dominio importando librerías externas (axios, fs, etc.).
Tests de dominio que fallan si no hay una base de datos o broker disponible.
Regla de oro: Si tu dominio necesita arrancar un contenedor para pasar un test, tu hexágono tiene fugas.
Última actualización