Warum hexagonale Architektur nicht nur ein modischer Begriff ist, sondern eine Rettung für Ihren Code?
Stellen Sie sich vor, Sie bauen ein Haus. Sie mauern doch keine Steckdosen in Betonwände ein? Sie verlegen zuerst die Verkabelung, machen die Markierungen und schließen dann sorgfältig die Steckdosen an. In der Softwareentwicklung ist es genauso – Ihre Geschäftslogik (der Kern der Anwendung) sollte nicht davon abhängen, wie mit ihr kommuniziert wird: REST API, Datenbank oder Nachrichtenwarteschlangen.
Die hexagonale Architektur (auch bekannt als „Ports und Adapter“ oder „saubere Architektur“ in der Interpretation von Alistair Cockburn) löst genau dieses Problem. In diesem Artikel werden wir uns ansehen, wie man diese Architektur in TypeScript mit echten Codebeispielen implementiert. Sie erfahren, wie Sie die Geschäftslogik von der Infrastruktur trennen, den Code testbar und flexibel machen.
Was ist hexagonale Architektur in einfachen Worten?
Stellen Sie sich ein Sechseck (Hexagon) vor. In der Mitte befindet sich Ihre Anwendung mit der Geschäftslogik. Auf jeder Seite befindet sich ein „Port“ (Schnittstelle), über den die Außenwelt mit dem Kern kommunizieren kann. Adapter sind konkrete Implementierungen: Controller, Repositories, Clients externer Dienste. Sie werden an die Ports angeschlossen, wie ein Stecker in eine Steckdose.
Das Hauptprinzip: Abhängigkeiten sind nach innen gerichtet. Externer Code (Datenbanken, Frameworks) hängt vom Kern ab, nicht umgekehrt. Dies ermöglicht es, die Datenbank von PostgreSQL auf MongoDB zu ändern, ohne eine einzige Zeile in der Geschäftslogik zu ändern.
Grundlegende Konzepte
- Kern (Domain) – Geschäftslogik, Entitäten, Value Objects, Use Cases. Hat keine externen Abhängigkeiten.
- Port – Schnittstelle (in TypeScript eine abstrakte Klasse oder ein Interface), die einen Vertrag für die Außenwelt definiert.
- Adapter – konkrete Implementierung eines Ports. Zum Beispiel ein Repository, das mit PostgreSQL über TypeORM arbeitet.
- Application – Schicht, die die Ausführung von Use Cases koordiniert.
Praktisches Beispiel: Auftragsverwaltungssystem
Lassen Sie uns ein einfaches Auftragssystem entwerfen. Wir haben die Entitäten Order und Product, den Use Case CreateOrder, einen Port für das Repository und einen Adapter für PostgreSQL.
Schritt 1. Definieren des Kerns (Domain Layer)
Erstellen wir einen Ordner domain/. Hier leben die Entitäten und Schnittstellen für die Ports. Keine Abhängigkeiten von Frameworks!
// domain/order.entity.tsexport class Order { constructor( public readonly id: string, public items: OrderItem[], public status: 'pending' | 'confirmed' | 'shipped', public total: number ) {}
public addItem(productId: string, quantity: number, price: number): void { this.items.push({ productId, quantity, price }); this.total = this.calculateTotal(); }
private calculateTotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); }}
export interface OrderItem { productId: string; quantity: number; price: number;}Jetzt definieren wir den Port für das Repository:
// domain/ports/order.repository.interface.tsimport { Order } from '../order.entity';
export interface IOrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>; findAll(): Promise<Order[]>;}Schritt 2. Use Case – das Herz der Anwendung
Ein Use Case ist ein Anwendungsszenario. Er enthält die Geschäftslogik und verwendet Ports, weiß aber nicht, wer sie implementiert.
// application/use-cases/create-order.use-case.tsimport { Order } from '../../domain/order.entity';import { IOrderRepository } from '../../domain/ports/order.repository.interface';
export class CreateOrderUseCase { constructor(private readonly orderRepository: IOrderRepository) {}
async execute(use