Hexagonal и clean architecture
Накратко: Хексагоналната архитектура (ports and adapters) дава на ядрото портове с неговите думи, а адаптерите за базата и външните услуги се включват отвън, затова зависимостите сочат навътре.
Проблем
Тестът, който иска банка
Плащането на поръчка има правила: платена поръчка не се плаща пак, а отказаната не става платена. Ето ядрото, както го е написал първият екип. Само си прави връзката с базата и клиента на доставчика на плащания 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 "вече е платена"; var response = provider.Charge(order.Total * 100L, "EUR", id); if (response.Status != "APPROVED") return "отказана"; order.Paid = true; orders.Save(order); return "платена"; }}Клиентът CardPay и хранилището с SQL са истински класове отвън. В урока тези „отвън“ са сведени до това, което правят в тест: нито база, нито мрежа има.
// Истинските класове отвън. Тук няма нито база, нито мрежа.class SqlOrders{ public SqlOrders(string host) => throw new InvalidOperationException("няма връзка с " + host);
public Order Find(string id) => throw new NotImplementedException(); public void Save(Order order) { }}Тестът на правилото „платена поръчка не се плаща пак“:
try{ var checkout = new Checkout(); Console.WriteLine(checkout.Pay("A-7"));}catch (InvalidOperationException e){ Console.WriteLine("Тестът пада: " + e.Message);}Тестът пада: няма връзка с db.shop.localТестът не стига до правилото. Пада на първия ред, защото иска база. Ако имаше база, щеше да поиска мрежа и ключ на CardPay, а всяко пускане щеше да плаща наистина. И още: в ядрото са центовете, "EUR" и "APPROVED", думите на CardPay. Когато магазинът смени доставчика, се пренаписва ядрото.
Идеята
Портове и адаптери
В слоевете домейнът стоеше над данните. Hexagonal architecture (портове и адаптери) отива по-далеч: всичко отвън е еднакво „навън“, и базата, и доставчикът, и екранът. В средата е ядрото с правилата.
- Порт е интерфейс на ядрото, с неговите думи: „плати тази поръчка за 120 €, кажи да или не“. Портът е на ядрото, не на доставчика;
- адаптер е клас отвън, който изпълнява порта и превежда.
CardPayAdapterпревръща 120 € в 12000 цента, вика SDK-то и превежда"APPROVED"вtrue. Това е шаблонът Adapter, сложен на всяка граница на приложението.
Адаптерите са два вида. Водещите (driving) викат ядрото: контролерът, тестът, командата в конзолата. Водените (driven) ядрото вика през порт: плащането, поръчките, пощата.
Това е DIP за цялото приложение: не за един клас, а за всяка граница. А ядрото получава адаптерите си отвън, в конструктора, както в урока Singleton и Dependency Injection.
Виж го
Ядрото
Двата порта, поръчката и ядрото. Нищо тук не знае за CardPay, SQL или центове:
// Портовете: какво иска ядрото, с неговите думиinterface 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; }}
// Ядрото: правилата, без нищо отвънclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "вече е платена"; bool ok = payments.Charge(id, order.Total); if (!ok) return "отказана"; order.Paid = true; orders.Save(order); return "платена"; }}I: IPayments, IOrders. Адаптерът ги изпълнява след двоеточието.Виж го
Адаптерите и магазинът
SDK-то на доставчика, както го вижда програмата. Мрежата е заменена със записани отговори, а клиентът печата какво е получил:
// SDK-то на доставчика. Тук мрежата е заменена// със записани отговори.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);Адаптерът към CardPay, двойникът за теста и поръчките в паметта:
// Адаптерът превежда думите на ядрото към доставчикаclass CardPayAdapter(CardPayClient client) : IPayments{ public bool Charge(string orderId, int euros) { var response = client.Charge(euros * 100L, "EUR", orderId); return response.Status == "APPROVED"; }}
// Двойникът за теста: записва и отговаря, както му кажешclass FakePayments(bool approve) : IPayments{ public List<string> Charged { get; } = [];
public bool Charge(string orderId, int euros) { Charged.Add($"{orderId} {euros} €"); return approve; }}
// Поръчките в паметта (в магазина: 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;}Сглобяването на магазина. Само тук се решава кой адаптер влиза в кой порт:
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: платена CardPay: A-8 4000 EUR → DECLINEDA-8: отказанаA-7: вече е платена// Портовете: какво иска ядрото, с неговите думиinterface 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; }}
// Ядрото: правилата, без нищо отвънclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "вече е платена"; bool ok = payments.Charge(id, order.Total); if (!ok) return "отказана"; order.Paid = true; orders.Save(order); return "платена"; }}// Портовете: какво иска ядрото, с неговите думиinterface 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; }}
// Ядрото: правилата, без нищо отвънclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "вече е платена"; bool ok = payments.Charge(id, order.Total); if (!ok) return "отказана"; order.Paid = true; orders.Save(order); return "платена"; }}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("Плащания: " + charged);Стъпка по стъпка, с думи
- Ядрото е Checkout с два порта. Портовете са на ядрото и говорят с неговите думи: евро, да или не.
- Checkout знае само портовете. Не знае кой стои зад тях.
- В магазина в портовете влизат адаптери: CardPayAdapter пред доставчика и хранилището на поръчките.
- A-7 за 120 €: адаптерът го превежда в 12000 цента EUR, доставчикът казва APPROVED, а ядрото чува true.
- За теста адаптерите се сменят: двойник вместо доставчика. Ядрото е същото, без нито един променен ред.
- Тестът плаща A-7 два пъти: „платена“, после „вече е платена“. Двойникът е записал едно плащане. Без мрежа и без база.
- Стрелките сочат навътре: адаптерите зависят от портовете на ядрото, а ядрото не зависи от нищо отвън.
Ядрото казва „120 €“, а CardPay получава „12000 EUR“. Преводът е в адаптера и само там. В истинския магазин поръчките са в база: адаптерът SqlOrders е хранилището от урока Repository, а тук са в паметта, защото в урока база няма.
Виж го
Тестът без нищо отвън
Същото ядро, други адаптери: двойникът вместо CardPay. Нито мрежа, нито база, нито ключ:
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("Плащания: " + charged);платенавече е платенаПлащания: A-7 120 €Правилото „платена поръчка не се плаща пак“ е проверено, а двойникът показва, че доставчикът е видял само едно плащане.
Пробвай
Предвиди изхода
Двойникът сега отказва всяко плащане. Какво печата тестът?
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("Плащания: " + charged);Отказаната поръчка не става платена, затова вторият опит пак стига до доставчика и пак е отказан. Двойникът е записал два опита. Така тест проверява правило, което с истинския доставчик би струвало две истински плащания.
Идеята
Правилото за зависимостите
Посоката от слоевете тук става правило за зависимостите (dependency rule): зависимостите в кода сочат навътре, към ядрото. Адаптерът знае порта, а ядрото не знае адаптера. Кой внася кого:
- ядрото: само себе си (
Checkout,Order, портовете); CardPayAdapter: портаPaymentsи SDK-то на CardPay;FakePaymentsиInMemoryOrders: само портовете;- сглобяването в Main: всичко, защото то решава.
Clean architecture (Robert Martin) рисува същото като кръгове: в средата правилата на бизнеса, около тях случаите на употреба (Checkout.pay), после адаптерите, а най-отвън рамките, базата и мрежата. Имената са различни, правилото е едно: стрелките сочат към средата.
Правилото не се чупи само с import. Чупи се и когато дума отвън влезе в ядрото.
Пробвай
Намери грешката
Някой е решил, че портът може да връща направо статуса на доставчика:
// Портовете: какво иска ядрото, с неговите думиinterface IPayments{ string Charge(string orderId, int euros);}
interface IOrders{ Order Find(string id); void Save(Order order);}// Ядрото: правилата, без нищо отвънclass Checkout(IOrders orders, IPayments payments){ public string Pay(string id) { var order = orders.Find(id); if (order.Paid) return "вече е платена"; var status = payments.Charge(id, order.Total); if (status != "APPROVED") return "отказана"; order.Paid = true; orders.Save(order); return "платена"; }}Тестът подава двойник, който одобрява:
// Двойникът на теста: одобряваclass 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: отказанаДвойникът одобрява, а поръчката е отказана. На кой ред от ядрото е грешката?
Избери реда с грешката.
"APPROVED" е дума на CardPay, а е влязла в ядрото. Ядрото не внася нито един клас на CardPay, но пак зависи от него: двойникът трябва да знае, че CardPay казва „APPROVED“, а друг доставчик, който казва „SUCCESS“, чупи ядрото. Портът трябва да е с думите на ядрото (да или не), а преводът на статуса е работа на адаптера.
Идеята
Кога е излишно
Портовете и адаптерите струват по един интерфейс и поне два класа за всяка граница. Не си струват, когато:
- няма правила. Услуга, която чете ред и го връща, ще има порт, адаптер и превод между едни и същи данни, без нищо за тестване;
- портът винаги има един адаптер и тестовете не го подменят. Тогава интерфейсът е само още един файл;
- всеки адаптер има свой обект за поръчката (
OrderEntityза базата,OrderDtoза HTTP) и превеждането между тях е повече код от правилата.
Струва си, когато правилата трябва да се тестват без мрежа и база, когато външното нещо се сменя (доставчик на плащания, поща, склад) или когато едно ядро се вика от няколко места: уеб, мобилно приложение, нощна задача.
По-точно, за любопитните
Името. Alistair Cockburn нарича идеята „hexagonal“ (2005), защото рисува ядрото като шестоъгълник с място за много портове. Шестте страни не значат нищо. Onion architecture (Jeffrey Palermo) и clean architecture (Robert Martin) са варианти на същото правило.
В рамките. Spring и ASP.NET Core сглобяват ядрото и адаптерите с DI контейнер: портът е интерфейс, а контейнерът подава адаптера. В Python (FastAPI, Django) и Node (Express, Nest) сглобяването често е на ръка в началото на програмата, както в урока. Тестовете в истински проект ползват и библиотеки за двойници: Mockito, Moq, unittest.mock, node:test mock. Ръчният двойник от урока често е по-ясен.
Колко е „отвън“. Часовникът и случайността също са отвън. Правило „поръчката изтича след 30 минути“ не се тества, ако ядрото само пита системния часовник. Порт Clock с двойник, който казва „сега е 10:30“, го прави.
Упражнение
Втори доставчик
Реши задачата в редактора си.
Магазинът добавя втория доставчик PayLater. Неговото SDK иска сумата като текст („120.00 EUR“) и отговаря с да или не:
// SDK-то на втория доставчик: сума като текст, отговор да или неclass 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; }}Напиши адаптер PayLaterAdapter за порта IPayments и го подай на същото ядро. Checkout не трябва да се променя. Плати A-7 (PayLater казва да) и A-8 (PayLater казва не):
PayLater: A-7 120.00 EUR → okA-7: платена PayLater: A-8 40.00 EUR → declinedA-8: отказанаОще задачи
- Известия. Добави порт
Notifierс метод за известие „Поръчка A-7 е платена“. Адаптер за конзолата и двойник, който записва известията. Провери, че отказаната поръчка не праща известие. - Часовникът. Платената поръчка може да се върне до 14 дни. Добави порт за часовника и тест с двойник, който казва „днес е 15-ият ден“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Adapter и Facade · Repository · SOLID без догма · Слоеве
- Продължава в: Монолит, модулен монолит, услуги · ADR: записани архитектурни решения · Архитектурен преглед
- Задачи по темата: Софтуерни архитектури