Ir al contenido
wannacode

Arquitectura hexagonal y clean architecture

En resumen: La arquitectura hexagonal (ports and adapters) da al núcleo puertos con sus propias palabras, y los adaptadores de la base de datos y de los servicios externos se enchufan desde fuera, por eso las dependencias apuntan hacia dentro.

Problema

La prueba que quiere un banco

Pagar un pedido tiene reglas: un pedido pagado no se paga otra vez, y uno rechazado no pasa a pagado. Así escribió el primer equipo el núcleo. Abre su propia conexión con la base de datos y su propio cliente para el proveedor de pagos CardPay:

class Checkout {
orders = new SqlOrders("db.shop.local");
provider = new CardPayClient();
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "ya está pagada";
const response = this.provider.charge(order.total * 100, "EUR", id);
if (response.status !== "APPROVED") return "rechazada";
order.paid = true;
this.orders.save(order);
return "pagada";
}
}

El cliente de CardPay y el repositorio SQL son clases reales de fuera. En esta lección esas clases «de fuera» se reducen a lo que hacen en una prueba: no hay base de datos ni red.

// Las clases reales de fuera. Aquí no hay ni base de datos ni red.
class SqlOrders {
constructor(host) {
throw new Error("sin conexión con " + host);
}
}

La prueba de la regla «un pedido pagado no se paga otra vez»:

try {
const checkout = new Checkout();
console.log(checkout.pay("A-7"));
} catch (e) {
console.log("La prueba falla: " + e.message);
}
Salida
La prueba falla: sin conexión con db.shop.local

La prueba nunca llega a la regla. Falla en la primera línea, porque quiere una base de datos. Con una base de datos querría la red y una clave de CardPay, y cada ejecución pagaría de verdad. Y hay más: el núcleo guarda céntimos, "EUR" y "APPROVED", las palabras de CardPay. Cuando la tienda cambia de proveedor, el núcleo se reescribe.

La idea

Puertos y adaptadores

En capas el dominio estaba encima de los datos. La arquitectura hexagonal (ports and adapters) va más lejos: todo lo de fuera es igualmente «fuera», la base de datos, el proveedor y la pantalla. En el centro está el núcleo con las reglas.

  • Un puerto es una interfaz del núcleo, con sus palabras: «paga este pedido de 120 €, dime sí o no». El puerto es del núcleo, no del proveedor;
  • un adaptador es una clase de fuera que implementa el puerto y traduce. CardPayAdapter convierte 120 € en 12000 céntimos, llama al SDK y traduce "APPROVED" a true. Es el patrón Adapter, puesto en cada frontera de la aplicación.

Los adaptadores son de dos tipos. Los conductores llaman al núcleo: el controlador, la prueba, el comando de consola. Los conducidos los llama el núcleo a través de un puerto: pago, pedidos, correo.

Esto es DIP para toda la aplicación: no para una clase, sino para cada frontera. Y el núcleo recibe sus adaptadores desde fuera, en el constructor, como en la lección Singleton e inyección de dependencias.

Míralo

El núcleo

Los dos puertos, el pedido y el núcleo. Nada de aquí sabe de CardPay, de SQL ni de céntimos:

// Los puertos: lo que necesita el núcleo, con sus propias palabras
// payments.charge(orderId, euros) devuelve true o false
// orders.find(id) devuelve el pedido, orders.save(order)
// El núcleo: las reglas, sin nada de fuera
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "ya está pagada";
const ok = this.payments.charge(id, order.total);
if (!ok) return "rechazada";
order.paid = true;
this.orders.save(order);
return "pagada";
}
}
JavaScript no tiene interfaces, así que los puertos son un comentario: sirve cualquier objeto con charge(orderId, euros). La regla la mantienen el cuidado y las pruebas, no un compilador.

Míralo

Los adaptadores y la tienda

El SDK del proveedor tal como lo ve el programa. La red está sustituida por respuestas grabadas, y el cliente imprime lo que recibió:

// El SDK del proveedor. Aquí la red está sustituida
// por respuestas grabadas.
class CardPayClient {
#answers;
constructor(...answers) {
this.#answers = answers;
}
charge(cents, currency, reference) {
const status = this.#answers.shift();
console.log(` CardPay: ${reference} ${cents} ${currency} → ${status}`);
return { status };
}
}

El adaptador de CardPay, el doble de prueba y los pedidos en memoria:

// El adaptador traduce las palabras del núcleo para el proveedor
class CardPayAdapter {
constructor(client) {
this.client = client;
}
charge(orderId, euros) {
const response = this.client.charge(euros * 100, "EUR", orderId);
return response.status === "APPROVED";
}
}
// El doble de prueba: registra y responde como tú le digas
class FakePayments {
charged = [];
constructor(approve) {
this.approve = approve;
}
charge(orderId, euros) {
this.charged.push(`${orderId} ${euros} €`);
return this.approve;
}
}
// Pedidos en memoria (en la tienda: SqlOrders)
class InMemoryOrders {
#rows = new Map();
find(id) {
return this.#rows.get(id);
}
save(order) {
this.#rows.set(order.id, order);
}
}

El montaje de la tienda. Solo aquí se decide qué adaptador va en qué puerto:

const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
orders.save({ id: "A-8", total: 40, paid: false });
const client = new CardPayClient("APPROVED", "DECLINED");
const payments = new CardPayAdapter(client);
const checkout = new Checkout(orders, payments);
for (const id of ["A-7", "A-8", "A-7"])
console.log(`${id}: ${checkout.pay(id)}`);
Salida
CardPay: A-7 12000 EUR → APPROVED
A-7: pagada
CardPay: A-8 4000 EUR → DECLINED
A-8: rechazada
A-7: ya está pagada

// Los puertos: lo que necesita el núcleo, con sus propias palabras
// payments.charge(orderId, euros) devuelve true o false
// orders.find(id) devuelve el pedido, orders.save(order)
// El núcleo: las reglas, sin nada de fuera
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "ya está pagada";
const ok = this.payments.charge(id, order.total);
if (!ok) return "rechazada";
order.paid = true;
this.orders.save(order);
return "pagada";
}
}
Paso a paso, en palabras
  1. El núcleo es Checkout con dos puertos. Los puertos son del núcleo y hablan con sus palabras: euros, sí o no.
  2. Checkout solo conoce los puertos. No sabe quién está detrás.
  3. En la tienda, en los puertos se enchufan adaptadores: CardPayAdapter delante del proveedor y el almacén de los pedidos.
  4. A-7 por 120 €: el adaptador lo traduce a 12000 céntimos EUR, el proveedor dice APPROVED, y el núcleo oye true.
  5. Para la prueba se cambian los adaptadores: un doble en lugar del proveedor. El núcleo es el mismo, sin una sola línea cambiada.
  6. La prueba paga A-7 dos veces: «pagada», luego «ya está pagada». El doble registró un cobro. Sin red y sin base de datos.
  7. Las flechas apuntan hacia dentro: los adaptadores dependen de los puertos del núcleo, y el núcleo no depende de nada de fuera.

El núcleo dice «120 €», y CardPay recibe «12000 EUR». La traducción está en el adaptador y solo ahí. En la tienda real los pedidos están en una base de datos: el adaptador SqlOrders es el repositorio de la lección Repository, y aquí están en memoria, porque la lección no tiene base de datos.

Míralo

La prueba sin nada de fuera

El mismo núcleo, otros adaptadores: el doble en lugar de CardPay. Sin red, sin base de datos, sin clave:

const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
const payments = new FakePayments(true);
const checkout = new Checkout(orders, payments);
console.log(checkout.pay("A-7"));
console.log(checkout.pay("A-7"));
console.log("Cobros: " + payments.charged.join(", "));
Salida
pagada
ya está pagada
Cobros: A-7 120 €

La regla «un pedido pagado no se paga otra vez» queda comprobada, y el doble muestra que el proveedor vio un solo cobro.

Pruébalo

Predice la salida

Ahora el doble rechaza todos los pagos. ¿Qué imprime la prueba?

const orders = new InMemoryOrders();
orders.save({ id: "A-8", total: 40, paid: false });
const payments = new FakePayments(false);
const checkout = new Checkout(orders, payments);
console.log(checkout.pay("A-8"));
console.log(checkout.pay("A-8"));
console.log("Cobros: " + payments.charged.join(", "));
Respuesta

La idea

La regla de dependencia

La dirección de las capas se convierte aquí en la regla de dependencia: las dependencias en el código apuntan hacia dentro, hacia el núcleo. El adaptador conoce el puerto, y el núcleo no conoce el adaptador. Quién importa a quién:

  • el núcleo: solo a sí mismo (Checkout, Order, los puertos);
  • CardPayAdapter: el puerto Payments y el SDK de CardPay;
  • FakePayments y InMemoryOrders: solo los puertos;
  • el montaje en el programa: todo, porque es quien decide.

La clean architecture (Robert Martin) dibuja lo mismo como círculos: las reglas de negocio en el centro, los casos de uso alrededor (Checkout.pay), luego los adaptadores, y fuera los frameworks, la base de datos y la red. Los nombres son distintos, la regla es una: las flechas apuntan al centro.

La regla no se rompe solo con un import. También se rompe cuando una palabra de fuera se cuela en el núcleo.

Pruébalo

Encuentra el error

Alguien decidió que el puerto puede devolver directamente el estado del proveedor:

// Los puertos: lo que necesita el núcleo, con sus propias palabras
// payments.charge(orderId, euros) devuelve un estado
// orders.find(id) devuelve el pedido, orders.save(order)
// El núcleo: las reglas, sin nada de fuera
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "ya está pagada";
const status = this.payments.charge(id, order.total);
if (status !== "APPROVED") return "rechazada";
order.paid = true;
this.orders.save(order);
return "pagada";
}
}

La prueba pasa un doble que aprueba:

// El doble de prueba: aprueba
class FakePayments {
charge(orderId, euros) {
return "OK";
}
}
const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
const checkout = new Checkout(orders, new FakePayments());
console.log("A-7: " + checkout.pay("A-7"));
Salida
A-7: rechazada

El doble aprueba, y el pedido se rechaza. ¿En qué línea del núcleo está el error?

Elige la línea con el error.

La idea

Cuándo no lo necesitas

Los puertos y adaptadores cuestan una interfaz y al menos dos clases por cada frontera. No compensan cuando:

  • no hay reglas. Un servicio que lee una fila y la devuelve tendrá un puerto, un adaptador y una traducción entre los mismos datos, sin nada que probar;
  • el puerto siempre tiene un adaptador y las pruebas nunca lo cambian. Entonces la interfaz es solo un archivo más;
  • cada adaptador tiene su propio objeto para el pedido (OrderEntity para la base de datos, OrderDto para HTTP) y traducir entre ellos es más código que las reglas.

Compensan cuando las reglas hay que probarlas sin red ni base de datos, cuando lo de fuera cambia (un proveedor de pagos, el correo, un almacén), o cuando un núcleo lo llaman desde varios sitios: la web, la app móvil, un trabajo nocturno.

Más precisamente, para curiosos

El nombre. Alistair Cockburn llamó «hexagonal» a la idea (2005), porque dibujó el núcleo como un hexágono con sitio para muchos puertos. Los seis lados no significan nada. La arquitectura cebolla (Jeffrey Palermo) y la clean architecture (Robert Martin) son variantes de la misma regla.

En los frameworks. Spring y ASP.NET Core montan el núcleo y los adaptadores con un contenedor de DI: el puerto es una interfaz, y el contenedor pasa el adaptador. En Python (FastAPI, Django) y Node (Express, Nest) el montaje suele hacerse a mano al inicio del programa, como en la lección. En un proyecto real las pruebas también usan bibliotecas de dobles: Mockito, Moq, unittest.mock, los mocks de node:test. El doble escrito a mano de la lección suele ser más claro.

Cuánto es «fuera». El reloj y el azar también están fuera. Una regla «el pedido caduca a los 30 minutos» no se puede probar si el núcleo pregunta él mismo la hora al sistema. Un puerto Clock con un doble que dice «ahora son las 10:30» lo hace posible.

Sin tipos. En JavaScript la regla de dependencia se mantiene con herramientas que miran los import (dependency-cruiser), y los puertos se describen con JSDoc o con una interfaz de TypeScript.

Ejercicio

Un segundo proveedor

Resuelve la tarea en tu editor.

La tienda añade un segundo proveedor, PayLater. Su SDK quiere el importe como texto («120.00 EUR») y responde sí o no:

// El SDK del segundo proveedor: el importe como texto, la respuesta sí o no
class PayLaterClient {
#answers;
constructor(...answers) {
this.#answers = answers;
}
submit(reference, amount) {
const ok = this.#answers.shift();
console.log(` PayLater: ${reference} ${amount} → ${ok ? "ok" : "declined"}`);
return ok;
}
}

Escribe un adaptador PayLaterAdapter para el puerto payments y pásalo al mismo núcleo. Checkout no debe cambiar. Paga A-7 (PayLater dice sí) y A-8 (PayLater dice no):

Salida
PayLater: A-7 120.00 EUR → ok
A-7: pagada
PayLater: A-8 40.00 EUR → declined
A-8: rechazada

Más tareas

  • Notificaciones. Añade un puerto Notifier con un método para la notificación «El pedido A-7 está pagado». Un adaptador para la consola y un doble que registre las notificaciones. Comprueba que un pedido rechazado no envía ninguna notificación.
  • El reloj. Un pedido pagado se puede devolver en 14 días. Añade un puerto para el reloj y una prueba con un doble que diga «hoy es el día 15».

Test

Ponte a prueba

1. ¿De quién es el puerto Payments?
2. La tienda cambia de proveedor de pagos. ¿Qué código se escribe de nuevo?
3. El núcleo dice if (status.equals("APPROVED")). ¿Qué está roto?
4. ¿Cuál es un adaptador conductor y no uno conducido?
5. Un servicio tiene un endpoint que lee una fila de la base de datos y la devuelve, sin reglas. ¿Valen la pena un puerto y un adaptador?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·