Hexagonale Architektur | Ein praktischer Leitfaden fur moderne Anwendungen
Erfahren Sie, was hexagonale Architektur ist, warum sie wichtig ist und wie Sie sie implementieren. Ein vollstandiger Leitfaden mit realen Beispielen fur wartbare, testbare Software.
Hexagonale Architektur ist ein Software-Design-Pattern, das Ihre zentrale Geschaftslogik von externen Systemen wie Datenbanken, APIs und Benutzeroberflachen isoliert. Die Idee ist einfach: Ihr Domain-Code sollte niemals von Infrastruktur abhangen. Infrastruktur hangt von der Domain ab.
Das Pattern wurde 2005 von Alistair Cockburn eingefuhrt. Sie werden es moglicherweise auch als “Ports and Adapters” horen. Beide Namen beschreiben dasselbe Konzept.
Warum es existiert
Die meisten Anwendungen beginnen gleich. Geschaftslogik wird mit Datenbankabfragen, HTTP-Handlern und Drittanbieter-Service-Aufrufen verwoben. Das funktioniert anfangs. Dann wachst die Codebasis, und drei Dinge passieren:
- Testen wird schmerzhaft. Sie konnen Geschaftsregeln nicht testen, ohne eine Datenbank hochzufahren oder die Halfte des Frameworks zu mocken.
- Infrastruktur zu andern ist riskant. Einen Zahlungsanbieter zu wechseln oder von REST zu GraphQL zu migrieren bedeutet, Geschaftslogik umzuschreiben.
- Der Code ist schwer nachzuvollziehen. Niemand kann erkennen, wo die Domain endet und die Infrastruktur beginnt.
Hexagonale Architektur lost alle drei Probleme, indem sie eine einzige Regel durchsetzt: Abhangigkeiten zeigen immer nach innen.
Die Kernkonzepte
Stellen Sie sich Ihre Anwendung als drei konzentrische Schichten vor.
Die Domain (Zentrum)
Das ist Ihre Geschaftslogik. Reine Funktionen, Entities, Value Objects und Domain Services. Sie hat null Abhangigkeiten von Frameworks, Datenbanken oder externen Libraries. Sie spricht ihre eigene Sprache.
Fur ein E-Commerce-System enthalt diese Schicht Konzepte wie Order, Product, PricingRule und InventoryPolicy. Diese Objekte wissen nichts uber SQL, HTTP oder JSON.
Ports (Mittlere Schicht)
Ports sind Interfaces, die definieren, wie die Aussenwelt mit der Domain interagiert. Sie sind Vertrage, keine Implementierungen.
Es gibt zwei Typen:
- Inbound Ports (treibend). Definieren, was die Anwendung tun kann. Denken Sie an Use Cases. “Eine Bestellung aufgeben.” “Ein Abonnement kundigen.” “Eine Rechnung erstellen.”
- Outbound Ports (getrieben). Definieren, was die Anwendung von der Aussenwelt braucht. “Eine Bestellung speichern.” “Eine Benachrichtigung senden.” “Den Wechselkurs abrufen.”
Ports sind Teil der Domain-Schicht. Sie sind in Domain-Sprache geschrieben, nicht in Infrastruktur-Sprache. Sie schreiben OrderRepository, nicht PostgresOrderDAO.
Adapter (Aussere Schicht)
Adapter sind die konkreten Implementierungen, die die reale Welt mit Ihren Ports verbinden.
- Inbound Adapter ubersetzen externe Anfragen in Domain-Aufrufe. Ein REST-Controller, ein GraphQL-Resolver, ein CLI-Befehl, ein Message-Queue-Consumer. All das sind Inbound Adapter.
- Outbound Adapter implementieren die Outbound-Port-Interfaces. Ein PostgreSQL-Repository, ein SMTP-E-Mail-Sender, ein Stripe-Zahlungsgateway. All das sind Outbound Adapter.
Die zentrale Erkenntnis: Adapter hangen von Ports ab. Ports hangen niemals von Adaptern ab. Das ist es, was das gesamte Pattern funktionieren lasst.
Ein konkretes Beispiel
Nehmen wir an, Sie bauen ein Feature zur Bestellungsaufgabe. So verteilen sich die Schichten.
Domain (Entities und Regeln):
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Inbound Port (Use-Case-Interface):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Outbound Ports (was der Use Case braucht):
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Application Service (implementiert den Inbound Port):
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adapter (Infrastruktur):
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Beachten Sie, dass PlaceOrderService niemals PostgreSQL, HTTP oder irgendein Framework erwahnt. Er arbeitet ausschliesslich mit Domain-Konzepten und Port-Interfaces.
Die Abhangigkeitsregel
Das ist die wichtigste Regel. Wenn Sie aus diesem Artikel nur eines mitnehmen, dann dieses:
Quellcode-Abhangigkeiten mussen immer nach innen zeigen, zur Domain hin.
- Adapter hangen von Ports ab. Niemals umgekehrt.
- Ports hangen von Domain-Entities ab. Niemals von Adaptern.
- Die Domain hangt von nichts Externem ab.
Dies wird durch Dependency Injection durchgesetzt. Ihr Composition Root (der Einstiegspunkt der Anwendung) verdrahtet alles:
// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
Die Domain-Schicht importiert niemals aus der Adapter-Schicht. Wenn Sie einen Import von adapters/ innerhalb von domain/ oder ports/ sehen, stimmt etwas nicht.
Projektstruktur
Eine saubere Ordnerstruktur macht die Grenzen sichtbar:
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Jeder Entwickler im Team kann sich diesen Baum ansehen und verstehen, wo neuer Code hingehort.
Testvorteile
Hier zahlt sich hexagonale Architektur wirklich aus.
Unit-Testing der Domain:
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Keine Datenbank. Kein HTTP-Server. Kein Mocking-Framework. Reine Logik, reine Tests.
Testen von Use Cases mit Fake-Adaptern:
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Sie testen den gesamten Use Case, ohne echte Infrastruktur zu beruhren. Die Fake-Adapter implementieren dieselben Port-Interfaces und sind somit austauschbar mit den echten.
Integrationstests nur fur Adapter:
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Jeder Adapter bekommt seinen eigenen Integrationstest. Wenn der Adapter seinen Test besteht und der Use Case mit Fake-Adaptern besteht, funktioniert das gesamte System.
Wann hexagonale Architektur verwenden
Hexagonale Architektur fugt Struktur und Indirektion hinzu. Das hat Kosten. Hier lohnt es sich:
- Langlebige Anwendungen. Wenn das Projekt uber Jahre gewartet wird, zahlt sich die Investition schnell aus.
- Komplexe Geschaftsregeln. Wenn Ihre Domain nicht-triviale Logik hat, die grundlich getestet werden muss.
- Mehrere Einstiegspunkte. Wenn dieselbe Logik von einer Web-API, einer CLI, einer Message Queue und einem Cron-Job aufgerufen wird.
- Infrastruktur konnte sich andern. Wenn Sie nicht sicher sind, ob Sie bei Ihrer aktuellen Datenbank, Ihrem Cloud-Anbieter oder Ihren Drittanbieter-Services bleiben.
- Mehrere Entwickler. Klare Grenzen reduzieren Merge-Konflikte und beschleunigen Code-Reviews.
Wann darauf verzichten
Nicht jedes Projekt braucht dieses Mass an Struktur:
- Einfache CRUD-Anwendungen. Wenn die App hauptsachlich Daten liest und schreibt mit minimaler Geschaftslogik, fugt die Indirektion Kosten ohne Nutzen hinzu.
- Prototypen und MVPs. Geschwindigkeit zahlt mehr als Architektur, wenn Sie eine Idee validieren. Refaktorieren Sie spater.
- Kleine Scripts und Tools. Ein 200-Zeilen-CLI-Tool braucht keine Ports und Adapter.
Das richtige Mass an Architektur hangt von der Komplexitat des Problems ab. Starten Sie einfach. Fuhren Sie hexagonale Grenzen ein, wenn der Schmerz, sie nicht zu haben, real wird.
Haufige Fehler
Infrastruktur in die Domain lecken lassen. Wenn Ihr Order-Entity einen @Column-Decorator oder eine toJSON()-Methode hat, ist Infrastruktur eingedrungen. Halten Sie Ihre Domain-Objekte sauber.
Ports erstellen, die Infrastruktur spiegeln. Ein Port namens SqlQuery verfehlt den Zweck. Ports sollten beschreiben, was die Domain braucht, in Domain-Sprache, nicht wie die Infrastruktur funktioniert.
Uber-Abstrahieren. Nicht alles braucht einen Port. Wenn Sie eine Utility-Funktion haben, die Daten formatiert, nutzen Sie sie direkt. Reservieren Sie Ports fur tatsachliche Infrastruktur-Grenzen.
Den Composition Root uberspringen. Ohne einen klaren Ort, an dem Abhangigkeiten verdrahtet werden, bricht das Pattern zusammen. Jede Abhangigkeit sollte injiziert werden, nicht direkt importiert.
Hexagonal vs. andere Patterns
Sie fragen sich vielleicht, wie hexagonale Architektur zu anderen bekannten Patterns steht:
- Clean Architecture (Robert C. Martin): Sehr ahnlich. Clean Architecture fugt mehr Schichten hinzu (Entities, Use Cases, Interface Adapter, Frameworks), aber die Kernidee ist dieselbe: Abhangigkeiten zeigen nach innen.
- Onion Architecture (Jeffrey Palermo): Ebenfalls sehr ahnlich. Onion Architecture verwendet konzentrische Ringe mit der Domain im Zentrum. Die Terminologie unterscheidet sich, aber die Abhangigkeitsregel ist identisch.
- Layered Architecture (N-Tier): Der traditionelle Ansatz, bei dem Schichten vertikal gestapelt werden (Prasentation, Geschaftslogik, Daten). Der entscheidende Unterschied: In der Layered Architecture hangt die Geschaftsschicht typischerweise von der Datenschicht ab. In der hexagonalen Architektur wird diese Abhangigkeit umgekehrt.
Alle drei modernen Patterns (hexagonal, clean, onion) teilen dasselbe fundamentale Prinzip. Die Domain hangt nicht von der Infrastruktur ab. Sie unterscheiden sich hauptsachlich in Namenskonventionen und der Anzahl der vorgeschriebenen Schichten.
Erste Schritte
Wenn Sie hexagonale Architektur auf ein bestehendes Projekt anwenden mochten, versuchen Sie nicht, alles auf einmal umzubauen. Beginnen Sie mit einem Bounded Context oder einem Feature:
- Identifizieren Sie die zentrale Geschaftslogik in diesem Feature. Extrahieren Sie sie in reine Funktionen oder Klassen ohne Framework-Abhangigkeiten.
- Definieren Sie Ports fur die externen Abhangigkeiten, die dieses Feature nutzt. Erstellen Sie Interfaces in Domain-Sprache.
- Verschieben Sie den bestehenden Infrastruktur-Code in Adapter-Klassen, die diese Interfaces implementieren.
- Verdrahten Sie alles in einem Composition Root mit Dependency Injection.
- Schreiben Sie Tests fur die Domain und Use Cases mit Fake-Adaptern.
Wiederholen Sie das fur das nachste Feature. Mit der Zeit breiten sich die hexagonalen Grenzen naturlich uber die Codebasis aus.
Abschliessende Gedanken
Hexagonale Architektur geht nicht darum, ein starres Template zu befolgen. Es geht um eine einfache Idee: Schutzen Sie Ihre Geschaftslogik vor dem Chaos der Aussenwelt. Datenbanken andern sich. Frameworks gehen aus der Mode. APIs werden abgekundigt. Ihre Geschaftsregeln sollten all das uberleben.
Das Pattern funktioniert, weil es sich daran orientiert, wie Software sich tatsachlich entwickelt. Anforderungen andern sich standig. Infrastruktur andert sich periodisch. Aber die Kernregeln Ihres Geschafts sind tendenziell der stabilste Teil des Systems. Bauen Sie um diese Stabilitat herum.
Wenn Sie ein Produkt bauen, das halten soll, ist hexagonale Architektur eine der besten Investitionen, die Sie in Ihre Codebasis machen konnen.
Brauchen Sie Hilfe bei der Gestaltung oder Refaktorierung der Architektur Ihrer Anwendung? Kontaktieren Sie uns. Wir helfen Teams, Software zu bauen, die wartbar bleibt, wahrend sie wachst.