Taktische Patterns ohne Doctrine in der Domain
Domain Driven Design in Symfony scheitert selten am strategischen Verstaendnis der Fachdomaene und fast immer an der taktischen Umsetzung im Code. Dieser Artikel zeigt konkret, wie Value Objects, Aggregate Grenzen, Repository Interfaces und Domain Events in Symfony implementiert werden, ohne dass Doctrine Annotationen die reine Domain Schicht verschmutzen.
Inhaltsverzeichnis
- 1. Warum Domain Driven Design in Symfony oft scheitert
- 2. Value Objects statt primitive Typen
- 3. Entities und Aggregate Grenzen definieren
- 4. Repository Interfaces in der Domain Schicht
- 5. Domain Events aus dem Aggregate dispatchen
- 6. Doctrine sauber aus der Domain heraushalten
- 7. Application Services als Use Case Orchestrierung
- 8. Ubiquitous Language konsequent im Code nutzen
- 9. Taktische DDD Patterns im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Domain Driven Design in Symfony oft scheitert
Domain Driven Design wird in vielen Symfony Projekten auf ein einziges Detail reduziert: Entities bekommen private Properties und Getter statt oeffentlicher Attribute. Das strategische Kernversprechen von Domain Driven Design, naemlich dass der Code die Sprache und die Regeln der Fachdomaene direkt abbildet, geht dabei meist verloren. Ein Order Entity mit zwanzig oeffentlichen Settern ist kein Ausdruck von Domain Driven Design, sondern ein Doctrine Entity mit Kapselungs Kosmetik.
Der eigentliche Wert von Domain Driven Design in Symfony entsteht erst, wenn Geschaeftsregeln als invariante Bedingungen in Aggregaten leben, statt in Controllern oder Services verstreut zu sein. Eine Bestellung, die keine leeren Positionen enthalten darf, oder ein Rabatt, der nur unterhalb eines bestimmten Schwellenwerts gueltig ist, gehoert in das Aggregate selbst, nicht in eine Validierungsklasse, die zufaellig an mehreren Stellen aufgerufen wird oder eben nicht. Die folgenden Abschnitte zeigen die konkreten taktischen Patterns, die Domain Driven Design in einem Symfony Projekt tatsaechlich umsetzbar machen.
2. Value Objects statt primitive Typen
Das grundlegendste taktische Pattern im Domain Driven Design ist das Value Object. Statt eine E-Mail-Adresse als string oder einen Geldbetrag als float zu modellieren, bekommt jedes fachlich bedeutsame Konzept eine eigene, unveraenderliche Klasse mit eingebauter Validierung. Ein EmailAddress Value Object stellt sicher, dass niemals ein ungueltiger Wert existieren kann, weil die Validierung im Konstruktor stattfindet und keine Setter existieren. Dieses Pattern verlagert Validierungslogik vom Rand der Anwendung, wo sie leicht vergessen wird, in den Typ selbst, wo sie garantiert immer greift.
Money als Value Object ist ein weiteres Standardbeispiel im Domain Driven Design, das in Symfony Projekten haeufig fehlt. Ein reiner float Betrag fuehrt zu Rundungsfehlern und erlaubt versehentliche Vermischung unterschiedlicher Waehrungen. Ein Money Value Object kapselt Betrag und Waehrung gemeinsam, implementiert Operationen wie add() und multiply() mit eingebauter Waehrungspruefung und macht dadurch einen ganzen Klassifizierung von Fehlern zur Compile Zeit beziehungsweise zur Laufzeit sofort sichtbar, statt erst in der Produktion aufzufallen.
<?php
declare(strict_types=1);
namespace App\Modules\Billing\Domain\ValueObject;
use InvalidArgumentException;
// Value Object — immutable, self-validating, no setters
final readonly class Money
{
private function __construct(
public int $amountInCents,
public string $currency,
) {
if ($amountInCents < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
if (!in_array($currency, ['EUR', 'USD', 'CHF'], true)) {
throw new InvalidArgumentException("Unsupported currency: {$currency}");
}
}
public static function fromCents(int $cents, string $currency): self
{
return new self($cents, $currency);
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amountInCents > $other->amountInCents;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
}
}
3. Entities und Aggregate Grenzen definieren
Ein Aggregate ist im Domain Driven Design eine Gruppe zusammengehoeriger Entities und Value Objects, die als eine Konsistenzeinheit behandelt werden, mit genau einer Aggregate Root als einzigem erlaubten Zugriffspunkt von aussen. Eine Bestellung mit ihren Positionen ist ein klassisches Beispiel: OrderItem Objekte existieren nur innerhalb von Order, werden niemals direkt ueber ein eigenes Repository geladen und koennen nur ueber Methoden der Order Aggregate Root veraendert werden, etwa $order->addItem(...) statt eines direkten Zugriffs auf die interne Items Collection.
Die Grenze eines Aggregates im Domain Driven Design richtig zu ziehen ist die schwierigste Entscheidung in der gesamten Domain Modellierung. Zu grosse Aggregate fuehren zu Lock Konflikten bei nebenlaeufigen Schreibzugriffen und laden unnoetig viele Daten. Zu kleine Aggregate verlagern Konsistenzregeln in Application Services, wo sie leicht vergessen werden. Die Faustregel des Domain Driven Design: Ein Aggregate sollte genau die Daten enthalten, die in einer einzigen Transaktion konsistent bleiben muessen, nicht mehr und nicht weniger.
<?php
declare(strict_types=1);
namespace App\Modules\Order\Domain\Entity;
use App\Modules\Order\Domain\Event\OrderCompletedEvent;
use App\Modules\Billing\Domain\ValueObject\Money;
// Aggregate Root — the only allowed entry point into this consistency boundary
final class Order
{
/** @var OrderItem[] */
private array $items = [];
/** @var object[] recorded domain events, dispatched after persistence */
private array $recordedEvents = [];
private function __construct(
private readonly string $id,
private readonly int $customerId,
private string $status = 'draft',
) {}
public static function create(string $id, int $customerId): self
{
return new self($id, $customerId);
}
public function addItem(string $productId, int $quantity, Money $unitPrice): void
{
if ($this->status !== 'draft') {
throw new \DomainException('Cannot modify a completed order');
}
if ($quantity < 1) {
throw new \DomainException('Quantity must be at least 1');
}
$this->items[] = new OrderItem($productId, $quantity, $unitPrice);
}
public function complete(): void
{
if ($this->items === []) {
throw new \DomainException('Cannot complete an order without items');
}
$this->status = 'completed';
$this->recordedEvents[] = new OrderCompletedEvent($this->id, $this->customerId);
}
/** @return object[] */
public function releaseEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
}
4. Repository Interfaces in der Domain Schicht
Im Domain Driven Design definiert die Domain Schicht das Repository Interface, waehrend die Infrastruktur Schicht die konkrete Implementierung liefert, meist mit Doctrine. Diese Umkehrung der Abhaengigkeitsrichtung ist entscheidend: OrderRepositoryInterface liegt in Domain\Repository und kennt Doctrine ueberhaupt nicht, waehrend DoctrineOrderRepository in Infrastructure\Doctrine das Interface implementiert und alle ORM Details kapselt. Die Methoden des Interfaces sprechen ausschliesslich die Sprache der Domaene, etwa findById() oder nextIdentity(), niemals Doctrine spezifische Konzepte wie QueryBuilder Objekte.
Dieser Ansatz macht Domain Logik im Domain Driven Design unabhaengig von der konkreten Persistenz Technologie testbar. Unit Tests fuer Aggregate Verhalten koennen ein In Memory Repository verwenden, das das Interface implementiert, ohne jemals eine echte Datenbankverbindung herzustellen. Das beschleunigt die Testausfuehrung erheblich und zwingt gleichzeitig dazu, dass die Domain Schicht wirklich frei von Infrastruktur Details bleibt, was in der Praxis der haeufigste Verstoss gegen Domain Driven Design Prinzipien in Symfony Projekten ist.
5. Domain Events aus dem Aggregate dispatchen
Domain Events sind im Domain Driven Design das Mittel, um Nebenwirkungen eines Aggregate Zustandswechsels zu kommunizieren, ohne dass das Aggregate selbst weiss, wer darauf reagiert. Wenn Order::complete() aufgerufen wird, zeichnet die Aggregate Root ein OrderCompletedEvent auf, ohne es direkt zu verschicken. Erst der Application Service, der die Aggregate Root nach erfolgreichem flush() speichert, ruft releaseEvents() auf und dispatcht die Events ueber den Symfony Messenger Event Bus.
Diese Verzoegerung zwischen Aufzeichnung und Versand ist im Domain Driven Design kein Implementierungsdetail, sondern eine bewusste Entscheidung: Events sollen erst verschickt werden, wenn die zugrunde liegende Zustandsaenderung tatsaechlich dauerhaft in der Datenbank gespeichert ist. Ein vorzeitig verschicktes Event ueber eine Bestellung, deren Speicherung anschliessend fehlschlaegt, wuerde andere Systemteile ueber einen Zustand informieren, der nie wirklich eingetreten ist, ein subtiler, aber in der Praxis haeufiger Fehler bei naiven Domain Event Implementierungen.
6. Doctrine sauber aus der Domain heraushalten
Ein zentrales Ziel von Domain Driven Design ist, dass die Domain Schicht unabhaengig von jedem konkreten Framework bleibt. In der Praxis bedeutet das fuer Symfony Projekte: keine #[ORM\Entity] Attribute direkt an Domain Klassen, sondern XML oder YAML Mapping Dateien, die vollstaendig ausserhalb der Domain Schicht in der Infrastruktur liegen. Doctrine kann Entities auch ganz ohne Attribute mappen, solange die Mapping Konfiguration separat existiert und private Properties ueber Reflection gesetzt werden koennen.
Diese Trennung erlaubt es, im Domain Driven Design ein Aggregate wie Order komplett ohne Doctrine Abhaengigkeit zu testen und theoretisch sogar die Persistenz Technologie zu wechseln, ohne die Domain Schicht anzufassen. In der Praxis wechselt kaum ein Projekt tatsaechlich die Datenbank, aber der eigentliche Nutzen liegt woanders: Die Domain Klassen bleiben lesbar und frei von technischen Annotationen, die sonst die fachliche Absicht des Codes ueberlagern wuerden.
# config/doctrine/Order.orm.yaml — mapping lives outside the domain class
App\Modules\Order\Domain\Entity\Order:
type: entity
table: orders
id:
id:
type: string
column: id
fields:
customerId:
type: integer
column: customer_id
status:
type: string
column: status
oneToMany:
items:
targetEntity: App\Modules\Order\Domain\Entity\OrderItem
mappedBy: order
cascade: [persist, remove]
orphanRemoval: true
7. Application Services als Use Case Orchestrierung
Zwischen Controller und Domain Schicht sitzt im Domain Driven Design die Application Schicht, meist als Command Handler oder Application Service umgesetzt. Diese Schicht orchestriert einen einzelnen Use Case: Sie laedt das Aggregate ueber das Repository, ruft fachliche Methoden auf der Aggregate Root auf, speichert das Ergebnis und dispatcht die aufgezeichneten Domain Events. Wichtig ist, dass die Application Schicht selbst keine Geschaeftsregeln enthaelt, sondern diese vollstaendig an die Domain Schicht delegiert.
Diese klare Trennung im Domain Driven Design verhindert das haeufigste Symptom eines anaemischen Domain Modells: Geschaeftslogik, die in Services statt in Entities landet. Ein Application Service, der pruefen wuerde, ob eine Bestellung Positionen hat, bevor er sie abschliesst, verlagert diese Regel aus dem Aggregate heraus, wo sie eigentlich hingehoert. Der Command Handler ruft stattdessen ausschliesslich $order->complete() auf und ueberlaesst die Pruefung vollstaendig der Aggregate Root.
8. Ubiquitous Language konsequent im Code nutzen
Die Ubiquitous Language ist im Domain Driven Design genauso wichtig wie jedes taktische Pattern, wird in der technischen Umsetzung aber oft vernachlaessigt. Wenn Fachexperten von einer Bestellung sprechen, die abgeschlossen wird, sollte die Methode im Code auch complete() heissen und nicht setStatus('completed'). Diese Uebereinstimmung zwischen gesprochener Fachsprache und Methodennamen im Code ist kein kosmetisches Detail, sondern reduziert Missverstaendnisse zwischen Fachabteilung und Entwicklungsteam messbar.
In einem Symfony Projekt mit konsequentem Domain Driven Design zeigt sich die Ubiquitous Language auch in den Modul und Klassennamen selbst. Statt eines generischen StatusService gibt es spezifische Methoden wie Order::cancel(), Order::complete() oder Order::refund(), jede mit ihren eigenen Invarianten. Diese Explizitheit macht den Code fuer neue Teammitglieder lesbar, ohne dass sie die technische Implementierung im Detail verstehen muessen, allein die Methodennamen erzaehlen bereits die Geschichte der Fachdomaene.
9. Taktische DDD Patterns im Vergleich
Nicht jedes Symfony Projekt braucht alle taktischen Patterns des Domain Driven Design gleichzeitig. Die folgende Tabelle ordnet die wichtigsten Patterns nach ihrem Nutzen und dem Aufwand, sie korrekt umzusetzen.
| Pattern | Loest welches Problem | Umsetzungsaufwand | Nutzen |
|---|---|---|---|
| Value Objects | Primitive Obsession, fehlende Validierung | Niedrig | Sehr hoch |
| Aggregate Root | Verstreute Invarianten Pruefung | Mittel | Hoch |
| Repository Interface | Domain an Doctrine gekoppelt | Niedrig | Hoch |
| Domain Events | Direkte Kopplung an Nebenwirkungen | Mittel | Mittel bis hoch |
| Application Service | Anaemisches Domain Modell | Niedrig | Sehr hoch |
In der Praxis liefern Value Objects und Application Services im Domain Driven Design den besten Nutzen pro Aufwand und sollten in jedem Symfony Projekt mit nicht trivialer Fachlogik als Erstes eingefuehrt werden. Aggregate Root und Domain Events lohnen sich vor allem dort, wo mehrere Entities gemeinsame Invarianten teilen.
Mironsoft
Domain Driven Design, Symfony Architektur und Fachdomaenen Modellierung
Anaemisches Domain Modell statt echtem Domain Driven Design?
Wir modellieren Aggregate Grenzen gemeinsam mit euren Fachexperten, implementieren Value Objects und Repository Interfaces und trennen die Domain Schicht sauber von Doctrine, damit Domain Driven Design im Symfony Projekt tatsaechlich funktioniert.
Domain Modellierung
Aggregate Grenzen und Invarianten gemeinsam mit Fachabteilungen erarbeiten
Refactoring
Anaemische Entities schrittweise zu echten Aggregate Roots umbauen
Doctrine Trennung
Mapping aus der Domain herausloesen, ohne die Anwendung zu unterbrechen
10. Zusammenfassung
Domain Driven Design in Symfony Projekten wird erst dann wirksam, wenn taktische Patterns konsequent umgesetzt werden: Value Objects statt primitiver Typen, Aggregate Roots als einzige Konsistenzgrenze, Repository Interfaces in der Domain Schicht mit Doctrine Implementierung in der Infrastruktur, und Domain Events, die Nebenwirkungen entkoppeln, statt sie direkt zu verdrahten. Application Services orchestrieren Use Cases, ohne selbst Geschaeftsregeln zu enthalten, die vollstaendig in der Domain Schicht bleiben.
Der groesste Fehler bei der Einfuehrung von Domain Driven Design ist, alle Patterns gleichzeitig und ueberall anzuwenden. Value Objects und Application Services lassen sich mit geringem Aufwand ueberall einfuehren, waehrend Aggregate Root und Domain Events gezielt dort eingesetzt werden sollten, wo tatsaechlich komplexe Geschaeftsregeln existieren. Diese pragmatische Priorisierung macht Domain Driven Design in echten Symfony Projekten umsetzbar, statt an akademischer Vollstaendigkeit zu scheitern.
Domain Driven Design in Symfony — Das Wichtigste auf einen Blick
Value Objects
Unveraenderliche, selbstvalidierende Klassen statt primitiver Typen fuer fachlich bedeutsame Konzepte.
Aggregate Root
Einzige Konsistenzgrenze und einziger erlaubter Zugriffspunkt fuer zusammengehoerige Entities.
Repository Interface
Interface in der Domain, Doctrine Implementierung in der Infrastruktur Schicht.
Domain Events
Aufgezeichnet im Aggregate, versendet erst nach erfolgreicher Persistierung.