DDD mit Event Sourcing: So baust du Aggregate, die nicht explodieren

DDD mit Event Sourcing: So baust du Aggregate, die nicht explodieren

Dieses riesige User-Model in deiner App? Das ist ein Gott-Objekt und es wird dir den Tag versauen. Lass uns das mit ordentlichem DDD und Event Sourcing fixen.

Einleitung: Das unvermeidliche Gott-Objekt

Wir haben es alle schon getan. Ein Projekt fängt einfach an. Du hast eine User-Entity. Dann braucht der User ein Profil. Dann muss er sein Passwort ändern können. Dann braucht er Zahlungsmethoden. Dann Lieferadressen. Ehe du dich versiehst, ist deine User.php ein 2.000-Zeilen-Monstrum, das jedes bekannte Design-Prinzip verletzt.

Das ist das “Gott-Aggregat”-Problem, und hier kommt Domain-Driven Design (DDD) zur Rettung. Genauer gesagt, zwei Schlüsselkonzepte: den wahren Job des Aggregat-Roots zu verstehen und zu lernen, wie man große Aggregate aufteilt.

In diesem Post werden wir praktisch und zeigen, wie man diese Ideen mit patchlevel/eventsourcing umsetzt, einer modernen PHP-Bibliothek, die das Ganze überraschend elegant macht.

Teil 1: Der wahre Job des Aggregat-Roots (Er ist ein Türsteher)

Stell dir einen Aggregat-Root wie einen Türsteher in einem Nachtclub vor. Sein einziger Job ist es, die Integrität des Clubs zu schützen (die Geschäftsregeln oder “Invarianten”). Er lässt nicht einfach jeden rein, um an den Möbeln herumzuspielen. Alle Anfragen müssen über den Türsteher laufen, der entscheidet, ob sie gültig sind.

In patchlevel/eventsourcing übersetzt sich das in einen einfachen, aber mächtigen Workflow:

  1. Eine öffentliche Methode empfängt einen Command (eine Anfrage zur Zustandsänderung).
  2. Die Methode validiert diesen Command gegen den aktuellen Zustand des Aggregats.
  3. Wenn gültig, zeichnet sie ein Event auf. Sie ändert den Zustand nicht direkt.
  4. Eine separate, private #[Apply]-Methode ist dafür verantwortlich, den Zustand basierend auf dem Event tatsächlich zu ändern.

Lass uns ein SupportTicket-Aggregat modellieren. Invariante: Ein Ticket kann von einem Benutzer nur geschlossen werden, wenn es ihm zugewiesen ist.

// src/Support/Domain/Ticket.php
declare(strict_types=1);

namespace App\Support\Domain;

use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Id;

#[Aggregate('ticket')]
final class Ticket extends BasicAggregateRoot
{
    #[Id]
    private TicketId $id;
    private ?UserId $assigneeId = null;
    private bool $isClosed = false;

    public static function raise(TicketId $id, string $summary): self
    {
        $self = new self();
        $self->recordThat(new TicketRaised($id, $summary));

        return $self;
    }

    public function assign(UserId $assigneeId): void
    {
        if ($this->isClosed) {
            throw new \DomainException('Ein geschlossenes Ticket kann nicht zugewiesen werden.');
        }
        $this->recordThat(new TicketAssigned($assigneeId));
    }

    public function close(UserId $userId): void
    {
        if ($this->isClosed) {
            return; // Idempotent: Bereits geschlossen, nichts zu tun.
        }

        // Das ist der Invarianten-Check!
        if ($this->assigneeId === null || !$this->assigneeId->equals($userId)) {
            throw new \DomainException('Ticket kann nur vom zugewiesenen Benutzer geschlossen werden.');
        }

        $this->recordThat(new TicketClosed());
    }

    #[Apply]
    private function applyTicketRaised(TicketRaised $event): void
    {
        $this->id = $event->ticketId;
        $this->isClosed = false;
    }

    #[Apply]
    private function applyTicketAssigned(TicketAssigned $event): void
    {
        $this->assigneeId = $event->assigneeId;
    }

    #[Apply]
    private function applyTicketClosed(TicketClosed $event): void
    {
        $this->isClosed = true;
    }
}

Die close()-Methode ist unser Türsteher. Sie prüft die Regel und, wenn alles cool ist, macht sie ein recordThat() mit einem Event. Die Zustandsänderung selbst ist sauber in der applyTicketClosed()-Methode versteckt. Diese Trennung ist der Kern von Event Sourcing.

Teil 2: Das Gott-Aggregat killen

Unser User-Objekt ist immer noch ein Problem. Es kümmert sich um Authentifizierung (Account) und persönliche Infos (Profile). Das sind zwei verschiedene Business-Kontexte. Der Versuch, sie in einem Aggregat zu managen, ist ein Rezept für eine Katastrophe. Was, wenn ein User sein Profilbild aktualisieren will, während ein Passwort-Reset läuft? Du bekommst Transaktionskonflikte auf demselben Objekt aus völlig unabhängigen Gründen. Total nervig.

Die Lösung ist, das Gott-Aggregat aufzuteilen.

Vorher: Das User Gott-Aggregat

#[Aggregate('user')]
final class User extends BasicAggregateRoot
{
    // ...tonnenweise Properties für E-Mail, Passwort, Name, Bio, etc.

    public function changePassword(string $newPassword): void { /* ... */ }
    public function updateBio(string $newBio): void { /* ... */ }

    #[Apply]
    private function applyPasswordChanged(PasswordChanged $event): void { /* ... */ }

    #[Apply]
    private function applyBioUpdated(BioUpdated $event): void { /* ... */ }
}

Nachher: Aufteilung in Account und Profile

Wir erstellen zwei neue, kleinere Aggregate.

Das Account-Aggregat:

#[Aggregate('account')]
final class Account extends BasicAggregateRoot
{
    #[Id]
    private AccountId $id;
    private string $email;

    public function changePassword(string $newPassword): void { /* ... */ }

    #[Apply]
    private function applyPasswordChanged(PasswordChanged $event): void { /* ... */ }
}

Das Profile-Aggregat:

#[Aggregate('profile')]
final class Profile extends BasicAggregateRoot
{
    #[Id]
    private ProfileId $id; // Kann dieselbe sein wie die AccountId
    private string $bio;

    public function updateBio(string $newBio): void { /* ... */ }

    #[Apply]
    private function applyBioUpdated(BioUpdated $event): void { /* ... */ }
}

Viel sauberer. Wie hängen sie jetzt zusammen? Wir benutzen Domain Events. Wenn ein neuer Account erstellt wird, könnte er ein AccountCreated-Event auslösen. Ein Listener kann das aufschnappen und einen Command auslösen, um ein zugehöriges Profile zu erstellen. Sie sind über eine ID verbunden, aber transaktional unabhängig.

Ein pragmatischer Ansatz: Micro-Aggregates

Event-Streams zu migrieren kann komplex sein. patchlevel/eventsourcing bietet einen genialen Zwischenschritt mit dem #[Stream]-Attribut. Du kannst mehrere Aggregate denselben Stream teilen lassen.

#[Aggregate('profile')]
#[Stream(Account::class)] // Lauscht auf den Account-Stream
final class Profile extends BasicAggregateRoot
{
    #[Id]
    private AccountId $id; // Wir benutzen die AccountId
    private string $bio;

    // ...

    #[Apply]
    private function applyAccountCreated(AccountCreated $event): void
    {
        // Wenn ein Account erstellt wird, erstellen wir den Profile-Zustand
        $this->id = $event->accountId;
        $this->bio = 'Neues Benutzerprofil';
    }

    #[Apply]
    private function applyBioUpdated(BioUpdated $event): void { /* ... */ }
}

Hier ist das Profile ein “Micro-Aggregat”, das sich auf den Event-Stream des Account draufsetzt. Das ist eine fantastische Methode, um mit dem Refactoring zu beginnen, ohne ein riesiges Datenmigrationsprojekt starten zu müssen.

Deine Logik testen (ohne Datenbank)

Das Schöne an diesem Pattern ist die Testbarkeit. patchlevel/eventsourcing liefert ein AggregateRootTestCase.

final class TicketTest extends AggregateRootTestCase
{
    protected function aggregateClass(): string
    {
        return Ticket::class;
    }

    public function testCloseTicketFailsIfNotAssigned(): void
    {
        $this->expectException(\DomainException::class);

        $ticketId = TicketId::fromString('123');
        $userId = UserId::fromString('456');

        $this
            ->given(new TicketRaised($ticketId, 'Mein Bildschirm ist kaputt'))
            ->when(fn(Ticket $ticket) => $ticket->close($userId))
            ->then(/* Keine Events sollten aufgezeichnet werden */);
    }
}

Wir können unsere Kern-Business-Logik komplett isoliert testen. Schnell, einfach und zuverlässig.

Fazit

Indem du diese beiden DDD-Prinzipien beherzigst – lass deinen Aggregat-Root ein strenger Türsteher sein und teile Gott-Objekte rücksichtslos auf – kannst du eine chaotische Codebase in eine Sammlung sauberer, wartbarer und testbarer Domain-Modelle verwandeln.

Event Sourcing mit einer Bibliothek wie patchlevel/eventsourcing macht dieses Muster natürlicher als je zuvor. Du hörst auf, in “Zeilen in einer Tabelle updaten” zu denken und fängst an, in “Business-Fakten aufzeichnen” zu denken. Es ist ein kleiner Shift im Kopf, aber er macht den ganzen Unterschied.


Was ist das schlimmste Gott-Objekt, das dir je untergekommen ist? Teile deine Horrorgeschichten auf Twitter oder LinkedIn.

Weiterführende Literatur: