Hexagonale und Clean Architecture
Kurz gesagt: Die hexagonale Architektur (Ports and Adapters) gibt dem Kern Ports in seinen eigenen Worten, und die Adapter für Datenbank und externe Dienste werden von außen eingesteckt, deshalb zeigen die Abhängigkeiten nach innen.
Problem
Der Test, der eine Bank will
Das Bezahlen einer Bestellung hat Regeln: Eine bezahlte Bestellung wird nicht noch einmal bezahlt, und eine abgelehnte wird nicht zu einer bezahlten. So hat das erste Team den Kern geschrieben. Er baut seine eigene Verbindung zur Datenbank und seinen eigenen Client für den Zahlungsanbieter CardPay auf:
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 "schon bezahlt"; var response = provider.charge(order.total * 100L, "EUR", id); if (!response.status().equals("APPROVED")) return "abgelehnt"; order.paid = true; orders.save(order); return "bezahlt"; }}Der CardPay-Client und das SQL-Repository sind echte Klassen von außen. In dieser Lektion sind diese „äußeren“ Klassen auf das reduziert, was sie in einem Test tun: Es gibt keine Datenbank und kein Netzwerk.
// Die echten Klassen von außen. Hier gibt es weder Datenbank noch Netzwerk.class SqlOrders { SqlOrders(String host) { throw new IllegalStateException("keine Verbindung zu " + host); }
Order find(String id) { return null; }
void save(Order order) {}}Der Test der Regel „Eine bezahlte Bestellung wird nicht noch einmal bezahlt“:
try { var checkout = new Checkout(); System.out.println(checkout.pay("A-7"));} catch (IllegalStateException e) { System.out.println("Der Test schlägt fehl: " + e.getMessage());}Der Test schlägt fehl: keine Verbindung zu db.shop.localDer Test erreicht die Regel nie. Er scheitert in der ersten Zeile, weil er eine Datenbank will. Mit einer Datenbank würde er ein Netzwerk und einen CardPay-Schlüssel wollen, und jeder Lauf würde wirklich bezahlen. Und mehr noch: Der Kern hält Cent, "EUR" und "APPROVED", die Worte von CardPay. Wechselt der Shop den Anbieter, wird der Kern neu geschrieben.
Die Idee
Ports und Adapter
In Schichten stand die Domäne über den Daten. Die hexagonale Architektur (Ports and Adapters) geht weiter: Alles außen ist gleichermaßen „außen“, die Datenbank, der Anbieter und der Bildschirm. In der Mitte ist der Kern mit den Regeln.
- Ein Port ist ein Interface des Kerns, in seinen Worten: „Bezahle diese Bestellung über 120 €, sag mir ja oder nein“. Der Port gehört dem Kern, nicht dem Anbieter;
- ein Adapter ist eine Klasse außen, die den Port implementiert und übersetzt.
CardPayAdaptermacht aus 120 € 12000 Cent, ruft das SDK auf und übersetzt"APPROVED"intrue. Das ist das Muster Adapter, an jede Grenze der Anwendung gesetzt.
Adapter gibt es in zwei Arten. Treibende Adapter rufen den Kern auf: der Controller, der Test, der Konsolenbefehl. Getriebene Adapter werden vom Kern über einen Port aufgerufen: Zahlung, Bestellungen, Mail.
Das ist DIP für die ganze Anwendung: nicht für eine Klasse, sondern für jede Grenze. Und der Kern bekommt seine Adapter von außen, im Konstruktor, wie in der Lektion Singleton und Dependency Injection.
Sieh es dir an
Der Kern
Die beiden Ports, die Bestellung und der Kern. Nichts hier weiß etwas von CardPay, SQL oder Cent:
// Die Ports: was der Kern braucht, in seinen eigenen Worteninterface 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; }}
// Der Kern: die Regeln, ohne etwas von außenclass 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 "schon bezahlt"; boolean ok = payments.charge(id, order.total); if (!ok) return "abgelehnt"; order.paid = true; orders.save(order); return "bezahlt"; }}interface, und der Adapter implementiert ihn mit implements. Der Compiler stellt sicher, dass jeder Adapter charge hat.Sieh es dir an
Die Adapter und der Shop
Das SDK des Anbieters, wie das Programm es sieht. Das Netzwerk ist durch aufgezeichnete Antworten ersetzt, und der Client druckt, was er bekommen hat:
// Das SDK des Anbieters. Hier ist das Netzwerk durch// aufgezeichnete Antworten ersetzt.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) {}Der CardPay-Adapter, der Testersatz und die Bestellungen im Speicher:
// Der Adapter übersetzt die Worte des Kerns für den Anbieterclass 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"); }}
// Der Testersatz: er zeichnet auf und antwortet, wie du es ihm sagstclass 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; }}
// Bestellungen im Speicher (im Shop: 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); }}Die Verdrahtung des Shops. Erst hier wird entschieden, welcher Adapter in welchen Port kommt:
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 → APPROVEDA-7: bezahlt CardPay: A-8 4000 EUR → DECLINEDA-8: abgelehntA-7: schon bezahlt// Die Ports: was der Kern braucht, in seinen eigenen Worteninterface 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; }}
// Der Kern: die Regeln, ohne etwas von außenclass 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 "schon bezahlt"; boolean ok = payments.charge(id, order.total); if (!ok) return "abgelehnt"; order.paid = true; orders.save(order); return "bezahlt"; }}// Die Ports: was der Kern braucht, in seinen eigenen Worteninterface 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; }}
// Der Kern: die Regeln, ohne etwas von außenclass 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 "schon bezahlt"; boolean ok = payments.charge(id, order.total); if (!ok) return "abgelehnt"; order.paid = true; orders.save(order); return "bezahlt"; }}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));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("Zahlungen: " + charged);Schritt für Schritt, in Worten
- Der Kern ist Checkout mit zwei Ports. Die Ports gehören dem Kern und sprechen seine Worte: Euro, ja oder nein.
- Checkout kennt nur die Ports. Wer dahintersteht, weiß er nicht.
- Im Shop stecken Adapter in den Ports: CardPayAdapter vor dem Anbieter und der Speicher der Bestellungen.
- A-7 für 120 €: Der Adapter übersetzt es in 12000 Cent EUR, der Anbieter sagt APPROVED, und der Kern hört true.
- Für den Test werden die Adapter getauscht: ein Testersatz statt des Anbieters. Der Kern ist derselbe, ohne eine einzige geänderte Zeile.
- Der Test bezahlt A-7 zweimal: „bezahlt“, dann „schon bezahlt“. Der Testersatz hat eine Zahlung aufgezeichnet. Kein Netzwerk, keine Datenbank.
- Die Pfeile zeigen nach innen: Die Adapter hängen von den Ports des Kerns ab, und der Kern hängt von nichts außen ab.
Der Kern sagt „120 €“, und CardPay bekommt „12000 EUR“. Die Übersetzung steckt im Adapter und nur dort. Im echten Shop liegen die Bestellungen in einer Datenbank: Der Adapter SqlOrders ist das Repository aus der Lektion Repository, und hier liegen sie im Speicher, weil die Lektion keine Datenbank hat.
Sieh es dir an
Der Test ohne etwas von außen
Derselbe Kern, andere Adapter: der Testersatz statt CardPay. Kein Netzwerk, keine Datenbank, kein Schlüssel:
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("Zahlungen: " + charged);bezahltschon bezahltZahlungen: A-7 120 €Die Regel „Eine bezahlte Bestellung wird nicht noch einmal bezahlt“ ist geprüft, und der Testersatz zeigt, dass der Anbieter nur eine Zahlung gesehen hat.
Probier es aus
Sag die Ausgabe voraus
Der Testersatz lehnt jetzt jede Zahlung ab. Was druckt der Test?
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("Zahlungen: " + charged);Eine abgelehnte Bestellung wird nicht bezahlt, also erreicht der zweite Versuch den Anbieter wieder und wird wieder abgelehnt. Der Testersatz hat zwei Versuche aufgezeichnet. So prüft ein Test eine Regel, die mit dem echten Anbieter zwei echte Abbuchungen kosten würde.
Die Idee
Die Abhängigkeitsregel
Die Richtung aus den Schichten wird hier zur Abhängigkeitsregel: Abhängigkeiten im Code zeigen nach innen, zum Kern. Der Adapter kennt den Port, und der Kern kennt den Adapter nicht. Wer wen importiert:
- der Kern: nur sich selbst (
Checkout,Order, die Ports); CardPayAdapter: den PortPaymentsund das CardPay-SDK;FakePaymentsundInMemoryOrders: nur die Ports;- die Verdrahtung in main: alles, denn sie entscheidet.
Die Clean Architecture (Robert Martin) zeichnet dasselbe als Kreise: die Geschäftsregeln in der Mitte, darum die Use Cases (Checkout.pay), dann die Adapter, und außen die Frameworks, die Datenbank und das Netzwerk. Die Namen sind verschieden, die Regel ist eine: Die Pfeile zeigen zur Mitte.
Die Regel wird nicht nur durch ein import gebrochen. Sie wird auch gebrochen, wenn ein Wort von außen in den Kern gelangt.
Probier es aus
Finde den Fehler
Jemand hat entschieden, dass der Port den Status des Anbieters direkt zurückgeben darf:
// Die Ports: was der Kern braucht, in seinen eigenen Worteninterface Payments { String charge(String orderId, int euros);}
interface Orders { Order find(String id);
void save(Order order);}// Der Kern: die Regeln, ohne etwas von außenclass 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 "schon bezahlt"; String status = payments.charge(id, order.total); if (!status.equals("APPROVED")) return "abgelehnt"; order.paid = true; orders.save(order); return "bezahlt"; }}Der Test reicht einen Testersatz hinein, der genehmigt:
// Der Testersatz: er genehmigtclass 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: abgelehntDer Testersatz genehmigt, und die Bestellung wird abgelehnt. In welcher Zeile des Kerns steckt der Fehler?
Wähle die Zeile mit dem Fehler.
"APPROVED" ist ein Wort von CardPay, und es ist in den Kern gelangt. Der Kern importiert keine CardPay-Klasse und hängt trotzdem von CardPay ab: Der Testersatz muss wissen, dass CardPay „APPROVED“ sagt, und ein anderer Anbieter, der „SUCCESS“ sagt, bricht den Kern. Der Port sollte die Worte des Kerns benutzen (ja oder nein), und den Status zu übersetzen ist Sache des Adapters.
Die Idee
Wann du es nicht brauchst
Ports und Adapter kosten für jede Grenze ein Interface und mindestens zwei Klassen. Sie lohnen sich nicht, wenn:
- es keine Regeln gibt. Ein Service, der eine Zeile liest und zurückgibt, bekommt einen Port, einen Adapter und eine Übersetzung zwischen denselben Daten, ohne etwas zu testen;
- der Port immer einen Adapter hat und Tests ihn nie tauschen. Dann ist das Interface nur eine Datei mehr;
- jeder Adapter sein eigenes Objekt für die Bestellung hat (
OrderEntityfür die Datenbank,OrderDtofür HTTP) und das Übersetzen dazwischen mehr Code ist als die Regeln.
Sie lohnen sich, wenn die Regeln ohne Netzwerk und Datenbank getestet werden müssen, wenn sich das Äußere ändert (ein Zahlungsanbieter, Mail, ein Lager) oder wenn ein Kern von mehreren Stellen aufgerufen wird: dem Web, der mobilen App, einem nächtlichen Job.
Genauer, für Neugierige
Der Name. Alistair Cockburn nannte die Idee „hexagonal“ (2005), weil er den Kern als Sechseck mit Platz für viele Ports zeichnete. Die sechs Seiten bedeuten nichts. Die Onion Architecture (Jeffrey Palermo) und die Clean Architecture (Robert Martin) sind Varianten derselben Regel.
In Frameworks. Spring und ASP.NET Core verdrahten Kern und Adapter mit einem DI-Container: Der Port ist ein Interface, und der Container reicht den Adapter hinein. In Python (FastAPI, Django) und Node (Express, Nest) ist die Verdrahtung oft von Hand am Anfang des Programms, wie in der Lektion. Tests in einem echten Projekt benutzen auch Bibliotheken für Testersatz: Mockito, Moq, unittest.mock, Mocks von node:test. Der handgeschriebene Testersatz der Lektion ist oft klarer.
Wie viel „außen“ ist. Auch die Uhr und der Zufall sind außen. Eine Regel „Die Bestellung läuft nach 30 Minuten ab“ lässt sich nicht testen, wenn der Kern die Systemuhr selbst fragt. Ein Port Clock mit einem Testersatz, der sagt „Es ist jetzt 10:30“, macht es möglich.
Übung
Ein zweiter Anbieter
Löse die Aufgabe in deinem Editor.
Der Shop bekommt einen zweiten Anbieter, PayLater. Sein SDK will den Betrag als Text („120.00 EUR“) und antwortet ja oder nein:
// Das SDK des zweiten Anbieters: der Betrag als Text, die Antwort ja oder neinclass 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; }}Schreibe einen Adapter PayLaterAdapter für den Port Payments und reiche ihn demselben Kern hinein. Checkout darf sich nicht ändern. Bezahle A-7 (PayLater sagt ja) und A-8 (PayLater sagt nein):
PayLater: A-7 120.00 EUR → okA-7: bezahlt PayLater: A-8 40.00 EUR → declinedA-8: abgelehntWeitere Aufgaben
- Benachrichtigungen. Füge einen Port
Notifiermit einer Methode für die Benachrichtigung „Bestellung A-7 ist bezahlt“ hinzu. Ein Adapter für die Konsole und ein Testersatz, der Benachrichtigungen aufzeichnet. Prüfe, dass eine abgelehnte Bestellung keine Benachrichtigung sendet. - Die Uhr. Eine bezahlte Bestellung kann innerhalb von 14 Tagen zurückgegeben werden. Füge einen Port für die Uhr hinzu und einen Test mit einem Testersatz, der sagt „Heute ist Tag 15“.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Adapter und Facade · Repository · SOLID ohne Dogma · Schichten
- Geht weiter in: Monolith, modularer Monolith, Services · ADR: Architekturentscheidungen festhalten · Architektur-Review
- Aufgaben zum Thema: Softwarearchitektur