Hexagonale Architektur ist mehr als ein Buzzword: Sie trennt klar zwischen Anwendungskern und Infrastruktur. Das Ergebnis? Schnellerer Builds, einfachere Tests und Code, der auch nach Jahren noch verständlich bleibt. Doch wie setzt man das konkret um? Dieser Leitfaden zeigt es anhand eines Laravel-Beispiels – von der monolithischen Controller-Logik bis zur sauberen Port-Adapter-Struktur.
Warum monolithische Controller die CI-Zeit in die Höhe treiben
Ein typischer Laravel-Controller vor der Migration sah oft so aus: Er führte mehrere Aufgaben parallel aus – Datenbankoperationen, Benachrichtigungen, Analytics und externe Synchronisation. Im folgenden Beispiel wird eine Bestellung erstellt, eine E-Mail versendet, ein Analytics-Event geloggt und optional eine CRM-Synchronisation angestoßen. Jeder dieser Schritte ist fest mit dem Controller verknüpft:
public function store(StoreOrderRequest $request) {
$order = Order::create($request->validated());
Mail::to($order->user->email)->send(new OrderCreated($order));
Statsig::logEvent($request->user(), 'order_created', [
'order_id' => $order->id,
]);
if (config('zoho.sync.enabled')) {
SyncOrderJob::dispatch($order->id);
}
return redirect()->route('orders.index');
}Das Problem: Jeder Test dieser Methode muss entweder die gesamte Infrastruktur booten oder mit Test-Doubles arbeiten. Beides wird schnell unübersichtlich. Die CI-Pipeline wird langsamer, weil jede Änderung an der Infrastruktur neue Tests erfordert. Die Lösung? Die Verantwortlichkeiten klar trennen – und das beginnt mit der Definition von Ports.
Ports: Die Sprache der Anwendung definieren
Ports sind Schnittstellen, die beschreiben, was die Anwendung braucht – nicht, wie sie es bekommt. Statt zu sagen "sende eine E-Mail", definiert der Port eine abstrakte Methode wie notifyOrderCreated(). Diese Abstraktion ermöglicht es, die Implementierung später auszutauschen, ohne den Anwendungscode anpassen zu müssen.
Im Beispiel wurden folgende Ports eingeführt:
- `ForStoringOrders`: Verantwortlich für das Erstellen und Aktualisieren von Bestellungen.
- `ForFindingOrders`: Bietet Lesezugriff auf Bestellungen, aufgeteilt in
find()undfindByUserId(). - `ForSendingOrderNotifications`: Benachrichtigt Benutzer über Bestellereignisse.
- `ForLoggingAnalyticsEvents`: Loggt Analytics-Events.
- `ForSyncingOrdersWithCrm`: Stößt die CRM-Synchronisation an.
Ein entscheidender Vorteil: Die Ports sind technologieneutral. Der Port ForSyncingOrdersWithCrm erwähnt weder Zoho noch ein anderes CRM. Die Entscheidung, welches CRM genutzt wird, liegt allein im Verantwortungsbereich des Adapters. Das macht den Code zukunftssicher – ein Wechsel des CRM erfordert nur eine Anpassung des Adapters, nicht des Anwendungskerns.
Action-Klassen: Der Orchestrator ohne Infrastruktur-Know-how
Die eigentliche Logik wandert in eine Action-Klasse, die nur von Ports abhängt. Diese Klasse kennt weder Eloquent noch Mailer noch SDKs – sie arbeitet ausschließlich mit den definierten Schnittstellen. Der folgende Code zeigt, wie die Bestellungserstellung nun aussieht:
final class CreateOrderAction {
public function __construct(
private ForStoringOrders $orders,
private ForSendingOrderNotifications $notifications,
private ForLoggingAnalyticsEvents $analytics,
private ForSyncingOrdersWithCrm $crm,
) {}
public function execute(CreateOrderInput $input): Order {
$order = $this->orders->create($input->toAttributes());
$this->notifications->notifyOrderCreated($input->user, $order);
$this->analytics->logEvent($input->user, 'order_created', [
'order_id' => $order->id,
]);
$this->crm->queueSync($order->id);
return $order;
}
}Der Controller selbst wird zur schlanken Vermittlerrolle degradiert:
public function store(StoreOrderRequest $request, CreateOrderAction $action) {
$order = $action->execute(CreateOrderInput::fromRequest($request));
return redirect()->route('orders.index');
}Ein entscheidender Unterschied: Die Konfiguration für die CRM-Synchronisation (if (config('zoho.sync.enabled'))) ist verschwunden. Die Entscheidung, ob synchronisiert wird, liegt nun im Adapter. Die Action ruft queueSync() immer auf – denn aus Anwendungssicht soll die Bestellung synchronisiert werden. Ob das tatsächlich passiert, ist eine Frage der Infrastruktur.
Adapter: Infrastruktur-Implementierungen mit klarem Fokus
Adapter sind die technischen Brücken zwischen Ports und der realen Welt. Sie leben außerhalb des Anwendungskerns und dürfen alles nutzen, was die jeweilige Technologie bietet – Eloquent, Mailer, externe APIs oder Konfigurationen. Hier zwei Beispiele:
Eloquent-Adapter für Bestellungen:
namespace App\Infrastructure\Persistence\Eloquent\Order;
final class EloquentOrders implements ForFindingOrders, ForStoringOrders {
public function find(int $id): ?Order {
return Order::find($id);
}
public function findByUserId(int $userId): Collection {
return Order::where('user_id', $userId)->get();
}
public function create(array $attributes): Order {
return Order::create($attributes);
}
public function update(int $id, array $attributes): Order {
$order = Order::findOrFail($id);
$order->update($attributes);
return $order;
}
}Mailer-Adapter für Benachrichtigungen:
namespace App\Infrastructure\Notifications;
final class MailerOrderNotifications implements ForSendingOrderNotifications {
public function __construct(private Mailer $mailer) {}
public function notifyOrderCreated(User $user, Order $order): void {
$this->mailer->to($user->email)
->send(new OrderCreated($order));
}
}Diese Adapter sind austauschbar. Möchte man statt E-Mails Slack-Benachrichtigungen versenden, muss nur der entsprechende Adapter angepasst werden – der Anwendungscode bleibt unverändert.
Tests ohne Datenbank: Schnelle und zuverlässige Unit-Tests
Der größte Vorteil der hexagonalen Architektur zeigt sich in den Tests. Da die Anwendung nur von Ports abhängt, können Unit-Tests diese Ports mit einfachen Mocks ersetzen. Eine Bestellung kann ohne Datenbank erstellt, Benachrichtigungen können simuliert und Analytics-Events gezählt werden – alles in Millisekunden.
Ein Beispiel für einen Test der CreateOrderAction:
public function test_it_creates_an_order_and_notifies_user() {
$mockOrders = $this->createMock(ForStoringOrders::class);
$mockNotifications = $this->createMock(ForSendingOrderNotifications::class);
$mockAnalytics = $this->createMock(ForLoggingAnalyticsEvents::class);
$mockCrm = $this->createMock(ForSyncingOrdersWithCrm::class);
$action = new CreateOrderAction(
$mockOrders,
$mockNotifications,
$mockAnalytics,
$mockCrm
);
$input = new CreateOrderInput(/* ... */);
$order = new Order(/* ... */);
$mockOrders->method('create')->willReturn($order);
$mockNotifications->expects($this->once())->method('notifyOrderCreated');
$result = $action->execute($input);
$this->assertEquals($order, $result);
}Diese Tests laufen in Sekunden und decken die Kernlogik ab, ohne von externen Abhängigkeiten blockiert zu werden. Integrationstests können sich dann auf die Adapter konzentrieren – die deutlich seltener geändert werden.
Fazit: Investition mit langfristigem ROI
Die Umstellung auf eine hexagonale Architektur erfordert zunächst mehr Boilerplate-Code und Disziplin. Doch der Aufwand lohnt sich:
- Schnellere CI-Pipelines: Keine Notwendigkeit mehr, die gesamte Infrastruktur für jeden Test zu booten.
- Einfachere Wartung: Änderungen an der Infrastruktur (z. B. ein CRM-Wechsel) betreffen nur noch den Adapter.
- Bessere Testbarkeit: Unit-Tests laufen in Millisekunden, Integrationstests bleiben überschaubar.
- Zukunftssicherheit: Neue Technologien können ohne Refactoring des Anwendungskerns integriert werden.
Wer ähnliche Probleme wie langsame CI-Zeiten oder schwer wartbaren Code hat, sollte diesen Ansatz ernsthaft in Betracht ziehen. Die initiale Hürde ist überschaubar – der langfristige Nutzen jedoch enorm.
KI-Zusammenfassung
Learn how to decouple business logic from infrastructure using hexagonal architecture, ports, and adapters to slash CI times and simplify testing.