Die meisten Codebasen beginnen mit dem Framework im Zentrum: Controller rufen Services auf, Services rufen das ORM auf, und Geschäftsregeln sickern in alle drei. Eine Preisregel zu testen braucht dann einen Webserver und eine Datenbank, und den Zahlungsanbieter auszutauschen heißt, den Code anzufassen, der entscheidet, was eine Bestellung kostet.
Hexagonale Architektur, auch Ports and Adapters genannt, dreht das um. Dieser Artikel erklärt die Idee und das Vokabular (Ports, treibende und getriebene Adapter, die Abhängigkeitsregel), zeigt ein Diagramm für einen Bestellservice und klärt, wann sich das Muster mit seinen zusätzlichen Dateien lohnt.
Die Idee in einem Satz
Leg die Geschäftslogik in einen Kern, der nichts über HTTP, Datenbanken oder Anbieter weiß, und verbinde alles andere über Interfaces mit ihm, die dem Kern gehören.
Alistair Cockburn beschrieb das Muster 2005 in seinem ursprünglichen Artikel zur hexagonalen Architektur. Das erklärte Ziel: Eine Anwendung soll gleichermaßen von Nutzern, Programmen, automatisierten Tests oder Batch-Skripten angesteuert und isoliert von ihren späteren Datenbanken und Geräten entwickelt und getestet werden können. Die Sechseckform hat keine weitere Bedeutung, außer Platz zu lassen, um mehrere Ports an ihren Seiten zu zeichnen.
Das Vokabular
| Begriff | Was es ist | Beispiel Bestellservice |
|---|---|---|
| Kern | Use Cases und das Domänenmodell | PlaceOrder, Order, Money, Preisregeln |
| Eingehender (treibender) Port | Ein Interface, das der Kern nach außen anbietet | PlaceOrder.execute(command) |
| Ausgehender (getriebener) Port | Ein Interface, das der Kern von außen braucht | OrderRepository, PaymentGateway |
| Treibender Adapter | Übersetzt Eingaben von außen in Port-Aufrufe | REST-Controller, CLI, Message-Consumer, Test |
| Getriebener Adapter | Implementiert einen ausgehenden Port mit echter Technik | Postgres-Repository, Zahlungs-API-Client |
Die Abhängigkeitsregel hält alles zusammen: Quellcode-Abhängigkeiten zeigen nach innen. Adapter hängen von Ports ab; Ports und Kern hängen von nichts außerhalb des Sechsecks ab. Zur Laufzeit ruft der Kern das Zahlungs-Gateway auf, aber im Code hängt der Zahlungsadapter vom Interface des Kerns ab, nicht umgekehrt.
Ein durchgespieltes Beispiel
Eine Bestellung aufzugeben heißt, die Bestellung zu speichern und den Kunden zu belasten. Im Kern:
// ports, owned by the core
interface OrderRepository { save(order: Order): Promise<void> }
interface PaymentGateway { authorize(amount: Money, token: string): Promise<AuthResult> }
// use case
class PlaceOrder {
constructor(private orders: OrderRepository, private payments: PaymentGateway) {}
async execute(cmd: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(cmd.items, cmd.customerId) // domain rules
const auth = await this.payments.authorize(order.total(), cmd.paymentToken)
if (!auth.ok) throw new PaymentDeclined(auth.reason)
await this.orders.save(order.markPaid(auth.id))
return order.id
}
}
Außerhalb des Kerns implementiert PostgresOrderRepository das OrderRepository mit SQL, und PaymentApiGateway implementiert PaymentGateway mit HTTP-Aufrufen, Retries und Fehler-Mapping. Ein REST-Controller parst die Anfrage, baut ein PlaceOrderCommand und ruft den Use Case auf. Ein Test macht dasselbe mit In-Memory-Adaptern, ohne Webserver oder Datenbank.
Abbildung auf Ordner
Die Struktur lässt sich leichter halten, wenn die Ordner sie laut aussprechen:
src/
core/
domain/ Order, Money, pricing rules
usecases/ PlaceOrder, CancelOrder
ports/ OrderRepository, PaymentGateway
adapters/
http/ REST controllers (driving)
postgres/ repository implementations (driven)
payments/ payment API client (driven)
main.ts wiring: builds adapters and passes them to use cases
Füge eine Lint-Regel oder einen Architekturtest hinzu, der den Build scheitern lässt, sobald etwas unter core/ aus adapters/ oder aus einem Framework-Paket importiert. Die Regel ist leicht versehentlich zu brechen und günstig automatisch durchzusetzen.
Häufige Fehler
- Ports, die wie die Technik geformt sind.
OrderRepository.executeQuery(sql)ist eine Datenbank-API mit neuem Namen. Benenne Ports nach dem, was der Kern braucht:save,findOpenOrdersFor(customer). - Domänenobjekte, die ORM-Entities sind. Annotationen des Persistenz-Frameworks ziehen die Datenbank in den Kern. Bilde im Adapter zwischen Domänenobjekten und Persistenzmodellen ab.
- Adapter mit Geschäftsregeln. Ein Zahlungsadapter, der entscheidet, dass Erstattungen über 500 Dollar eine Freigabe brauchen, hat eine Regel aus dem Kern verschoben. Beschränke Adapter auf Übersetzung und Transport.
- Ein Port pro Klasse. Ports existieren für Grenzen, nicht für jeden Kollaborateur innerhalb des Kerns.
Das Architekturdiagramm
Diagramminhalt
- PlaceOrder use case
- Domain model
- PlaceOrder port
- OrderRepository port
- PaymentGateway port
- REST controller
- Test driver
- Postgres adapter
- Payment API adapter
- Postgres
- Payment provider
- calls
- implements
- SQL
- HTTPS
So liest du es:
- Das Sechseck ist der Anwendungskern: der Use Case PlaceOrder und das Domänenmodell, ohne Framework- oder Anbietercode.
- Links, treibende Seite: Ein REST-Controller und ein Testtreiber rufen beide den eingehenden Port PlaceOrder auf. Aus Sicht des Kerns sind sie austauschbar.
- Rechts, getriebene Seite: Der Use Case ruft zwei ausgehende Ports auf. Die gestrichelten Kanten mit hohlen Pfeilspitzen zeigen, dass der Postgres-Adapter und der Zahlungs-API-Adapter sie implementieren; das ist die Richtung der Code-Abhängigkeit.
- Ganz rechts: die echte Postgres-Datenbank und der Zahlungsanbieter, die nur die Adapter kennen.
- Der Markdown-Block nennt die Abhängigkeitsregel, das Eine, was Reviewer prüfen sollten.
Tests werden günstiger
Der größte Nutzen zeigt sich in den Tests:
- Kerntests nutzen In-Memory-Adapter und laufen in Millisekunden. Preisregeln, Zustandsübergänge und Fehlerbehandlung werden ohne I/O getestet.
- Adaptertests prüfen einen Adapter gegen die echte Technik: das Postgres-Repository gegen ein echtes Postgres, den Zahlungsadapter gegen die Sandbox des Anbieters.
- Ein paar End-to-End-Tests belegen die Verdrahtung.
Diese Aufteilung ist die Testpyramide in konkreter Form, und sie ist der Grund, warum Teams das Muster selbst in kleinen Services übernehmen.
Einen neuen Eingang hinzufügen
Der eingehende Port zahlt sich aus, sobald der zweite Kanal kommt. Angenommen, das Geschäft will Bestellungen nicht nur aus dem Web, sondern auch aus der Message-Queue eines Partners. Du schreibst einen Consumer, der eine Nachricht liest, dasselbe PlaceOrderCommand baut und denselben Port aufruft. Validierung, Preisberechnung und Zahlungslogik ändern sich nicht, ihre Tests auch nicht. Ohne den Port entsteht meist eine zweite Kopie der Bestelllogik im Consumer, die sich innerhalb weniger Releases von der ersten entfernt.
Wann es sich nicht lohnt
- CRUD ohne Regeln. Wenn der Service hauptsächlich Daten zwischen HTTP und einer Tabelle bewegt, sind die Ports bloße Zeremonie.
- Kurzlebiger Code. Prototypen und Skripte zahlen die zusätzlichen Interfaces nicht zurück.
- Für immer ein Adapter. Wenn du die Datenbank nie tauschen wirst und ohnehin gegen eine echte testest, fügt ein ausgehender Port Indirektion mit wenig Ertrag hinzu. Halte den Kern trotzdem frei von Framework-Code.
Hexagonale Architektur ist eng verwandt mit Onion und Clean Architecture. Sie unterscheiden sich im Vokabular der Schichten, teilen aber die Abhängigkeitsregel.
Was ins Diagramm gehört
- Die Grenze des Kerns, gezeichnet als Form, die eindeutig nur Domäne und Use Cases umschließt.
- Ports auf der Grenze, so benannt, wie der Kern sie nennt.
- Adapter außerhalb, als treibend oder getrieben beschriftet, mit der Technik, die sie kapseln.
- Die Abhängigkeitsrichtung bei getriebenen Adaptern, die sich von der Aufrufrichtung unterscheidet.
Für ein ganzes System statt eines einzelnen Services zeigt das C4-Modell-Diagrammbeispiel, wie man von Containern zu Komponenten hineinzoomt, und das Microservices-Architekturdiagramm zeigt, wo Services wie dieser hineinpassen. Das Diagramm zur Integration eines Payment Gateways behandelt, was hinter dem Zahlungsadapter passiert.
In Archboard nachbauen
- Melde dich mit Google oder GitHub an und leg ein Diagramm an.
- Füge aus dem Basis-Pack ein Sechseck für den Anwendungskern hinzu, mit einem Text als Beschriftung.
- Füge darin Komponente-Nodes aus dem C4-Modell-Pack für Use Case und Domänenmodell hinzu und Komponente-Nodes auf der Grenze für die drei Ports.
- Füge außerhalb Container-Nodes für die treibenden und getriebenen Adapter hinzu, dazu Postgres aus dem Daten-Pack und ein Externes System für den Zahlungsanbieter.
- Zeichne Implementierungskanten gestrichelt mit hohlen Pfeilspitzen und füge einen Markdown-Block aus dem Basis-Pack mit der Abhängigkeitsregel hinzu.
Das Beispiel nutzt 13 Diagramm-Nodes und bleibt damit im Free-Plan.
FAQ
Was ist der Unterschied zwischen einem Port und einem Adapter? Ein Port ist ein Interface, das dem Kern gehört. Ein Adapter ist der Code, der dieses Interface mit einer bestimmten Technik verbindet, etwa einem REST-Framework oder einer Datenbank.
Was sind treibende und getriebene Adapter? Treibende Adapter starten Interaktionen mit dem Kern, etwa Controller, Consumer und Tests. Getriebene Adapter werden vom Kern aufgerufen, etwa Repositories und API-Clients.
Ist hexagonale Architektur dasselbe wie Clean Architecture? Sie teilen die Abhängigkeitsregel: Abhängigkeiten zeigen zur Domäne. Clean und Onion Architecture fügen benannte Schichten im Kern hinzu; hexagonale Architektur unterscheidet nur zwischen innen und außen.
Braucht jeder Service hexagonale Architektur? Nein. Sie zahlt sich aus, wo es echte Geschäftsregeln und mehr als einen Weg gibt, sie anzusteuern oder zu speichern. Bei dünnen CRUD-Services sind es meist nur zusätzliche Dateien.
Wohin gehören Transaktionen? Meist in den Use Case oder einen Unit-of-Work-Port, sodass der Kern entscheidet, was gemeinsam committet werden muss, und der Adapter, wie.