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{ private readonly SqlOrders orders = new("db.shop.local"); private readonly CardPayClient provider = new();
public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "ya está pagada"; var response = provider.Charge(order.Total * 100L, "EUR", id); if (response.Status != "APPROVED") return "rechazada"; order.Paid = true; 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{ public SqlOrders(string host) => throw new InvalidOperationException("sin conexión con " + host);
public Order Find(string id) => throw new NotImplementedException(); public void Save(Order order) { }}La prueba de la regla «un pedido pagado no se paga otra vez»:
try{ var checkout = new Checkout(); Console.WriteLine(checkout.Pay("A-7"));}catch (InvalidOperationException e){ Console.WriteLine("La prueba falla: " + e.Message);}La prueba falla: sin conexión con db.shop.localLa 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.
CardPayAdapterconvierte 120 € en 12000 céntimos, llama al SDK y traduce"APPROVED"atrue. 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 palabrasinterface IPayments{ bool Charge(string orderId, int euros);}
interface IOrders{ Order Find(string id); void Save(Order order);}
class Order(string id, int total){ public string Id { get; } = id; public int Total { get; } = total; public bool Paid { get; set; }}
// El núcleo: las reglas, sin nada de fueraclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "ya está pagada"; bool ok = payments.Charge(id, order.Total); if (!ok) return "rechazada"; order.Paid = true; orders.Save(order); return "pagada"; }}I por costumbre: IPayments, IOrders. El adaptador los implementa después de los dos puntos.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(params string[] answers){ private readonly Queue<string> queue = new(answers);
public CardPayResponse Charge(long cents, string currency, string reference) { var status = queue.Dequeue(); Console.WriteLine($" CardPay: {reference} {cents} {currency} → {status}"); return new CardPayResponse(status); }}
record CardPayResponse(string Status);El adaptador de CardPay, el doble de prueba y los pedidos en memoria:
// El adaptador traduce las palabras del núcleo para el proveedorclass CardPayAdapter(CardPayClient client) : IPayments{ public bool Charge(string orderId, int euros) { var response = client.Charge(euros * 100L, "EUR", orderId); return response.Status == "APPROVED"; }}
// El doble de prueba: registra y responde como tú le digasclass FakePayments(bool approve) : IPayments{ public List<string> Charged { get; } = [];
public bool Charge(string orderId, int euros) { Charged.Add($"{orderId} {euros} €"); return approve; }}
// Pedidos en memoria (en la tienda: SqlOrders)class InMemoryOrders : IOrders{ private readonly Dictionary<string, Order> rows = [];
public Order Find(string id) => rows[id];
public void Save(Order order) => rows[order.Id] = order;}El montaje de la tienda. Solo aquí se decide qué adaptador va en qué puerto:
var orders = new InMemoryOrders();orders.Save(new Order("A-7", 120));orders.Save(new Order("A-8", 40));var client = new CardPayClient("APPROVED", "DECLINED");var payments = new CardPayAdapter(client);var checkout = new Checkout(orders, payments);foreach (var id in new[] { "A-7", "A-8", "A-7" }) Console.WriteLine($"{id}: {checkout.Pay(id)}"); CardPay: A-7 12000 EUR → APPROVEDA-7: pagada CardPay: A-8 4000 EUR → DECLINEDA-8: rechazadaA-7: ya está pagada// Los puertos: lo que necesita el núcleo, con sus propias palabrasinterface IPayments{ bool Charge(string orderId, int euros);}
interface IOrders{ Order Find(string id); void Save(Order order);}
class Order(string id, int total){ public string Id { get; } = id; public int Total { get; } = total; public bool Paid { get; set; }}
// El núcleo: las reglas, sin nada de fueraclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "ya está pagada"; bool ok = payments.Charge(id, order.Total); if (!ok) return "rechazada"; order.Paid = true; orders.Save(order); return "pagada"; }}// Los puertos: lo que necesita el núcleo, con sus propias palabrasinterface IPayments{ bool Charge(string orderId, int euros);}
interface IOrders{ Order Find(string id); void Save(Order order);}
class Order(string id, int total){ public string Id { get; } = id; public int Total { get; } = total; public bool Paid { get; set; }}
// El núcleo: las reglas, sin nada de fueraclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "ya está pagada"; bool ok = payments.Charge(id, order.Total); if (!ok) return "rechazada"; order.Paid = true; orders.Save(order); return "pagada"; }}var orders = new InMemoryOrders();orders.Save(new Order("A-7", 120));orders.Save(new Order("A-8", 40));var client = new CardPayClient("APPROVED", "DECLINED");var payments = new CardPayAdapter(client);var checkout = new Checkout(orders, payments);foreach (var id in new[] { "A-7", "A-8", "A-7" }) Console.WriteLine($"{id}: {checkout.Pay(id)}");var orders = new InMemoryOrders();orders.Save(new Order("A-7", 120));var payments = new FakePayments(true);var checkout = new Checkout(orders, payments);Console.WriteLine(checkout.Pay("A-7"));Console.WriteLine(checkout.Pay("A-7"));var charged = string.Join(", ", payments.Charged);Console.WriteLine("Cobros: " + charged);Paso a paso, en palabras
- El núcleo es Checkout con dos puertos. Los puertos son del núcleo y hablan con sus palabras: euros, sí o no.
- Checkout solo conoce los puertos. No sabe quién está detrás.
- En la tienda, en los puertos se enchufan adaptadores: CardPayAdapter delante del proveedor y el almacén de los pedidos.
- A-7 por 120 €: el adaptador lo traduce a 12000 céntimos EUR, el proveedor dice APPROVED, y el núcleo oye true.
- 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.
- La prueba paga A-7 dos veces: «pagada», luego «ya está pagada». El doble registró un cobro. Sin red y sin base de datos.
- 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:
var orders = new InMemoryOrders();orders.Save(new Order("A-7", 120));var payments = new FakePayments(true);var checkout = new Checkout(orders, payments);Console.WriteLine(checkout.Pay("A-7"));Console.WriteLine(checkout.Pay("A-7"));var charged = string.Join(", ", payments.Charged);Console.WriteLine("Cobros: " + charged);pagadaya está pagadaCobros: 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?
var orders = new InMemoryOrders();orders.Save(new Order("A-8", 40));var payments = new FakePayments(false);var checkout = new Checkout(orders, payments);Console.WriteLine(checkout.Pay("A-8"));Console.WriteLine(checkout.Pay("A-8"));var charged = string.Join(", ", payments.Charged);Console.WriteLine("Cobros: " + charged);Un pedido rechazado no pasa a pagado, así que el segundo intento llega otra vez al proveedor y se rechaza otra vez. El doble registró dos intentos. Así una prueba comprueba una regla que con el proveedor real costaría dos cobros reales.
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 puertoPaymentsy el SDK de CardPay;FakePaymentsyInMemoryOrders: solo los puertos;- el montaje en Main: 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 palabrasinterface IPayments{ string Charge(string orderId, int euros);}
interface IOrders{ Order Find(string id); void Save(Order order);}// El núcleo: las reglas, sin nada de fueraclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "ya está pagada"; var status = payments.Charge(id, order.Total); if (status != "APPROVED") return "rechazada"; order.Paid = true; orders.Save(order); return "pagada"; }}La prueba pasa un doble que aprueba:
// El doble de prueba: apruebaclass FakePayments : IPayments{ public string Charge(string orderId, int euros) => "OK";}var orders = new InMemoryOrders();orders.Save(new Order("A-7", 120));var checkout = new Checkout(orders, new FakePayments());Console.WriteLine("A-7: " + checkout.Pay("A-7"));A-7: rechazadaEl doble aprueba, y el pedido se rechaza. ¿En qué línea del núcleo está el error?
Elige la línea con el error.
"APPROVED" es una palabra de CardPay, y se ha colado en el núcleo. El núcleo no importa ninguna clase de CardPay, pero depende de CardPay: el doble tiene que saber que CardPay dice «APPROVED», y otro proveedor que diga «SUCCESS» rompe el núcleo. El puerto debería usar las palabras del núcleo (sí o no), y traducir el estado es cosa del adaptador.
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 (
OrderEntitypara la base de datos,OrderDtopara 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 noclass PayLaterClient(params bool[] answers){ private readonly Queue<bool> queue = new(answers);
public bool Submit(string reference, string amount) { var ok = queue.Dequeue(); Console.WriteLine($" PayLater: {reference} {amount} → {(ok ? "ok" : "declined")}"); return ok; }}Escribe un adaptador PayLaterAdapter para el puerto IPayments y pásalo al mismo núcleo. Checkout no debe cambiar. Paga A-7 (PayLater dice sí) y A-8 (PayLater dice no):
PayLater: A-7 120.00 EUR → okA-7: pagada PayLater: A-8 40.00 EUR → declinedA-8: rechazadaMás tareas
- Notificaciones. Añade un puerto
Notifiercon 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Adapter y Facade · Repository · SOLID sin dogma · Capas
- Continúa en: Monolito, monolito modular, servicios · ADR: registros de decisiones de arquitectura · Revisión de arquitectura
- Ejercicios sobre el tema: Arquitectura de software