Zum Inhalt
wannacode

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 {
orders = new SqlOrders("db.shop.local");
provider = new CardPayClient();
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "schon bezahlt";
const response = this.provider.charge(order.total * 100, "EUR", id);
if (response.status !== "APPROVED") return "abgelehnt";
order.paid = true;
this.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 {
constructor(host) {
throw new Error("keine Verbindung zu " + host);
}
}

Der Test der Regel „Eine bezahlte Bestellung wird nicht noch einmal bezahlt“:

try {
const checkout = new Checkout();
console.log(checkout.pay("A-7"));
} catch (e) {
console.log("Der Test schlägt fehl: " + e.message);
}
Ausgabe
Der Test schlägt fehl: keine Verbindung zu db.shop.local

Der 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. CardPayAdapter macht aus 120 € 12000 Cent, ruft das SDK auf und übersetzt "APPROVED" in true. 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 Worten
// payments.charge(orderId, euros) gibt true oder false zurück
// orders.find(id) gibt die Bestellung zurück, orders.save(order)
// Der Kern: die Regeln, ohne etwas von außen
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "schon bezahlt";
const ok = this.payments.charge(id, order.total);
if (!ok) return "abgelehnt";
order.paid = true;
this.orders.save(order);
return "bezahlt";
}
}
JavaScript hat keine Interfaces, deshalb sind die Ports ein Kommentar: Jedes Objekt mit charge(orderId, euros) passt. Die Regel halten Sorgfalt und Tests ein, kein Compiler.

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 {
#answers;
constructor(...answers) {
this.#answers = answers;
}
charge(cents, currency, reference) {
const status = this.#answers.shift();
console.log(` CardPay: ${reference} ${cents} ${currency} → ${status}`);
return { status };
}
}

Der CardPay-Adapter, der Testersatz und die Bestellungen im Speicher:

// Der Adapter übersetzt die Worte des Kerns für den Anbieter
class CardPayAdapter {
constructor(client) {
this.client = client;
}
charge(orderId, euros) {
const response = this.client.charge(euros * 100, "EUR", orderId);
return response.status === "APPROVED";
}
}
// Der Testersatz: er zeichnet auf und antwortet, wie du es ihm sagst
class FakePayments {
charged = [];
constructor(approve) {
this.approve = approve;
}
charge(orderId, euros) {
this.charged.push(`${orderId} ${euros} €`);
return this.approve;
}
}
// Bestellungen im Speicher (im Shop: SqlOrders)
class InMemoryOrders {
#rows = new Map();
find(id) {
return this.#rows.get(id);
}
save(order) {
this.#rows.set(order.id, order);
}
}

Die Verdrahtung des Shops. Erst hier wird entschieden, welcher Adapter in welchen Port kommt:

const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
orders.save({ id: "A-8", total: 40, paid: false });
const client = new CardPayClient("APPROVED", "DECLINED");
const payments = new CardPayAdapter(client);
const checkout = new Checkout(orders, payments);
for (const id of ["A-7", "A-8", "A-7"])
console.log(`${id}: ${checkout.pay(id)}`);
Ausgabe
CardPay: A-7 12000 EUR → APPROVED
A-7: bezahlt
CardPay: A-8 4000 EUR → DECLINED
A-8: abgelehnt
A-7: schon bezahlt

// Die Ports: was der Kern braucht, in seinen eigenen Worten
// payments.charge(orderId, euros) gibt true oder false zurück
// orders.find(id) gibt die Bestellung zurück, orders.save(order)
// Der Kern: die Regeln, ohne etwas von außen
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "schon bezahlt";
const ok = this.payments.charge(id, order.total);
if (!ok) return "abgelehnt";
order.paid = true;
this.orders.save(order);
return "bezahlt";
}
}
Schritt für Schritt, in Worten
  1. Der Kern ist Checkout mit zwei Ports. Die Ports gehören dem Kern und sprechen seine Worte: Euro, ja oder nein.
  2. Checkout kennt nur die Ports. Wer dahintersteht, weiß er nicht.
  3. Im Shop stecken Adapter in den Ports: CardPayAdapter vor dem Anbieter und der Speicher der Bestellungen.
  4. A-7 für 120 €: Der Adapter übersetzt es in 12000 Cent EUR, der Anbieter sagt APPROVED, und der Kern hört true.
  5. Für den Test werden die Adapter getauscht: ein Testersatz statt des Anbieters. Der Kern ist derselbe, ohne eine einzige geänderte Zeile.
  6. Der Test bezahlt A-7 zweimal: „bezahlt“, dann „schon bezahlt“. Der Testersatz hat eine Zahlung aufgezeichnet. Kein Netzwerk, keine Datenbank.
  7. 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:

const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
const payments = new FakePayments(true);
const checkout = new Checkout(orders, payments);
console.log(checkout.pay("A-7"));
console.log(checkout.pay("A-7"));
console.log("Zahlungen: " + payments.charged.join(", "));
Ausgabe
bezahlt
schon bezahlt
Zahlungen: 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?

const orders = new InMemoryOrders();
orders.save({ id: "A-8", total: 40, paid: false });
const payments = new FakePayments(false);
const checkout = new Checkout(orders, payments);
console.log(checkout.pay("A-8"));
console.log(checkout.pay("A-8"));
console.log("Zahlungen: " + payments.charged.join(", "));
Antwort

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 Port Payments und das CardPay-SDK;
  • FakePayments und InMemoryOrders: nur die Ports;
  • die Verdrahtung in dem Programm: 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 Worten
// payments.charge(orderId, euros) gibt einen Status zurück
// orders.find(id) gibt die Bestellung zurück, orders.save(order)
// Der Kern: die Regeln, ohne etwas von außen
class Checkout {
constructor(orders, payments) {
this.orders = orders;
this.payments = payments;
}
pay(id) {
const order = this.orders.find(id);
if (order.paid) return "schon bezahlt";
const status = this.payments.charge(id, order.total);
if (status !== "APPROVED") return "abgelehnt";
order.paid = true;
this.orders.save(order);
return "bezahlt";
}
}

Der Test reicht einen Testersatz hinein, der genehmigt:

// Der Testersatz: er genehmigt
class FakePayments {
charge(orderId, euros) {
return "OK";
}
}
const orders = new InMemoryOrders();
orders.save({ id: "A-7", total: 120, paid: false });
const checkout = new Checkout(orders, new FakePayments());
console.log("A-7: " + checkout.pay("A-7"));
Ausgabe
A-7: abgelehnt

Der Testersatz genehmigt, und die Bestellung wird abgelehnt. In welcher Zeile des Kerns steckt der Fehler?

Wähle die Zeile mit dem Fehler.

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 (OrderEntity für die Datenbank, OrderDto fü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.

Ohne Typen. In JavaScript hält man die Abhängigkeitsregel mit Werkzeugen ein, die auf die imports schauen (dependency-cruiser), und Ports beschreibt man mit JSDoc oder einem TypeScript-Interface.

Ü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 nein
class PayLaterClient {
#answers;
constructor(...answers) {
this.#answers = answers;
}
submit(reference, amount) {
const ok = this.#answers.shift();
console.log(` PayLater: ${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):

Ausgabe
PayLater: A-7 120.00 EUR → ok
A-7: bezahlt
PayLater: A-8 40.00 EUR → declined
A-8: abgelehnt

Weitere Aufgaben

  • Benachrichtigungen. Füge einen Port Notifier mit 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

1. Wem gehört der Port Payments?
2. Der Shop wechselt den Zahlungsanbieter. Welcher Code wird neu geschrieben?
3. Der Kern sagt if (status.equals("APPROVED")). Was ist kaputt?
4. Welcher ist ein treibender Adapter und kein getriebener?
5. Ein Service hat einen Endpunkt, der eine Zeile aus der Datenbank liest und ohne Regeln zurückgibt. Lohnen sich ein Port und ein Adapter?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·