in Symfony: Ports und Adapters
Die hexagonale Architektur — auch bekannt als Ports and Adapters — befreit die Domänenlogik von der Kopplung an Frameworks, Datenbanken und externe Services. In Symfony lässt sie sich mit PHP-Interfaces als Ports und Symfony-Services als Adapters präzise umsetzen: vollständig testbarer Domänencode ohne ein einziges Framework-Import in der Kernlogik.
Inhaltsverzeichnis
- 1. Was ist die hexagonale Architektur?
- 2. Die drei Schichten: Domain, Application, Infrastructure
- 3. Ports: PHP-Interfaces als Vertragsdefinition
- 4. Der Domänenkern ohne Framework-Import
- 5. Application Layer: Use Cases und Command Handler
- 6. Adapters: Symfony-Services implementieren Ports
- 7. Dependency Injection: Ports mit Adapters verdrahten
- 8. Testen ohne Framework: Domänenlogik isoliert
- 9. Vergleich: Traditionell vs. Hexagonal in Symfony
- 10. Zusammenfassung
- 11. FAQ
1. Was ist die hexagonale Architektur?
Die hexagonale Architektur, ursprünglich von Alistair Cockburn als "Ports and Adapters" beschrieben, ist ein Architekturmuster, das die Geschäftslogik einer Anwendung von ihrer technischen Umgebung isoliert. Der Kern der Idee: Eine Applikation soll gleichermaßen von einem HTTP-Request, einem CLI-Kommando, einem Test oder einem Message-Queue-Consumer aufgerufen werden können — ohne dass die Geschäftslogik etwas über diesen Aufrufkontext weiß. Gleichzeitig soll die Applikation austauschbare Infrastruktur nutzen können: eine echte Datenbank in der Produktion, eine In-Memory-Implementierung in Tests.
In der hexagonalen Architektur ist das Hexagon die Domäne — der Bereich, in dem Geschäftsregeln gelten. Alles außerhalb des Hexagons ist entweder ein primärer Adapter (der die Domäne aufruft: HTTP, CLI, Tests) oder ein sekundärer Adapter (den die Domäne aufruft: Datenbank, E-Mail-Service, externe APIs). Ports sind die definierten Schnittstellen dieser Verbindungen: PHP-Interfaces, die beschreiben, was gebraucht wird, ohne zu beschreiben, wie es implementiert ist. Das ist das Grundprinzip: Die Domäne definiert Ports (Interfaces), und Adapters implementieren diese Ports außerhalb der Domäne.
Warum ist das in Symfony-Projekten relevant? In einem typischen Symfony-Projekt kennt der Service-Code Doctrine-Entities, HTTP-Request-Objekte und Symfony-spezifische Klassen. Das macht die Domänenlogik schwer testbar — für jeden Test braucht man eine Datenbank, einen HTTP-Request und einen laufenden Symfony-Container. Mit der hexagonalen Architektur kennt die Domänenlogik nichts davon: Sie kennt nur Ports (Interfaces), die in Tests durch In-Memory-Implementierungen ersetzt werden. Das ermöglicht schnelle, datenbankfreie Unit-Tests für die gesamte Geschäftslogik.
2. Die drei Schichten: Domain, Application, Infrastructure
Die hexagonale Architektur strukturiert ein Symfony-Projekt in drei klar getrennten Schichten. Die Domain-Schicht enthält die Geschäftslogik: Entities (keine Doctrine-Entities, sondern reine PHP-Objekte mit Domänenlogik), Value Objects, Domain Services und die Port-Interfaces (Repositories, Mailer, Notification-Sender usw.). Diese Schicht hat keinerlei Abhängigkeit auf Symfony oder Doctrine — sie importiert ausschließlich PHP-Standard-Bibliotheken und ggf. Contracts wie PSR-Interfaces.
Die Application-Schicht orchestriert die Domäne. Sie enthält Use Cases (oder Command Handler bei Verwendung von CQRS/Messenger), die die Domain-Objekte und Port-Interfaces kombinieren, um fachliche Anwendungsfälle abzubilden. Die Application-Schicht kennt die Domain-Schicht vollständig, kennt aber die Infrastruktur-Schicht nicht. Sie ruft Port-Interfaces auf — welcher konkrete Adapter dahintersteckt, ist ihr egal. Das ist das Dependency-Inversion-Prinzip der SOLID-Prinzipien in seiner reinsten Form.
Die Infrastructure-Schicht enthält alle Adapters: Doctrine-Repository-Implementierungen als sekundäre Adapters (sie implementieren die Port-Interfaces der Domain-Schicht), Symfony-Controller als primäre Adapters (sie nehmen HTTP-Requests entgegen und rufen Application-Schicht-Use-Cases auf), Mailer-Adapters, externe-API-Adapters und alle anderen Framework-spezifischen Implementierungen. Die Infrastruktur-Schicht kennt alle anderen Schichten und darf Symfony, Doctrine und alle anderen Bibliotheken importieren. Sie ist die einzige Schicht, die Framework-Kopplung enthält.
3. Ports: PHP-Interfaces als Vertragsdefinition
Ein Port in der hexagonalen Architektur ist ein PHP-Interface, das beschreibt, was die Domäne von ihrer Umgebung braucht oder was sie nach außen anbietet. Ein Repository-Port beschreibt Methoden wie findById(), save() und findByStatus() — ohne zu erwähnen, ob es Doctrine, Redis oder eine REST-API dahintersteckt. Ein Notification-Port beschreibt sendOrderConfirmation() und sendShippingUpdate() — ohne zu wissen, ob das per E-Mail, SMS oder Push-Notification geschieht. Diese Ports sind Teil der Domain-Schicht und sind der einzige Kommunikationsweg zwischen Domäne und Infrastruktur.
In PHP 8.2+ werden Ports von den Typsystem-Features des Sprache unterstützt: readonly Classes für Value Objects, Intersection Types für präzise Typ-Kombinationen und Enums für Domänen-Zustände. Die Port-Interfaces selbst sind einfache PHP-Interfaces — keine besonderen Annotations, keine Doctrine-Attribute, keine Symfony-Attribute. Das ist der Beweis, dass die Domain-Schicht framework-unabhängig ist: Ein Interface mit nur PHP-Standard-Typen braucht keine externe Bibliothek. Das Hinzufügen von PSR-3 für den Logger-Port ist ein akzeptierter Kompromiss — PSR-Interfaces sind Standards, keine Framework-Abhängigkeiten.
<?php
declare(strict_types=1);
// Domain-Layer: Ports defined in the domain — no framework imports
namespace App\Domain\Order\Port;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Model\OrderStatus;
/**
* Repository port — describes what the domain needs for Order persistence.
* No Doctrine, no Symfony — pure PHP interface in the domain layer.
*/
interface OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order;
/** @return Order[] */
public function findByStatus(OrderStatus $status): array;
public function save(Order $order): void;
public function remove(Order $order): void;
}
// ---
namespace App\Domain\Order\Port;
use App\Domain\Order\Model\Order;
/**
* Notification port — describes what the domain needs to notify customers.
* Implementation can be email, SMS, or push — domain does not care.
*/
interface OrderNotificationPort
{
public function sendOrderConfirmation(Order $order): void;
public function sendShippingUpdate(Order $order, string $trackingCode): void;
}
// ---
namespace App\Domain\Order\Model;
/**
* Value Object for Order ID — immutable, validated at construction.
* No Doctrine mapping here — this is pure domain model.
*/
final readonly class OrderId
{
public function __construct(
public readonly string $value,
) {
if (empty($this->value)) {
throw new \InvalidArgumentException('OrderId cannot be empty');
}
}
public static function generate(): self
{
return new self(\Ramsey\Uuid\Uuid::uuid4()->toString());
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
4. Der Domänenkern ohne Framework-Import
Der Domänenkern der hexagonalen Architektur ist die Gesamtheit aller Domain-Schicht-Klassen: Entities, Value Objects, Domain Services, Aggregate Roots und Port-Interfaces. Das entscheidende Merkmal: Kein use Symfony\, kein use Doctrine\ in diesen Klassen. Eine Domänen-Entity ist kein Doctrine-Mapping-Objekt — sie ist ein PHP-Objekt, das Geschäftsregeln durch Methoden ausdrückt. Der Konstruktor wirft eine DomainException, wenn eine Bestellung mit negativem Betrag erstellt wird. Die Methode ship() prüft, ob die Bestellung in einem Zustand ist, aus dem versandt werden darf, und wirft andernfalls eine Domänen-spezifische Exception.
Domain Services in der hexagonalen Architektur kapseln Logik, die nicht natürlich zu einer Entity gehört, aber trotzdem Domänenlogik ist. Ein OrderPricingService berechnet den Gesamtpreis unter Berücksichtigung von Rabatten, Steuern und Versandkosten — er kennt die Preisregeln der Domäne, aber keine Datenbank. Ein InventoryCheckService prüft die Verfügbarkeit von Produkten — aber nur gegen Port-Interfaces, nicht gegen Doctrine direkt. Der Domain Service ruft den Repository-Port auf, und der konkrete Adapter dahinter ist in der Infrastruktur-Schicht implementiert.
Value Objects sind in der hexagonalen Architektur besonders wichtig: Sie ersetzen primitive Typen durch semantisch reichhaltige, validierte Objekte. Statt string $email gibt es ein EmailAddress-Value-Object, das im Konstruktor validiert wird und nur gültige E-Mail-Adressen repräsentiert. In PHP 8.2+ werden Value Objects mit readonly class geschrieben — immutable, explizit und typsicher. Der gesamte Domänenkern wird zu einem System, das ungültige Zustände durch sein Typsystem unmöglich macht.
5. Application Layer: Use Cases und Command Handler
Die Application-Schicht enthält Use Cases — die fachlichen Anwendungsfälle der Applikation. In der hexagonalen Architektur ist ein Use Case eine Klasse, die einen einzigen Anwendungsfall orchestriert: PlaceOrderUseCase, CancelOrderUseCase, ShipOrderUseCase. Jeder Use Case empfängt ein Command (ein einfaches DTO mit den benötigten Eingabedaten), ruft Domain-Objekte und Port-Interfaces auf und koordiniert den Ablauf. Er enthält keine Geschäftsregeln selbst — die liegen in der Domain-Schicht — sondern nur Orchestrierungslogik.
Bei Verwendung von Symfony Messenger wird der Use Case zum Message Handler: Das Command ist eine Messenger-Message, der Use Case implementiert das MessageHandlerInterface. Die Dependency Injection verdrahtet automatisch alle Port-Interfaces mit ihren Infrastruktur-Adaptern. Das Schöne daran: Die Use-Case-Klasse in der Application-Schicht kennt nur die Domain-Port-Interfaces — nicht die konkreten Doctrine-Repositories oder Mailer-Services. Symfony's DI-Container löst diese Abhängigkeiten beim Kompilieren auf.
<?php
declare(strict_types=1);
// Application Layer: Use Case orchestrates domain without knowing infrastructure
namespace App\Application\Order\UseCase;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Port\OrderNotificationPort;
use App\Domain\Order\Port\OrderRepositoryInterface;
use App\Application\Order\Command\PlaceOrderCommand;
/**
* PlaceOrderUseCase — application layer use case.
* Knows Domain (Order, OrderId) and Ports (interfaces).
* Does NOT know Doctrine, Mailer classes, or Symfony internals.
*/
final class PlaceOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // Port — not Doctrine
private readonly OrderNotificationPort $notification, // Port — not MailerInterface
) {}
/**
* Execute the place order use case.
* All infrastructure calls go through ports — swappable in tests.
*/
public function execute(PlaceOrderCommand $command): OrderId
{
// Create domain entity — domain model enforces business rules
$order = Order::place(
id: OrderId::generate(),
customerId: $command->customerId,
items: $command->items,
shippingAddress: $command->shippingAddress,
);
// Persist through port — actual implementation injected by DI
$this->orderRepository->save($order);
// Notify through port — email, SMS, or push depending on adapter
$this->notification->sendOrderConfirmation($order);
return $order->getId();
}
}
// Application command — pure DTO, no framework imports
namespace App\Application\Order\Command;
use App\Domain\Order\Model\CustomerId;
use App\Domain\Order\Model\OrderItem;
use App\Domain\Order\Model\ShippingAddress;
final readonly class PlaceOrderCommand
{
/** @param OrderItem[] $items */
public function __construct(
public readonly CustomerId $customerId,
public readonly array $items,
public readonly ShippingAddress $shippingAddress,
) {}
}
6. Adapters: Symfony-Services implementieren Ports
Adapters sind die Infrastruktur-Schicht der hexagonalen Architektur. Jeder sekundäre Adapter implementiert einen Domain-Port und enthält die konkrete Implementierung: Das Doctrine-Repository implementiert OrderRepositoryInterface und übersetzt zwischen Domänen-Entities und Doctrine-Mapping-Objekten. Der Mailer-Adapter implementiert OrderNotificationPort und nutzt Symfony's MailerInterface. Ein SMS-Adapter würde dieselbe OrderNotificationPort-Interface implementieren und stattdessen eine SMS-API aufrufen.
Der Doctrine-Adapter hat eine besondere Herausforderung: Er muss zwischen der Domänen-Entity (ohne Doctrine-Mapping) und der Doctrine-Mapping-Entity übersetzen. Es gibt zwei gängige Ansätze: Den direkten Ansatz, bei dem die Domänen-Entity als Doctrine-Entity annotiert wird (Kompromiss zwischen Reinheit und Pragmatismus), und den sauberen Ansatz mit separaten Mapping-Klassen. Letzterer nutzt ein separates OrderRecord-Objekt mit Doctrine-Mapping, das der Adapter beim Speichern befüllt und beim Laden in die Domänen-Entity übersetzt. Das ist mehr Code, aber vollständige Trennung: Doctrine-Änderungen berühren die Domäne nicht.
Primäre Adapters in der hexagonalen Architektur sind Symfony-Controller, Console-Commands und Messenger-Handler. Ein Controller empfängt den HTTP-Request, extrahiert die benötigten Daten, erstellt ein Application-Command und ruft den Use Case auf. Der Controller kennt den Use Case — aber durch die Application-Schicht-Abstraktion braucht er keine Domänenlogik. Er ist ein dünner Adapter, der HTTP-Semantik in Use-Case-Aufrufe übersetzt. Das macht den Controller extrem schlank und leicht testbar — kein Testaufwand für Geschäftslogik, weil die im Use Case liegt.
7. Dependency Injection: Ports mit Adapters verdrahten
Der Symfony DI-Container ist der Mechanismus, der Ports und Adapters in der hexagonalen Architektur verbindet. Der Use Case deklariert im Konstruktor den Port (das Interface), der Container liefert beim Erstellen des Use-Case-Services den Adapter (die konkrete Implementierung). Das erfordert entweder explizite Alias-Konfiguration in services.yaml (App\Domain\Order\Port\OrderRepositoryInterface: '@App\Infrastructure\Doctrine\DoctrineOrderRepository') oder die Nutzung von PHP-Attributen.
Mit autoconfigure und dem #[AsAlias]-Attribut auf dem Adapter kann man in modernen Symfony-Projekten auch die Alias-Konfiguration aus YAML eliminieren. Der Doctrine-Adapter trägt das Attribut und signalisiert damit, welchen Port er implementiert. Symfony's Container-Compiler löst die Interface-zu-Klasse-Bindung automatisch auf — ohne einen einzigen YAML-Eintrag. Das schließt die hexagonale Architektur in Symfony mit dem autoconfigure-Feature zu einem sehr kompakten Setup zusammen: Ports als Interfaces in der Domain, Adapters mit PHP-Attributen in der Infrastructure, DI-Container als Klebstoff.
<?php
declare(strict_types=1);
// Infrastructure Layer: Doctrine Adapter implements Domain Port
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Model\OrderStatus;
use App\Domain\Order\Port\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* DoctrineOrderRepository — infrastructure adapter for the order repository port.
* Implements domain port, uses Doctrine ORM. Domain knows only the interface.
* #[AsAlias] binds the interface to this concrete class automatically.
*/
#[\Symfony\Component\DependencyInjection\Attribute\AsAlias(id: OrderRepositoryInterface::class)]
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function findById(OrderId $id): ?Order
{
// In pragmatic approach: domain entity IS the Doctrine entity
// In pure approach: load Doctrine record, map to domain entity
return $this->entityManager->find(Order::class, $id->value);
}
/** @return Order[] */
public function findByStatus(OrderStatus $status): array
{
return $this->entityManager->createQueryBuilder()
->select('o')
->from(Order::class, 'o')
->where('o.status = :status')
->setParameter('status', $status->value)
->getQuery()
->getResult();
}
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
}
public function remove(Order $order): void
{
$this->entityManager->remove($order);
$this->entityManager->flush();
}
}
// Symfony Controller as PRIMARY adapter — thin layer, no business logic
namespace App\Infrastructure\Http;
use App\Application\Order\Command\PlaceOrderCommand;
use App\Application\Order\UseCase\PlaceOrderUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/orders', methods: ['POST'])]
final class PlaceOrderController extends AbstractController
{
public function __construct(
private readonly PlaceOrderUseCase $placeOrder,
) {}
public function __invoke(Request $request): JsonResponse
{
// Controller translates HTTP request to application command
$command = new PlaceOrderCommand(/* ... extracted from $request ... */);
$orderId = $this->placeOrder->execute($command);
return $this->json(['orderId' => $orderId->value], 201);
}
}
8. Testen ohne Framework: Domänenlogik isoliert
Der größte Gewinn der hexagonalen Architektur ist die Testbarkeit. Da die Domain-Schicht und die Application-Schicht ausschließlich über Port-Interfaces auf die Infrastruktur zugreifen, können in Tests In-Memory-Implementierungen dieser Ports verwendet werden. Ein InMemoryOrderRepository implementiert OrderRepositoryInterface und speichert Orders in einem einfachen PHP-Array. Kein Datenbankzugriff, keine Transaktionen, kein Doctrine — der Unit-Test für PlaceOrderUseCase läuft in Millisekunden.
Das Schreiben von In-Memory-Adapters ist kein großer Aufwand: Sie sind typischerweise 10-20 Zeilen PHP ohne externe Abhängigkeiten. Dafür erhält man einen kompletten Unit-Test-Suite für die gesamte Geschäftslogik ohne Datenbankzugriff. In einem typischen Symfony-Projekt mit der hexagonalen Architektur läuft die Unit-Test-Suite in unter 3 Sekunden — unabhängig von der Projektgröße. Integrationstests mit echten Doctrine-Repositorys und der Symfony-Testumgebung kommen als zweite Schicht hinzu, testen aber weniger Code und brauchen entsprechend weniger Wartung.
| Schicht | Inhalt | Framework-Abhängigkeit | Test-Strategie |
|---|---|---|---|
| Domain | Entities, Value Objects, Domain Services, Ports | Keine | Unit-Tests, In-Memory-Adapters |
| Application | Use Cases, Commands, Command Handler | Nur PSR-Interfaces | Unit-Tests mit Port-Mocks |
| Infrastructure | Doctrine-Repos, Controller, Mailer-Adapters | Symfony, Doctrine, alle Libs | Integrationstests mit DB |
| In-Memory-Adapter | Test-Implementierungen der Ports | Keine | Für Unit-Tests der App-Schicht |
9. Vergleich: Traditionell vs. Hexagonal in Symfony
In einem traditionellen Symfony-Projekt kennt ein Service direkt das Doctrine-Repository, den Mailer und externe APIs — alle als konkrete Klassen. Das führt dazu, dass Tests entweder eine vollständige Symfony-Umgebung mit Datenbank brauchen oder aufwändige Mocking-Konfigurationen für jede Klasse. Mit der hexagonalen Architektur kennt derselbe Service nur Interfaces. Tests ersetzen diese Interfaces durch In-Memory-Implementierungen — schnell, ohne Mock-Framework und ohne Datenbankzugriff.
Der Initialaufwand der hexagonalen Architektur ist real: Mehr Dateien, mehr Interfaces, mehr Schichten. Für kleine Projekte mit wenig Domänenlogik ist dieser Overhead nicht gerechtfertigt. Für mittlere und große Projekte, bei denen die Testbarkeit und die Austauschbarkeit von Infrastruktur-Komponenten wichtig sind, zahlt sich die hexagonale Architektur mit zunehmendem Projektumfang aus. Ein Indikator: Wenn ein Projekt beginnt, Integrationstests zu schreiben, die mehr Zeit mit Datenbank-Setup verbringen als mit dem eigentlichen Test, ist die hexagonale Architektur ein sinnvoller nächster Schritt.
Mironsoft
Symfony Architektur, Domain-Driven Design und Clean Code
Hexagonale Architektur für euer Symfony-Projekt einführen?
Wir analysieren bestehende Symfony-Projekte auf Architektur-Schwächen, entwerfen eine schrittweise Migration zur hexagonalen Architektur und implementieren Domänenkern, Ports und Adapters — mit vollständiger Unit-Test-Abdeckung ohne Datenbankabhängigkeit.
Architektur-Analyse
Bestehende Symfony-Projekte auf Framework-Kopplung und Testbarkeits-Schwächen analysieren
Migration
Schrittweise Migration zu hexagonaler Architektur ohne Unterbrechung des laufenden Betriebs
Test-Strategie
Unit-Test-Suite mit In-Memory-Adapters aufbauen — schnelle Tests ohne Datenbankabhängigkeit
10. Zusammenfassung
Die hexagonale Architektur in Symfony isoliert den Domänenkern durch Ports (PHP-Interfaces) und Adapters (Symfony-Services). Die Domain-Schicht kennt keine Framework-Klassen, die Application-Schicht orchestriert Use Cases gegen Port-Interfaces, die Infrastructure-Schicht implementiert diese Ports mit Doctrine, Symfony Mailer und externen APIs. Symfony's DI-Container verdrahtet Ports und Adapters automatisch — mit #[AsAlias] auf dem Adapter sogar ohne YAML.
Der Hauptgewinn der hexagonalen Architektur ist die Unit-Testbarkeit des gesamten Domänen- und Application-Codes ohne Datenbankzugriff. In-Memory-Implementierungen der Port-Interfaces ermöglichen schnelle Tests in Millisekunden. Integrationstests mit echter Infrastruktur bleiben als zweite Test-Schicht erhalten, müssen aber weniger Domänenlogik abdecken. Für mittelgroße und große Symfony-Projekte ist die hexagonale Architektur das architektonische Fundament, das langfristige Wartbarkeit und Testbarkeit sichert.
Hexagonale Architektur in Symfony — Das Wichtigste auf einen Blick
Domain-Schicht
Entities, Value Objects, Domain Services und Port-Interfaces — kein use Symfony\ oder use Doctrine\. Reine PHP-Klassen mit Domänenlogik.
Application-Schicht
Use Cases orchestrieren Domäne gegen Port-Interfaces. Commands sind DTOs. Kennt Domain, kennt keine Infrastructure-Klassen.
Infrastructure-Schicht
Doctrine-Repos, Controller, Mailer-Adapters implementieren Ports. Einzige Schicht mit Framework-Kopplung. #[AsAlias] verdrahtet Port und Adapter.
Testing
In-Memory-Adapters für alle Ports → Unit-Tests für gesamten Domain/Application-Code ohne Datenbankzugriff. Unit-Suite läuft in unter 3 Sekunden.