DDD with Event Sourcing: How to Build Aggregates That Don’t Explode
That giant User
model in your app? It’s a God Object, and it’s going to ruin
your day. Let’s fix it with some proper DDD and Event Sourcing.
Introduction: The Inevitable God Object
We’re all guilty of it. A project starts simple. You have a User
entity. Then
the user needs a profile. Then they need to change their password. Then they
need payment methods. Then shipping addresses. Before you know it, your
User.php
is a 2,000-line monstrosity that violates every design principle
known to humanity.
This is the “God Aggregate” problem, and it’s where Domain-Driven Design (DDD)
comes to the rescue. Specifically, two key concepts: understanding the
Aggregate Root’s true job and learning how to split large Aggregates.
In this post, we’ll get practical and show how to implement these ideas using
patchlevel/eventsourcing
, a modern PHP library that makes this stuff
surprisingly elegant.
Part 1: The Aggregate Root’s Real Job (It’s a Bouncer)
Think of an Aggregate Root as a bouncer at a nightclub. Its only job is to
protect the club’s integrity (the business rules, or “invariants”). It doesn’t
let anyone just wander in and mess with the furniture. All requests have to go
through the bouncer, who decides if they’re valid.
In patchlevel/eventsourcing
, this translates to a simple, powerful workflow:
- A public method receives a command (a request to change state).
- The method validates this command against the Aggregate’s current state.
- If valid, it records an event. It does not change the state directly.
- A separate, private
#[Apply]
method is responsible for actually changing
the state from the event.
Let’s model a SupportTicket
Aggregate. Invariant: A ticket can only be
closed by a user if it has been assigned to them.
// 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('Cannot assign a closed ticket.');
}
$this->recordThat(new TicketAssigned($assigneeId));
}
public function close(UserId $userId): void
{
if ($this->isClosed) {
return; // Idempotent: already closed, do nothing.
}
// This is the invariant check!
if ($this->assigneeId === null || !$this->assigneeId->equals($userId)) {
throw new \DomainException('Ticket can only be closed by its assignee.');
}
$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;
}
}
The close()
method is our bouncer. It checks the rule and, if everything’s
cool, it recordThat()
an event. The state change itself is neatly tucked away
in the applyTicketClosed()
method. This separation is the core of event
sourcing.
Part 2: Killing the God Aggregate
Our User
object is still a problem. It handles authentication (Account
) and
personal info (Profile
). These are two different business contexts. Trying to
manage them in one Aggregate is a recipe for disaster. What if a user wants to
update their profile picture while a password reset is in progress? You get
transactional conflicts on the same object for unrelated reasons.
The solution is to split the God Aggregate.
Before: The User
God Aggregate
#[Aggregate('user')]
final class User extends BasicAggregateRoot
{
// ...tons of properties for email, password, 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 { /* ... */ }
}
After: Splitting into Account
and Profile
We create two new, smaller Aggregates.
The Account
Aggregate:
#[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 { /* ... */ }
}
The Profile
Aggregate:
#[Aggregate('profile')]
final class Profile extends BasicAggregateRoot
{
#[Id]
private ProfileId $id; // This can be the same as the AccountId
private string $bio;
public function updateBio(string $newBio): void { /* ... */ }
#[Apply]
private function applyBioUpdated(BioUpdated $event): void { /* ... */ }
}
This is much cleaner. Now, how do they relate? We use Domain Events. When a new
Account
is created, it might dispatch an AccountCreated
event. A listener
can then pick this up and trigger a command to create a corresponding Profile
.
They are linked by ID, but they are transactionally independent.
A Pragmatic Approach: Micro-Aggregates
Migrating event streams can be complex. patchlevel/eventsourcing
offers a
great intermediate step with the #[Stream]
attribute. You can have multiple
Aggregates share the same stream.
#[Aggregate('profile')]
#[Stream(Account::class)] // Listen to the Account stream
final class Profile extends BasicAggregateRoot
{
#[Id]
private AccountId $id; // We use the AccountId
private string $bio;
// ...
#[Apply]
private function applyAccountCreated(AccountCreated $event): void
{
// When an Account is created, we create the Profile state
$this->id = $event->accountId;
$this->bio = 'New user profile';
}
#[Apply]
private function applyBioUpdated(BioUpdated $event): void { /* ... */ }
}
Here, the Profile
is a “micro-aggregate” that piggybacks on the Account
’s
event stream. It’s a fantastic way to start refactoring without a massive data
migration project.
Testing Your Logic (Without a Database)
The beauty of this pattern is testability. patchlevel/eventsourcing
provides a
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, 'My screen is broken'))
->when(fn(Ticket $ticket) => $ticket->close($userId))
->then(/* No events should be recorded */);
}
}
We can test our core business logic in complete isolation. Fast, easy, and
reliable.
Conclusion
By embracing these two DDD principles—letting your Aggregate Root be a strict
bouncer and ruthlessly splitting up God Objects—you can transform a chaotic
codebase into a collection of clean, maintainable, and testable domain models.
Event sourcing with a library like patchlevel/eventsourcing
makes this pattern
more natural than ever. You stop thinking about “updating rows in a table” and
start thinking about “recording business facts.” It’s a subtle shift, but it
makes all the difference.
What’s the worst God Object you’ve ever encountered? Share your horror stories
on Twitter or LinkedIn.
Related Reading: