Arquitectura hexagonal: ports and adapters | Nicolás Garzón
La arquitectura hexagonal separa el núcleo de aplicación de los mecanismos externos mediante puertos definidos desde las necesidades del núcleo y adaptadores que traducen tecnologías, protocolos y modelos concretos.
Ports and Adapters propone que una aplicación pueda ser utilizada y probada sin quedar atada a un canal específico de entrada ni a un mecanismo concreto de salida.
Texto
Copiar adaptadores de entrada
HTTP · CLI · mensajes · jobs
↓
puertos entrantes
↓
núcleo de aplicación
↓
puertos salientes
↓
adaptadores de salida
DB · APIs · correo · brokerEl “hexágono” no representa seis lados obligatorios. Es una forma de mostrar que existen múltiples puntos de conexión alrededor de un núcleo protegido.
Una aplicación suele comenzar con un único canal, como HTTP, y una base concreta. Si la lógica queda escrita directamente dentro de controllers y modelos ORM:
No puede ejecutarse desde un job o consumer sin duplicación.
Las pruebas dependen de servidor y base.
Los tipos externos invaden el dominio.
Sustituir un proveedor exige modificar casos de uso.
Los límites entre política y mecanismo desaparecen.
Hexagonal convierte esas conexiones en adaptadores explícitos.
Un puerto entrante representa una capacidad ofrecida por la aplicación:
TypeScript
Copiar interface ConfirmOrderUseCase {
execute ( command: ConfirmOrderCommand) : Promise < ConfirmOrderResult> ;
} Puede ser invocado desde:
Controller HTTP.
CLI administrativa.
Consumer de mensajes.
Job programado.
Prueba automatizada.
El puerto no conoce el transporte. Su contrato expresa intención de aplicación.
El adapter traduce el mundo externo al puerto:
TypeScript
Copiar class ConfirmOrderHttpController {
constructor ( private readonly confirmOrder: ConfirmOrderUseCase) { }
async handle ( request: HttpRequest) : Promise < HttpResponse> {
const command = {
orderId: request. params. orderId,
actorId: request. auth. userId,
tenantId: request. auth. tenantId,
requestId: request. headers[ "idempotency-key" ] ,
} ;
const result = await this . confirmOrder. execute ( command) ;
return mapConfirmOrderResult ( result) ;
}
}
Lee protocolo y contexto autenticado.
Valida forma básica.
Construye un command propio.
Invoca el caso de uso.
Traduce el resultado a HTTP.
No debería contener invariantes del pedido ni queries directas.
Representan capacidades externas que el núcleo necesita:
TypeScript
Copiar interface OrderRepository {
findById ( id: OrderId) : Promise < Order | null > ;
save ( order: Order) : Promise < void > ;
}
interface PaymentGateway {
authorize ( input: AuthorizationRequest) : Promise < AuthorizationResult> ;
} El núcleo define contratos desde su lenguaje. No importa tipos de Prisma o Stripe.
Implementan puertos usando tecnología concreta:
TypeScript
Copiar class PostgresOrderRepository implements OrderRepository {
constructor ( private readonly db: Database) { }
async findById ( id: OrderId) : Promise < Order | null > {
const row = await this . db. orders. findOne ( { id: id. value } ) ;
return row ? mapRowToOrder ( row) : null ;
}
async save ( order: Order) : Promise < void > {
await this . db. orders. update ( mapOrderToRow ( order) ) ;
}
}
Representación.
Tipos.
Errores.
Transacciones.
Semántica de ausencia.
Capacidades y limitaciones.
Inician una interacción: controller, CLI, test, cron o message consumer.
Son utilizados por la aplicación: repository, gateway, clock, event publisher.
La distinción ayuda a comprender quién controla el flujo.
El ensamblaje ocurre en el borde:
TypeScript
Copiar const repository = new PostgresOrderRepository ( db) ;
const payments = new StripePaymentGateway ( stripe) ;
const useCase = new ConfirmOrder ( repository, payments) ;
const controller = new ConfirmOrderHttpController ( useCase) ; El núcleo no utiliza un service locator para buscar dependencias. Las recibe explícitamente.
DomiSys puede confirmar un pedido desde:
Una acción del administrador.
Un flujo automático tras pago.
Una integración externa.
Todos pueden usar el mismo puerto:
Texto
Copiar HTTP adapter ─┐
Webhook adapter ─┼→ ConfirmOrderUseCase
Job adapter ─────┘Cada adapter traduce identidad y datos. El caso de uso aplica las mismas reglas.
Un puerto saliente no debe copiar el proveedor.
TypeScript
Copiar interface PaymentGateway {
createPaymentIntent ( params: Stripe. PaymentIntentCreateParams) : Promise < Stripe. PaymentIntent> ;
} TypeScript
Copiar interface PaymentGateway {
authorize ( input: AuthorizationRequest) : Promise < AuthorizationResult> ;
} El segundo contrato permite traducir unidades, estados, idempotencia y errores.
Fakes de puertos salientes:
TypeScript
Copiar class InMemoryOrderRepository implements OrderRepository {
} Prueba parsing, autenticación, mapeo y códigos.
Prueba contra tecnología real o sandbox.
Verifica que todas las implementaciones respeten la misma semántica.
Simetría entre entradas y salidas.
Puertos como capacidades.
Adaptadores alrededor del núcleo.
Clean Architecture enfatiza:
Capas o círculos conceptuales.
Políticas interiores.
Regla de dependencia.
Pueden aplicarse juntas. No es necesario elegir una etiqueta exclusiva.
Repository<T> permite operaciones que el dominio no necesita y filtra persistencia.
Un wrapper que devuelve tipos externos no protege el núcleo.
Abstraer Math.max o una función estable añade ruido. Crea puertos para fronteras relevantes.
Si una operación usa varias persistencias, el diseño debe establecer coordinación. La arquitectura hexagonal no define automáticamente atomicidad.
Un consumer es adapter de entrada; publicar es un puerto saliente. Deben manejar duplicación, orden y errores según su contrato.
Llamar “adapter” a cualquier clase.
Diseñar puertos desde la tecnología.
Crear un puerto por función trivial.
Permitir tipos externos en el núcleo.
Ocultar el composition root dentro de un container global.
Creer que sustituir providers siempre será gratuito.
No probar adapters reales.
Varios canales de entrada.
Proveedores o persistencia externos.
Necesidad de pruebas precisas.
Dominio que debe permanecer independiente.
Integraciones con semántica distinta.
Una aplicación pequeña puede usar funciones y pocos contratos. La idea importante es conservar límites, no cumplir una cantidad de clases.
Los puertos expresan capacidades del núcleo.
Los adapters traducen protocolos, modelos y errores.
Entradas y salidas tienen roles distintos.
El composition root ensambla implementaciones.
Un puerto debe hablar el lenguaje del consumidor.
Hexagonal protege dependencias, pero no resuelve por sí sola consistencia o transacciones distribuidas.
¿Por qué un controller es un adapter de entrada?
¿Qué diferencia existe entre driving y driven adapter?
¿Qué debe traducir un repository adapter?
¿Cuándo un puerto es ceremonial?
¿Cómo reutilizarías un caso de uso desde HTTP y un job?
Arquitectura en capas compara una organización por niveles de abstracción y muestra cuándo las capas aportan separación o solo forwarding.