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:
- Eine öffentliche Methode empfängt einen Command (eine Anfrage zur
Zustandsänderung). - Die Methode validiert diesen Command gegen den aktuellen Zustand des
Aggregats. - Wenn gültig, zeichnet sie ein Event auf. Sie ändert den Zustand nicht
direkt. - 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: