La mayoría de las bases de código empiezan con el framework en el centro: los controladores llaman a los servicios, los servicios llaman al ORM y las reglas de negocio se filtran en los tres. Probar una regla de precios necesita entonces un servidor web y una base de datos, y cambiar de proveedor de pagos obliga a tocar el código que decide cuánto cuesta un pedido.
La arquitectura hexagonal, también llamada puertos y adaptadores, le da la vuelta a eso. Este artículo explica la idea y el vocabulario (puertos, adaptadores primarios y secundarios, la regla de dependencia), muestra un diagrama para un servicio de pedidos y explica cuándo el patrón compensa sus archivos de más.
La idea en una frase
Pon la lógica de negocio en un núcleo que no sepa nada de HTTP, bases de datos ni proveedores, y conecta todo lo demás a él mediante interfaces que son propiedad del núcleo.
Alistair Cockburn describió el patrón en 2005 en su artículo original sobre la arquitectura hexagonal. Su intención declarada es que una aplicación pueda ser dirigida por igual por usuarios, programas, pruebas automatizadas o scripts por lotes, y que pueda desarrollarse y probarse aislada de sus futuras bases de datos y dispositivos. La forma de hexágono no significa nada más que dejar sitio para dibujar varios puertos en sus lados.
El vocabulario
| Término | Qué es | Ejemplo en el servicio de pedidos |
|---|---|---|
| Núcleo | Los casos de uso y el modelo de dominio | PlaceOrder, Order, Money, reglas de precios |
| Puerto de entrada (primario) | Una interfaz que el núcleo ofrece al exterior | PlaceOrder.execute(command) |
| Puerto de salida (secundario) | Una interfaz que el núcleo necesita del exterior | OrderRepository, PaymentGateway |
| Adaptador primario | Convierte la entrada externa en llamadas a puertos | Controlador REST, CLI, consumidor de mensajes, prueba |
| Adaptador secundario | Implementa un puerto de salida con tecnología real | Repositorio de Postgres, cliente de la API de pagos |
La regla de dependencia lo une todo: las dependencias del código fuente apuntan hacia dentro. Los adaptadores dependen de los puertos; los puertos y el núcleo no dependen de nada fuera del hexágono. En tiempo de ejecución el núcleo llama a la pasarela de pagos, pero en el código es el adaptador de pagos el que depende de la interfaz del núcleo, y no al revés.
Un ejemplo completo
Hacer un pedido requiere guardar el pedido y cobrar al cliente. En el núcleo:
// ports, owned by the core
interface OrderRepository { save(order: Order): Promise<void> }
interface PaymentGateway { authorize(amount: Money, token: string): Promise<AuthResult> }
// use case
class PlaceOrder {
constructor(private orders: OrderRepository, private payments: PaymentGateway) {}
async execute(cmd: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(cmd.items, cmd.customerId) // domain rules
const auth = await this.payments.authorize(order.total(), cmd.paymentToken)
if (!auth.ok) throw new PaymentDeclined(auth.reason)
await this.orders.save(order.markPaid(auth.id))
return order.id
}
}
Fuera del núcleo, PostgresOrderRepository implementa OrderRepository con SQL, y PaymentApiGateway implementa PaymentGateway con llamadas HTTP, reintentos y traducción de errores. Un controlador REST analiza la petición, construye un PlaceOrderCommand y llama al caso de uso. Una prueba hace lo mismo con adaptadores en memoria, sin servidor web ni base de datos.
Llevarlo a carpetas
La estructura es más fácil de mantener cuando las carpetas la dicen en voz alta:
src/
core/
domain/ Order, Money, pricing rules
usecases/ PlaceOrder, CancelOrder
ports/ OrderRepository, PaymentGateway
adapters/
http/ REST controllers (driving)
postgres/ repository implementations (driven)
payments/ payment API client (driven)
main.ts wiring: builds adapters and passes them to use cases
Añade una regla de lint o una prueba de arquitectura que haga fallar el build cuando algo dentro de core/ importe desde adapters/ o desde un paquete del framework. La regla es fácil de romper sin querer y barata de hacer cumplir automáticamente.
Errores comunes
- Puertos con la forma de la tecnología.
OrderRepository.executeQuery(sql)es una API de base de datos con otro nombre. Nombra los puertos según lo que necesita el núcleo:save,findOpenOrdersFor(customer). - Objetos de dominio que son entidades del ORM. Las anotaciones del framework de persistencia arrastran la base de datos al núcleo. Haz la conversión entre objetos de dominio y modelos de persistencia en el adaptador.
- Adaptadores con reglas de negocio. Un adaptador de pagos que decide que los reembolsos de más de 500 dólares necesitan aprobación ha sacado una regla del núcleo. Limita los adaptadores a traducir y transportar.
- Un puerto por clase. Los puertos existen para las fronteras, no para cada colaborador dentro del núcleo.
El diagrama de arquitectura
Contenido del diagrama
- PlaceOrder use case
- Domain model
- PlaceOrder port
- OrderRepository port
- PaymentGateway port
- REST controller
- Test driver
- Postgres adapter
- Payment API adapter
- Postgres
- Payment provider
- calls
- implements
- SQL
- HTTPS
Cómo leerlo:
- El hexágono es el núcleo de la aplicación: el caso de uso PlaceOrder y el modelo de dominio, sin código de framework ni de proveedores.
- Izquierda, lado primario: un controlador REST y un driver de pruebas llaman al puerto de entrada PlaceOrder. Desde el punto de vista del núcleo, son intercambiables.
- Derecha, lado secundario: el caso de uso llama a dos puertos de salida. Las aristas discontinuas con punta de flecha hueca muestran que el adaptador de Postgres y el de la API de pagos los implementan, que es la dirección de la dependencia del código.
- Extremo derecho: la base de datos Postgres real y el proveedor de pagos, que solo conocen los adaptadores.
- El bloque Markdown enuncia la regla de dependencia, lo único que deben comprobar los revisores.
Las pruebas salen más baratas
La mayor parte del beneficio aparece en las pruebas:
- Las pruebas del núcleo usan adaptadores en memoria y tardan milisegundos. Las reglas de precios, las transiciones de estado y el manejo de errores se prueban sin E/S.
- Las pruebas de adaptadores comprueban un adaptador contra la tecnología real: el repositorio de Postgres contra un Postgres real y el adaptador de pagos contra el sandbox del proveedor.
- Unas pocas pruebas de extremo a extremo demuestran que el cableado funciona.
Esa división es la pirámide de pruebas hecha realidad, y es la razón por la que los equipos adoptan el patrón incluso en servicios pequeños.
Añadir una nueva vía de entrada
El puerto de entrada se amortiza la primera vez que llega un segundo canal. Supón que el negocio quiere recibir pedidos desde la cola de mensajes de un socio además de la web. Escribes un consumidor que lee un mensaje, construye el mismo PlaceOrderCommand y llama al mismo puerto. La validación, los precios y la lógica de pagos no cambian, y sus pruebas tampoco. Sin el puerto, lo habitual es acabar con una segunda copia de la lógica de pedidos dentro del consumidor, que se aleja de la primera en pocas versiones.
Cuándo no compensa
- CRUD sin reglas. Si el servicio se dedica sobre todo a mover datos entre HTTP y una tabla, los puertos son pura ceremonia.
- Código de vida corta. Los prototipos y los scripts no amortizan las interfaces adicionales.
- Un solo adaptador para siempre. Si nunca vas a cambiar de base de datos y de todos modos pruebas contra una real, un puerto de salida añade indirección con poco retorno. Aun así, mantén el núcleo libre de código del framework.
La arquitectura hexagonal es pariente cercana de la arquitectura en cebolla y de la clean architecture. Difieren en el vocabulario de capas, pero comparten la regla de dependencia.
Qué poner en el diagrama
- La frontera del núcleo, dibujada como una forma que encierre claramente solo el dominio y los casos de uso.
- Los puertos en la frontera, con los nombres que les da el núcleo.
- Los adaptadores fuera, etiquetados como primarios o secundarios, con la tecnología que envuelven.
- La dirección de la dependencia de los adaptadores secundarios, distinta de la dirección de la llamada.
Para un sistema completo en lugar de un solo servicio, el ejemplo de diagrama con el modelo C4 muestra cómo hacer zoom de contenedores a componentes, y el diagrama de arquitectura de microservicios muestra dónde encajan servicios como este. El diagrama de integración de una pasarela de pagos cubre lo que ocurre detrás del adaptador de pagos.
Constrúyelo en Archboard
- Inicia sesión con Google o GitHub y crea un diagrama.
- Añade un Hexágono del pack Básico para el núcleo de la aplicación, con un Texto como etiqueta.
- Dentro, añade nodos Componente del pack Modelo C4 para el caso de uso y el modelo de dominio, y nodos Componente en la frontera para los tres puertos.
- Añade fuera nodos Contenedor para los adaptadores primarios y secundarios, además de Postgres del pack Datos y un Sistema externo para el proveedor de pagos.
- Dibuja las aristas de implementación discontinuas con punta de flecha hueca, y añade un bloque Markdown del pack Básico con la regla de dependencia.
El ejemplo usa 13 nodos de diagrama, dentro del plan Free.
Preguntas frecuentes
¿Qué diferencia hay entre un puerto y un adaptador? Un puerto es una interfaz que pertenece al núcleo. Un adaptador es el código que conecta esa interfaz con una tecnología concreta, como un framework REST o una base de datos.
¿Qué son los adaptadores primarios y secundarios? Los adaptadores primarios inician las interacciones con el núcleo, como los controladores, los consumidores y las pruebas. A los adaptadores secundarios los llama el núcleo, como los repositorios y los clientes de API.
¿Es la arquitectura hexagonal lo mismo que la clean architecture? Comparten la regla de dependencia: las dependencias apuntan hacia el dominio. La clean architecture y la arquitectura en cebolla añaden capas con nombre dentro del núcleo; la arquitectura hexagonal solo distingue el interior del exterior.
¿Todos los servicios necesitan arquitectura hexagonal? No. Compensa donde hay reglas de negocio reales y más de una forma de dirigirlas o almacenarlas. En servicios CRUD finos, son sobre todo archivos de más.
¿Dónde van las transacciones? Normalmente en el caso de uso o en un puerto de unit of work, para que el núcleo decida qué debe confirmarse junto y el adaptador decida cómo.