Към съдържанието
wannacode

Hexagonal и clean architecture

Накратко: Хексагоналната архитектура (ports and adapters) дава на ядрото портове с неговите думи, а адаптерите за базата и външните услуги се включват отвън, затова зависимостите сочат навътре.

Проблем

Тестът, който иска банка

Плащането на поръчка има правила: платена поръчка не се плаща пак, а отказаната не става платена. Ето ядрото, както го е написал първият екип. Само си прави връзката с базата и клиента на доставчика на плащания CardPay:

class Checkout {
private final SqlOrders orders = new SqlOrders("db.shop.local");
private final CardPayClient provider = new CardPayClient();
String pay(String id) {
Order order = orders.find(id);
if (order.paid) return "вече е платена";
var response = provider.charge(order.total * 100L, "EUR", id);
if (!response.status().equals("APPROVED")) return "отказана";
order.paid = true;
orders.save(order);
return "платена";
}
}

Клиентът CardPay и хранилището с SQL са истински класове отвън. В урока тези „отвън“ са сведени до това, което правят в тест: нито база, нито мрежа има.

// Истинските класове отвън. Тук няма нито база, нито мрежа.
class SqlOrders {
SqlOrders(String host) {
throw new IllegalStateException("няма връзка с " + host);
}
Order find(String id) { return null; }
void save(Order order) {}
}

Тестът на правилото „платена поръчка не се плаща пак“:

try {
var checkout = new Checkout();
System.out.println(checkout.pay("A-7"));
} catch (IllegalStateException e) {
System.out.println("Тестът пада: " + e.getMessage());
}
Изход
Тестът пада: няма връзка с 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 Payments {
boolean charge(String orderId, int euros);
}
interface Orders {
Order find(String id);
void save(Order order);
}
class Order {
final String id;
final int total;
boolean paid;
Order(String id, int total) {
this.id = id;
this.total = total;
}
}
// Ядрото: правилата, без нищо отвън
class Checkout {
private final Orders orders;
private final Payments payments;
Checkout(Orders orders, Payments payments) {
this.orders = orders;
this.payments = payments;
}
String pay(String id) {
Order order = orders.find(id);
if (order.paid) return "вече е платена";
boolean ok = payments.charge(id, order.total);
if (!ok) return "отказана";
order.paid = true;
orders.save(order);
return "платена";
}
}
В Java портът е interface, а адаптерът го изпълнява с implements. Компилаторът пази, че всеки адаптер има charge.

Виж го

Адаптерите и магазинът

SDK-то на доставчика, както го вижда програмата. Мрежата е заменена със записани отговори, а клиентът печата какво е получил:

// SDK-то на доставчика. Тук мрежата е заменена
// със записани отговори.
class CardPayClient {
private final Deque<String> answers;
CardPayClient(String... answers) {
this.answers = new ArrayDeque<>(List.of(answers));
}
CardPayResponse charge(long cents, String currency, String ref) {
String status = answers.removeFirst();
System.out.printf(" CardPay: %s %d %s → %s%n", ref, cents, currency, status);
return new CardPayResponse(status);
}
}
record CardPayResponse(String status) {}

Адаптерът към CardPay, двойникът за теста и поръчките в паметта:

// Адаптерът превежда думите на ядрото към доставчика
class CardPayAdapter implements Payments {
private final CardPayClient client;
CardPayAdapter(CardPayClient client) {
this.client = client;
}
public boolean charge(String orderId, int euros) {
var response = client.charge(euros * 100L, "EUR", orderId);
return response.status().equals("APPROVED");
}
}
// Двойникът за теста: записва и отговаря, както му кажеш
class FakePayments implements Payments {
final List<String> charged = new ArrayList<>();
private final boolean approve;
FakePayments(boolean approve) {
this.approve = approve;
}
public boolean charge(String orderId, int euros) {
charged.add(orderId + " " + euros + " €");
return approve;
}
}
// Поръчките в паметта (в магазина: SqlOrders)
class InMemoryOrders implements Orders {
private final Map<String, Order> rows = new HashMap<>();
public Order find(String id) {
return rows.get(id);
}
public void save(Order order) {
rows.put(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);
for (String id : List.of("A-7", "A-8", "A-7"))
System.out.println(id + ": " + checkout.pay(id));
Изход
CardPay: A-7 12000 EUR → APPROVED
A-7: платена
CardPay: A-8 4000 EUR → DECLINED
A-8: отказана
A-7: вече е платена

// Портовете: какво иска ядрото, с неговите думи
interface Payments {
boolean charge(String orderId, int euros);
}
interface Orders {
Order find(String id);
void save(Order order);
}
class Order {
final String id;
final int total;
boolean paid;
Order(String id, int total) {
this.id = id;
this.total = total;
}
}
// Ядрото: правилата, без нищо отвън
class Checkout {
private final Orders orders;
private final Payments payments;
Checkout(Orders orders, Payments payments) {
this.orders = orders;
this.payments = payments;
}
String pay(String id) {
Order order = orders.find(id);
if (order.paid) return "вече е платена";
boolean ok = payments.charge(id, order.total);
if (!ok) return "отказана";
order.paid = true;
orders.save(order);
return "платена";
}
}
Стъпка по стъпка, с думи
  1. Ядрото е Checkout с два порта. Портовете са на ядрото и говорят с неговите думи: евро, да или не.
  2. Checkout знае само портовете. Не знае кой стои зад тях.
  3. В магазина в портовете влизат адаптери: CardPayAdapter пред доставчика и хранилището на поръчките.
  4. A-7 за 120 €: адаптерът го превежда в 12000 цента EUR, доставчикът казва APPROVED, а ядрото чува true.
  5. За теста адаптерите се сменят: двойник вместо доставчика. Ядрото е същото, без нито един променен ред.
  6. Тестът плаща A-7 два пъти: „платена“, после „вече е платена“. Двойникът е записал едно плащане. Без мрежа и без база.
  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);
System.out.println(checkout.pay("A-7"));
System.out.println(checkout.pay("A-7"));
String charged = String.join(", ", payments.charged);
System.out.println("Плащания: " + 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);
System.out.println(checkout.pay("A-8"));
System.out.println(checkout.pay("A-8"));
String charged = String.join(", ", payments.charged);
System.out.println("Плащания: " + charged);
Отговор

Идеята

Правилото за зависимостите

Посоката от слоевете тук става правило за зависимостите (dependency rule): зависимостите в кода сочат навътре, към ядрото. Адаптерът знае порта, а ядрото не знае адаптера. Кой внася кого:

  • ядрото: само себе си (Checkout, Order, портовете);
  • CardPayAdapter: порта Payments и SDK-то на CardPay;
  • FakePayments и InMemoryOrders: само портовете;
  • сглобяването в main: всичко, защото то решава.

Clean architecture (Robert Martin) рисува същото като кръгове: в средата правилата на бизнеса, около тях случаите на употреба (Checkout.pay), после адаптерите, а най-отвън рамките, базата и мрежата. Имената са различни, правилото е едно: стрелките сочат към средата.

Правилото не се чупи само с import. Чупи се и когато дума отвън влезе в ядрото.

Пробвай

Намери грешката

Някой е решил, че портът може да връща направо статуса на доставчика:

// Портовете: какво иска ядрото, с неговите думи
interface Payments {
String charge(String orderId, int euros);
}
interface Orders {
Order find(String id);
void save(Order order);
}
// Ядрото: правилата, без нищо отвън
class Checkout {
private final Orders orders;
private final Payments payments;
Checkout(Orders orders, Payments payments) {
this.orders = orders;
this.payments = payments;
}
String pay(String id) {
Order order = orders.find(id);
if (order.paid) return "вече е платена";
String status = payments.charge(id, order.total);
if (!status.equals("APPROVED")) return "отказана";
order.paid = true;
orders.save(order);
return "платена";
}
}

Тестът подава двойник, който одобрява:

// Двойникът на теста: одобрява
class FakePayments implements Payments {
public String charge(String orderId, int euros) {
return "OK";
}
}
var orders = new InMemoryOrders();
orders.save(new Order("A-7", 120));
var checkout = new Checkout(orders, new FakePayments());
System.out.println("A-7: " + checkout.pay("A-7"));
Изход
A-7: отказана

Двойникът одобрява, а поръчката е отказана. На кой ред от ядрото е грешката?

Избери реда с грешката.

Идеята

Кога е излишно

Портовете и адаптерите струват по един интерфейс и поне два класа за всяка граница. Не си струват, когато:

  • няма правила. Услуга, която чете ред и го връща, ще има порт, адаптер и превод между едни и същи данни, без нищо за тестване;
  • портът винаги има един адаптер и тестовете не го подменят. Тогава интерфейсът е само още един файл;
  • всеки адаптер има свой обект за поръчката (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 {
private final Deque<Boolean> answers;
PayLaterClient(Boolean... answers) {
this.answers = new ArrayDeque<>(List.of(answers));
}
boolean submit(String reference, String amount) {
boolean ok = answers.removeFirst();
System.out.printf(" PayLater: %s %s → %s%n", reference, amount, ok ? "ok" : "declined");
return ok;
}
}

Напиши адаптер PayLaterAdapter за порта Payments и го подай на същото ядро. Checkout не трябва да се променя. Плати A-7 (PayLater казва да) и A-8 (PayLater казва не):

Изход
PayLater: A-7 120.00 EUR → ok
A-7: платена
PayLater: A-8 40.00 EUR → declined
A-8: отказана

Още задачи

  • Известия. Добави порт Notifier с метод за известие „Поръчка A-7 е платена“. Адаптер за конзолата и двойник, който записва известията. Провери, че отказаната поръчка не праща известие.
  • Часовникът. Платената поръчка може да се върне до 14 дни. Добави порт за часовника и тест с двойник, който казва „днес е 15-ият ден“.

Тест

Провери се

1. Чий е портът Payments?
2. Магазинът сменя доставчика на плащания. Кой код се пише наново?
3. В ядрото пише if (status.equals("APPROVED")). Какво е нарушено?
4. Кое е водещ адаптер (driving), а не воден (driven)?
5. Услуга има един ендпойнт, който чете ред от базата и го връща, без правила. Струва ли си порт и адаптер?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·