Was macht Hexagonale Architektur?
Hexagonale Architektur (Ports und Adapter) hält die Geschäftslogik unabhängig von Frameworks, Transport- und Persistenzdetails. Die Kernanwendung hängt von abstrakten Ports ab, und Adapter implementieren diese Ports an den Rändern.
Wann verwenden
- Neue Funktionen entwickeln, bei denen langfristige Wartbarkeit und Testbarkeit wichtig sind.
- Refaktorisieren von stark geschichtetem oder framework-lastigem Code, in dem die Domänenlogik mit I/O-Belangen vermischt ist.
- Unterstützen mehrerer Schnittstellen für denselben Anwendungsfall (HTTP, CLI, Queue-Worker, Cron-Jobs).
- Austauschen von Infrastruktur (Datenbank, externe APIs, Message Bus), ohne die Geschäftsregeln neu zu schreiben.
Verwende dieses Skill, wenn die Anfrage Grenzen, domänenzentriertes Design, Refaktorisieren von eng gekoppelten Diensten oder das Entkoppeln der Anwendungslogik von bestimmten Bibliotheken betrifft.
Kernkonzepte
- Domänenmodell: Geschäftsregeln und Entitäten/Wertobjekte. Keine Framework-Importe.
- Use Cases (Anwendungsschicht): Orchestrieren von Domänenverhalten und Arbeitsschritten.
- Eingehende Ports: Verträge, die beschreiben, was die Anwendung tun kann (Befehle/Anfragen/Use-Case-Schnittstellen).
- Ausgehende Ports: Verträge für Abhängigkeiten, die die Anwendung benötigt (Repositories, Gateways, Event-Publisher, Uhr, UUID usw.).
- Adapter: Infrastruktur- und Bereitstellungsimplementierungen von Ports (HTTP-Controller, DB-Repositories, Queue-Consumer, SDK-Wrapper).
- Kompositionswurzel: Einzige Verdrahtungsstelle, an der konkrete Adapter an Use Cases gebunden werden.
Ausgehende Port-Schnittstellen leben normalerweise in der Anwendungsschicht (oder nur in der Domäne, wenn die Abstraktion wirklich auf Domänenebene ist), während Infrastrukturadapter sie implementieren.
Die Abhängigkeitsrichtung ist immer nach innen gerichtet:
- Adapter -> Anwendung/Domäne
- Anwendung -> Port-Schnittstellen (eingehende/ausgehende Verträge)
- Domäne -> domänenexklusive Abstraktionen (keine Framework- oder Infrastrukturabhängigkeiten)
- Domäne -> nichts von außen
Wie es funktioniert
Schritt 1: Eine Use-Case-Grenze modellieren
Definiere einen einzelnen Use Case mit einem klaren Eingabe- und Ausgabe-DTO. Halte Transportdetails (Express req, GraphQL context, Job-Payload-Wrapper) außerhalb dieser Grenze.
Schritt 2: Zuerst ausgehende Ports definieren
Identifiziere jeden Seiteneffekt als Port:
- Persistenz (
UserRepositoryPort) - Externe Aufrufe (
BillingGatewayPort) - Querschnittsfunktionen (
LoggerPort,ClockPort)
Ports sollten Fähigkeiten modellieren, nicht Technologien.
Schritt 3: Den Use Case mit reiner Orchestrierung implementieren
Die Use-Case-Klasse/-Funktion erhält Ports über den Konstruktor/die Argumente. Sie validiert anwendungsbezogene Invarianten, koordiniert Domänenregeln und gibt einfache Datenstrukturen zurück.
Schritt 4: Adapter an den Rändern bauen
- Eingehender Adapter: Konvertiert Protokolleingaben in Use-Case-Eingaben.
- Ausgehender Adapter: Bildet Anwendungsverträge auf konkrete APIs/ORM/Query-Builder ab.
- Das Mapping bleibt in Adaptern, nicht in Use Cases.
Schritt 5: Alles in einer Kompositionswurzel verdrahten
Instanziiere Adapter und injiziere sie in Use Cases. Halte diese Verdrahtung zentralisiert, um verstecktes Service-Locator-Verhalten zu vermeiden.
Schritt 6: Pro Grenze testen
- Unit-Tests für Use Cases mit Fake-Ports.
- Integrationstests für Adapter mit realen Infrastrukturabhängigkeiten.
- E2E-Tests für benutzerseitige Abläufe durch eingehende Adapter.
Architekturdiagramm
flowchart LR
Client["Client (HTTP/CLI/Worker)"] --> InboundAdapter["Eingehender Adapter"]
InboundAdapter -->|"ruft auf"| UseCase["UseCase (Anwendungsschicht)"]
UseCase -->|"verwendet"| OutboundPort["Ausgehender Port (Schnittstelle)"]
OutboundAdapter["Ausgehender Adapter"] -->|"implementiert"| OutboundPort
OutboundAdapter --> ExternalSystem["DB/API/Queue"]
UseCase --> DomainModel["Domänenmodell"]
Vorgeschlagenes Modullayout
Verwende feature-first-Organisation mit expliziten Grenzen:
src/
features/
orders/
domain/
Order.ts
OrderPolicy.ts
application/
ports/
inbound/
CreateOrder.ts
outbound/
OrderRepositoryPort.ts
PaymentGatewayPort.ts
use-cases/
CreateOrderUseCase.ts
adapters/
inbound/
http/
createOrderRoute.ts
outbound/
postgres/
PostgresOrderRepository.ts
stripe/
StripePaymentGateway.ts
composition/
ordersContainer.ts
TypeScript-Beispiel
Port-Definitionen
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
}
Use Case
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
authorizationId: string;
};
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
const auth = await this.paymentGateway.authorize({
orderId: order.id,
amountCents: order.amountCents,
});
// markAuthorized gibt eine neue Order-Instanz zurück; es mutiert nicht die vorhandene.
const authorizedOrder = order.markAuthorized(auth.authorizationId);
await this.orderRepository.save(authorizedOrder);
return {
orderId: order.id,
authorizationId: auth.authorizationId,
};
}
}
Ausgehender Adapter
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: SqlClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
[order.id, order.amountCents, order.status, order.authorizationId]
);
}
async findById(orderId: string): Promise<Order | null> {
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
return row ? Order.rehydrate(row) : null;
}
}
Kompositionswurzel
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
Sprachübergreifende Abbildung
Verwende dieselben Grenzregeln in allen Ökosystemen; nur Syntax und Verdrahtungsstil ändern sich.
- TypeScript/JavaScript
- Ports:
application/ports/*als Schnittstellen/Typen. - Use Cases: Klassen/Funktionen mit Konstruktor-/Argumentinjektion.
- Adapter:
adapters/inbound/*,adapters/outbound/*. - Komposition: explizites Factory-/Container-Modul (keine versteckten Globals).
- Ports:
- Java
- Packages:
domain,application.port.in,application.port.out,application.usecase,adapter.in,adapter.out. - Ports: Schnittstellen in
application.port.*. - Use Cases: einfache Klassen (Spring
@Serviceist optional, nicht erforderlich). - Komposition: Spring-Konfiguration oder manuelle Verdrahtungsklasse; halte die Verdrahtung außerhalb von Domänen-/Use-Case-Klassen.
- Packages:
- Kotlin
- Module/Packages spiegeln die Java-Aufteilung wider (
domain,application.port,application.usecase,adapter). - Ports: Kotlin-Schnittstellen.
- Use Cases: Klassen mit Konstruktorinjektion (Koin/Dagger/Spring/manuell).
- Komposition: Moduldefinitionen oder dedizierte Kompositionsfunktionen; vermeide Service-Locator-Muster.
- Module/Packages spiegeln die Java-Aufteilung wider (
- Go
- Packages:
internal/<feature>/domain,application,ports,adapters/inbound,adapters/outbound. - Ports: kleine Schnittstellen, die dem verbrauchenden Anwendungspackage gehören.
- Use Cases: Structs mit Schnittstellenfeldern und expliziten
New...-Konstruktoren. - Komposition: Verdrahtung in
cmd/<app>/main.go(oder dediziertes Verdrahtungspackage), halte Konstruktoren explizit.
- Packages:
Anti-Patterns vermeiden
- Domänenentitäten importieren ORM-Modelle, Web-Framework-Typen oder SDK-Clients.
- Use Cases lesen direkt von
req,resoder Queue-Metadaten. - Rückgabe von Datenbankzeilen direkt aus Use Cases ohne Domänen-/Anwendungs-Mapping.
- Adapter rufen sich gegenseitig direkt auf, anstatt durch Use-Case-Ports zu fließen.
- Verteilung der Abhängigkeitsverdrahtung auf viele Dateien mit versteckten globalen Singletons.
Migrationshandbuch
- Wähle einen vertikalen Ausschnitt (einzelner Endpunkt/Job) mit häufigen Änderungsschmerzen.
- Extrahiere eine Use-Case-Grenze mit expliziten Ein-/Ausgabetypen.
- Führe ausgehende Ports um bestehende Infrastrukturaufrufe ein.
- Verschiebe Orchestrierungslogik von Controllern/Services in den Use Case.
- Behalte alte Adapter, aber lasse sie an den neuen Use Case delegieren.
- Füge Tests um die neue Grenze hinzu (Unit + Adapterintegration).
- Wiederhole ausschnittsweise; vermeide vollständige Neuschreibungen.
Refaktorisieren bestehender Systeme
- Strangler-Ansatz: Behalte aktuelle Endpunkte bei, leite einen Use Case nach dem anderen durch neue Ports/Adapter.
- Keine Big-Bang-Neuschreibungen: Migriere pro Feature-Ausschnitt und bewahre das Verhalten mit Charakterisierungstests.
- Fassade zuerst: Umwickle Legacy-Services hinter ausgehenden Ports, bevor du Interna ersetzt.
- Kompositionsstillstand: Zentralisiere die Verdrahtung frühzeitig, damit neue Abhängigkeiten nicht in die Domänen-/Use-Case-Schichten gelangen.
- Ausschnittauswahl-Regel: Priorisiere zuerst Abläufe mit hoher Änderungsrate und geringem Schadensradius.
- Rollback-Pfad: Behalte einen umkehrbaren Schalter oder Routenumschalter pro migriertem Ausschnitt bei, bis das Produktionsverhalten verifiziert ist.
Testleitfaden (gleiche hexagonale Grenzen)
- Domänentests: Teste Entitäten/Wertobjekte als reine Geschäftsregeln (keine Mocks, kein Framework-Setup).
- Use-Case-Unit-Tests: Teste Orchestrierung mit Fakes/Stubs für ausgehende Ports; bewerte Geschäftsergebnisse und Port-Interaktionen.
- Ausgehende Adapter-Vertragstests: Definiere gemeinsame Vertragssuiten auf Port-Ebene und führe sie gegen jede Adapterimplementierung aus.
- Eingehende Adapter-Tests: Überprüfe das Protokoll-Mapping (HTTP/CLI/Queue-Payload zu Use-Case-Eingabe und Ausgabe/Fehler-Mapping zurück zum Protokoll).
- Adapter-Integrationstests: Führe gegen reale Infrastruktur (DB/API/Queue) für Serialisierung, Schema/Query-Verhalten, Wiederholungen und Timeouts aus.
- Ende-zu-Ende-Tests: Decke kritische Benutzerreisen durch eingehenden Adapter -> Use Case -> ausgehenden Adapter ab.
- Refaktorsicherheit: Füge Charakterisierungstests vor der Extraktion hinzu; behalte sie bei, bis das neue Grenzverhalten stabil und gleichwertig ist.
Checkliste für bewährte Praktiken
- Domänen- und Use-Case-Schichten importieren nur interne Typen und Ports.
- Jede externe Abhängigkeit wird durch einen ausgehenden Port repräsentiert.
- Validierung erfolgt an Grenzen (eingehender Adapter + Use-Case-Invarianten).
- Verwende unveränderliche Transformationen (gebe neue Werte/Entitäten zurück, anstatt gemeinsamen Zustand zu mutieren).
- Fehler werden über Grenzen hinweg übersetzt (Infra-Fehler -> Anwendungs-/Domänenfehler).
- Kompositionswurzel ist explizit und einfach zu prüfen.
- Use Cases sind mit einfachen In-Memory-Fakes für Ports testbar.
- Refaktorisierung beginnt mit einem vertikalen Ausschnitt und verhaltensbewahrenden Tests.
- Sprach-/Framework-Spezifika bleiben in Adaptern, niemals in Domänenregeln.