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:
def __init__(self):
self.orders = SqlOrders("db.shop.local")
self.provider = CardPayClient()
def pay(self, order_id):
order = self.orders.find(order_id)
if order.paid:
return "ya está pagada"
response = self.provider.charge(order.total * 100, "EUR", order_id)
if response["status"] != "APPROVED":
return "rechazada"
order.paid = True
self.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:
def __init__(self, host):
raise ConnectionError("sin conexión con " + host)

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

try:
checkout = Checkout()
print(checkout.pay("A-7"))
except ConnectionError as e:
print("La prueba falla: " + str(e))
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
class Payments(Protocol):
def charge(self, order_id: str, euros: int) -> bool: ...
class Orders(Protocol):
def find(self, order_id: str) -> "Order": ...
def save(self, order: "Order") -> None: ...
@dataclass
class Order:
id: str
total: int
paid: bool = False
# El núcleo: las reglas, sin nada de fuera
class Checkout:
def __init__(self, orders: Orders, payments: Payments):
self.orders = orders
self.payments = payments
def pay(self, order_id):
order = self.orders.find(order_id)
if order.paid:
return "ya está pagada"
if not self.payments.charge(order_id, order.total):
return "rechazada"
order.paid = True
self.orders.save(order)
return "pagada"
En Python el puerto es un Protocol: los adaptadores no heredan de él, basta con que tengan los mismos métodos. Un verificador de tipos (mypy) detecta un adaptador sin charge antes de que corra el programa.

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:
def __init__(self, *answers):
self.answers = list(answers)
def charge(self, cents, currency, reference):
status = self.answers.pop(0)
print(f" CardPay: {reference} {cents} {currency} → {status}")
return {"status": 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:
def __init__(self, client):
self.client = client
def charge(self, order_id, euros):
response = self.client.charge(euros * 100, "EUR", order_id)
return response["status"] == "APPROVED"
# El doble de prueba: registra y responde como tú le digas
class FakePayments:
def __init__(self, approve):
self.approve = approve
self.charged = []
def charge(self, order_id, euros):
self.charged.append(f"{order_id} {euros} €")
return self.approve
# Pedidos en memoria (en la tienda: SqlOrders)
class InMemoryOrders:
def __init__(self):
self.rows = {}
def find(self, order_id):
return self.rows[order_id]
def save(self, order):
self.rows[order.id] = order

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

orders = InMemoryOrders()
orders.save(Order("A-7", 120))
orders.save(Order("A-8", 40))
client = CardPayClient("APPROVED", "DECLINED")
payments = CardPayAdapter(client)
checkout = Checkout(orders, payments)
for order_id in ["A-7", "A-8", "A-7"]:
print(f"{order_id}: {checkout.pay(order_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
class Payments(Protocol):
def charge(self, order_id: str, euros: int) -> bool: ...
class Orders(Protocol):
def find(self, order_id: str) -> "Order": ...
def save(self, order: "Order") -> None: ...
@dataclass
class Order:
id: str
total: int
paid: bool = False
# El núcleo: las reglas, sin nada de fuera
class Checkout:
def __init__(self, orders: Orders, payments: Payments):
self.orders = orders
self.payments = payments
def pay(self, order_id):
order = self.orders.find(order_id)
if order.paid:
return "ya está pagada"
if not self.payments.charge(order_id, order.total):
return "rechazada"
order.paid = True
self.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:

orders = InMemoryOrders()
orders.save(Order("A-7", 120))
payments = FakePayments(True)
checkout = Checkout(orders, payments)
print(checkout.pay("A-7"))
print(checkout.pay("A-7"))
print("Cobros: " + ", ".join(payments.charged))
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?

orders = InMemoryOrders()
orders.save(Order("A-8", 40))
payments = FakePayments(False)
checkout = Checkout(orders, payments)
print(checkout.pay("A-8"))
print(checkout.pay("A-8"))
print("Cobros: " + ", ".join(payments.charged))
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
class Payments(Protocol):
def charge(self, order_id: str, euros: int) -> str: ...
class Orders(Protocol):
def find(self, order_id: str) -> "Order": ...
def save(self, order: "Order") -> None: ...
# El núcleo: las reglas, sin nada de fuera
class Checkout:
def __init__(self, orders: Orders, payments: Payments):
self.orders = orders
self.payments = payments
def pay(self, order_id):
order = self.orders.find(order_id)
if order.paid:
return "ya está pagada"
status = self.payments.charge(order_id, order.total)
if status != "APPROVED":
return "rechazada"
order.paid = True
self.orders.save(order)
return "pagada"

La prueba pasa un doble que aprueba:

# El doble de prueba: aprueba
class FakePayments:
def charge(self, order_id, euros):
return "OK"
orders = InMemoryOrders()
orders.save(Order("A-7", 120))
checkout = Checkout(orders, FakePayments())
print("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.

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:
def __init__(self, *answers):
self.answers = list(answers)
def submit(self, reference, amount):
ok = self.answers.pop(0)
print(f" PayLater: {reference} {amount} → {'ok' if ok else '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 ·