Hexagonale Architektur
Die hexagonale Architektur (Ports and Adapters) 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.
Verwendungszweck
- Entwicklung neuer Funktionen, bei denen langfristige Wartbarkeit und Testbarkeit wichtig sind.
- Refactoring von geschichteten oder framework-lastigen Codebasen, in denen Domänenlogik mit I/O-Belangen vermischt ist.
- Unterstützung mehrerer Schnittstellen für denselben Anwendungsfall (HTTP, CLI, Queue-Worker, Cron-Jobs).
- Austausch von Infrastruktur (Datenbank, externe APIs, Message Bus), ohne Geschäftsregeln neu schreiben zu müssen.
Verwenden Sie diese Fähigkeit, wenn es um Grenzen, domainzentriertes Design, Refactoring eng gekoppelter Dienste oder die Entkopplung der Anwendungslogik von bestimmten Bibliotheken geht.
Kernkonzepte
- Domänenmodell: Geschäftsregeln und Entitäten/Wertobjekte. Keine Framework-Imports.
- Anwendungsfälle (Anwendungsschicht): Orchestrieren von Domänenverhalten und Workflow-Schritten.
- 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).
- Composition Root: Ein einziger Ort, an dem konkrete Adapter an Anwendungsfälle gebunden werden.
Ausgehende Port-Schnittstellen liegen normalerweise in der Anwendungsschicht (oder nur in der Domäne, wenn die Abstraktion wirklich domänenspezifisch 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äneninterne Abstraktionen (keine Framework- oder Infrastrukturabhängigkeiten)
- Domäne -> nichts Externes
Funktionsweise
Schritt 1: Modellieren einer Use-Case-Grenze
Definieren Sie einen einzelnen Anwendungsfall mit einem klaren Eingabe- und Ausgabe-DTO. Halten Sie Transportdetails (Express req, GraphQL context, Job-Payload-Wrapper) außerhalb dieser Grenze.
Schritt 2: Ausgehende Ports zuerst definieren
Identifizieren Sie jeden Seiteneffekt als einen Port:
- Persistenz (
UserRepositoryPort) - Externe Aufrufe (
BillingGatewayPort) - Querschnittliche Aspekte (
LoggerPort,ClockPort)
Ports sollten Fähigkeiten modellieren, nicht Technologien.
Schritt 3: Anwendungsfall mit reiner Orchestrierung implementieren
Die Use-Case-Klasse/-Funktion erhält Ports über Konstruktor/Argumente. Sie validiert anwendungsspezifische Invarianten, koordiniert Domänenregeln und gibt einfache Datenstrukturen zurück.
Schritt 4: Adapter an den Rändern bauen
- Eingehender Adapter wandelt Protokolleingabe in Use-Case-Eingabe um.
- Ausgehender Adapter bildet Anwendungsverträge auf konkrete APIs/ORM/Query-Builder ab.
- Das Mapping bleibt in Adaptern, nicht innerhalb von Anwendungsfällen.
Schritt 5: Alles in einem Composition Root verdrahten
Instanziieren Sie Adapter und injizieren Sie sie dann in Anwendungsfälle. Halten Sie diese Verdrahtung zentralisiert, um verstecktes Service-Locator-Verhalten zu vermeiden.
Schritt 6: Pro Grenze testen
- Komponententests für Anwendungsfälle mit gefälschten Ports.
- Integrationstests für Adapter mit echten Infrastrukturabhängigkeiten.
- End-to-End-Tests für benutzerseitige Abläufe durch eingehende Adapter.
Architekturdiagramm
flowchart LR
Client["Client (HTTP/CLI/Worker)"] --> InboundAdapter["Eingehender Adapter"]
InboundAdapter -->|"ruft auf"| UseCase["Anwendungsfall (Anwendungsschicht)"]
UseCase -->|"verwendet"| OutboundPort["Ausgehender Port (Schnittstelle)"]
OutboundAdapter["Ausgehender Adapter"] -->|"implementiert"| OutboundPort
OutboundAdapter --> ExternalSystem["DB/API/Queue"]
UseCase --> DomainModel["Domänenmodell"]
Vorgeschlagene Modulstruktur
Verwenden Sie feature-orientierte 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 }>;
}
Anwendungsfall
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; sie mutiert nicht an Ort und Stelle.
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;
}
}
Composition Root
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übergreifendes Mapping
Verwenden Sie dieselben Grenzregeln in allen Ökosystemen; nur Syntax und Verdrahtungsstil ändern sich.
- TypeScript/JavaScript
- Ports:
application/ports/*als Schnittstellen/Typen. - Anwendungsfälle: Klassen/Funktionen mit Konstruktor-/Argument-Injektion.
- Adapter:
adapters/inbound/*,adapters/outbound/*. - Komposition: explizite Factory-/Container-Module (keine versteckten globalen Variablen).
- Ports:
- Java
- Pakete:
domain,application.port.in,application.port.out,application.usecase,adapter.in,adapter.out. - Ports: Schnittstellen in
application.port.*. - Anwendungsfälle: einfache Klassen (Spring
@Serviceist optional, nicht erforderlich). - Komposition: Spring-Konfiguration oder manuelle Verdrahtungsklasse; Verdrahtung außerhalb von Domänen-/Use-Case-Klassen halten.
- Pakete:
- Kotlin
- Module/Pakete spiegeln die Java-Aufteilung wider (
domain,application.port,application.usecase,adapter). - Ports: Kotlin-Schnittstellen.
- Anwendungsfälle: Klassen mit Konstruktor-Injektion (Koin/Dagger/Spring/manuell).
- Komposition: Moduldefinitionen oder dedizierte Kompositionsfunktionen; Service-Locator-Muster vermeiden.
- Module/Pakete spiegeln die Java-Aufteilung wider (
- Go
- Pakete:
internal/<feature>/domain,application,ports,adapters/inbound,adapters/outbound. - Ports: kleine Schnittstellen, die dem konsumierenden Anwendungspaket gehören.
- Anwendungsfälle: Structs mit Schnittstellenfeldern und expliziten
New...-Konstruktoren. - Komposition: Verdrahtung in
cmd/<app>/main.go(oder dediziertes Verdrahtungspaket), Konstruktoren explizit halten.
- Pakete:
Anti-Patterns, die vermieden werden sollten
- Domänenentitäten importieren ORM-Modelle, Web-Framework-Typen oder SDK-Clients.
- Anwendungsfälle lesen direkt von
req,resoder Queue-Metadaten. - Rückgabe von Datenbankzeilen direkt aus Anwendungsfällen ohne Domänen-/Anwendungsmapping.
- Adapter rufen einander direkt auf, anstatt über Use-Case-Ports zu fließen.
- Abhängigkeitsverdrahtung über viele Dateien mit versteckten globalen Singletons verteilen.
Migrations-Playbook
- Wählen Sie einen vertikalen Ausschnitt (einzelner Endpunkt/Job) mit häufigem Änderungsaufwand.
- Extrahieren Sie eine Use-Case-Grenze mit expliziten Eingabe-/Ausgabetypen.
- Führen Sie ausgehende Ports um vorhandene Infrastrukturaufrufe ein.
- Verschieben Sie Orchestrierungslogik von Controllern/Diensten in den Anwendungsfall.
- Behalten Sie alte Adapter, lassen Sie sie jedoch an den neuen Anwendungsfall delegieren.
- Fügen Sie Tests um die neue Grenze hinzu (Komponente + Adapterintegration).
- Wiederholen Sie den Vorgang ausschnittsweise; vermeiden Sie vollständige Neuschreibungen.
Refactoring bestehender Systeme
- Strangler-Ansatz: Behalten Sie aktuelle Endpunkte bei, leiten Sie einen Anwendungsfall nach dem anderen durch neue Ports/Adapter.
- Keine Big-Bang-Neuschreibungen: Migrieren Sie pro Feature-Ausschnitt und bewahren Sie das Verhalten mit Charakterisierungstests.
- Fassade zuerst: Wickeln Sie Legacy-Dienste hinter ausgehenden Ports ein, bevor Sie Interna ersetzen.
- Kompositionsstopp: Zentralisieren Sie die Verdrahtung frühzeitig, damit neue Abhängigkeiten nicht in Domänen-/Use-Case-Schichten eindringen.
- Auswahlregel für Ausschnitte: Priorisieren Sie zuerst Abläufe mit hoher Änderungsrate und geringem Schadensradius.
- Rückfallpfad: Halten Sie einen umkehrbaren Schalter oder Routenwechsel pro migriertem Ausschnitt bereit, bis das Produktionsverhalten verifiziert ist.
Testrichtlinien (gleiche hexagonale Grenzen)
- Domänentests: Testen Sie Entitäten/Wertobjekte als reine Geschäftsregeln (keine Mocks, kein Framework-Setup).
- Use-Case-Komponententests: Testen Sie die Orchestrierung mit Fakes/Stubs für ausgehende Ports; überprüfen Sie Geschäftsergebnisse und Port-Interaktionen.
- Ausgehende Adapter-Vertragstests: Definieren Sie gemeinsame Vertragssuiten auf Port-Ebene und führen Sie diese gegen jede Adapterimplementierung aus.
- Eingehende Adaptertests: Überprüfen Sie das Protokoll-Mapping (HTTP/CLI/Queue-Payload zu Use-Case-Eingabe und Ausgabe-/Fehler-Mapping zurück zum Protokoll).
- Adapter-Integrationstests: Testen Sie gegen reale Infrastruktur (DB/API/Queue) für Serialisierung, Schema-/Query-Verhalten, Wiederholungen und Timeouts.
- End-to-End-Tests: Decken Sie kritische Benutzerreisen durch eingehenden Adapter -> Anwendungsfall -> ausgehenden Adapter ab.
- Refactoring-Sicherheit: Fügen Sie Charakterisierungstests vor der Extraktion hinzu; behalten Sie diese bei, bis das neue Grenzverhalten stabil und gleichwertig ist.
Best Practices-Checkliste
- Domänen- und Anwendungsschichten importieren nur interne Typen und Ports.
- Jede externe Abhängigkeit wird durch einen ausgehenden Port repräsentiert.
- Validierung erfolgt an den Grenzen (eingehender Adapter + Use-Case-Invarianten).
- Verwenden Sie unveränderliche Transformationen (geben Sie neue Werte/Entitäten zurück, anstatt gemeinsamen Zustand zu mutieren).
- Fehler werden über Grenzen hinweg übersetzt (Infrastrukturfehler -> Anwendungs-/Domänenfehler).
- Der Composition Root ist explizit und leicht zu überprüfen.
- Anwendungsfälle sind mit einfachen In-Memory-Fakes für Ports testbar.
- Refactoring beginnt mit einem vertikalen Ausschnitt und verhaltenserhaltenden Tests.
- Sprach-/Framework-Spezifika bleiben in Adaptern, niemals in Domänenregeln.