Domain Driven Design in Symfony Projekten: Taktische Patterns
AI generated
SF
{ }
Symfony · Domain Driven Design · Value Objects · Aggregate
Domain Driven Design in Symfony Projekten
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.

20 Min. Lesezeit Value Objects · Aggregate Root · Repositories · Domain Events Symfony 7.x · PHP 8.3+ · Doctrine ORM

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.

11. FAQ: Domain Driven Design in Symfony Projekten

1Strategisches vs. taktisches DDD?
Strategisch behandelt Bounded Contexts und Domaenenaufteilung, taktisch die konkreten Code Patterns innerhalb eines Bounded Context.
2Muss ich Doctrine Attribute vermeiden?
Nicht zwingend, aber XML oder YAML Mapping ausserhalb der Entity haelt die Domain frei von ORM Annotationen.
3Wie gross sollte ein Aggregate sein?
So gross wie noetig fuer alle Invarianten einer Transaktion, nicht groesser, sonst drohen Lock Konflikte.
4Wo werden Domain Events verschickt?
Im Application Service nach erfolgreicher Persistierung, das Aggregate selbst zeichnet nur auf.
5Was ist ein anaemisches Domain Modell?
Ein Entity nur mit Gettern und Settern, dessen Geschaeftsregeln extern in Services liegen statt im Aggregate.
6Braucht jedes Feld ein Value Object?
Nein, nur Felder mit eigener fachlicher Bedeutung oder Validierung, technische IDs meist nicht.
7Wie teste ich ein Aggregate ohne Datenbank?
Ueber Unit Tests, die das Aggregate direkt instanziieren, plus In Memory Repository Implementierungen fuer Application Service Tests.
8Passt DDD auch zu kleinen Projekten?
Value Objects und Application Services lohnen sich fast immer, Aggregate Root erst bei echter fachlicher Komplexitaet.
9Wie haengt CQRS damit zusammen?
CQRS passt gut dazu, ist aber kein zwingender Bestandteil von Domain Driven Design.
10Wie fuehrt man DDD in Legacy Projekten ein?
Schrittweise mit Value Objects beginnen, dann Logik aus Services in Entities verschieben, erst spaeter volle Aggregate Grenzen.