Aggregates
AggregateRoot — version-based optimistic locking, domain event recording, pullDomainEvents, and the reconstruct pattern.
Aggregates
AggregateRoot is the base class for all domain aggregates. It provides version tracking for optimistic locking and domain event collection.
Define an Aggregate
use Vortos\Domain\Aggregate\AggregateRoot;
use Vortos\Domain\Identity\AggregateId;
final class User extends AggregateRoot
{
private function __construct(
private readonly UserId $id,
private readonly Email $email,
private string $name,
private string $passwordHash,
) {}
// Named constructor for new aggregates
public static function register(string $name, string $email, string $plainPassword): self
{
$id = UserId::generate();
$user = new self(
id: $id,
email: new Email($email),
name: $name,
passwordHash: password_hash($plainPassword, PASSWORD_BCRYPT),
);
// Record event — dispatched by CommandBus after commit
$user->recordEvent(new UserRegisteredEvent(
aggregateId: (string) $id,
email: $email,
name: $name,
));
return $user;
}
// Named constructor for loading from DB
public static function reconstruct(
UserId $id,
Email $email,
string $name,
string $passwordHash,
): self {
return new self(
id: $id,
email: $email,
name: $name,
passwordHash: $passwordHash,
);
}
public function getId(): UserId { return $this->id; }
public function getEmail(): Email { return $this->email; }
public function getName(): string { return $this->name; }
public function getPasswordHash(): string { return $this->passwordHash; }
// Command method — mutates state and records event
public function updateName(string $name): void
{
$old = $this->name;
$this->name = $name;
$this->recordEvent(new UserNameUpdatedEvent((string) $this->id, $old, $name));
}
}AggregateRoot API
// Abstract — must implement
abstract public function getId(): AggregateId;
// Record an event (called inside command methods)
protected function recordEvent(DomainEventInterface $event): void;
// Pull all recorded events and clear the buffer
public function pullDomainEvents(): array;
// Check if any events are pending
public function hasDomainEvents(): bool;
// Optimistic locking
public function getVersion(): int;
protected function restoreVersion(int $version): void; // @internal — called by DbalStore::hydrate() automatically
public function incrementVersion(): void; // @internal — called by DbalStore after saveDomain Event Recording
Aggregates never dispatch events — they record them. The CommandBus calls pullDomainEvents() inside the transaction and writes them to the outbox:
Command → CommandBus
UnitOfWork::run()
handler() → aggregate.recordEvent(...)
repository.save(aggregate)
foreach aggregate.pullDomainEvents() as event
outbox.store(event) ← same transaction
commit()Record events at the end of command methods, after state has been mutated:
public function updateEmail(Email $newEmail): void
{
$old = $this->email;
$this->email = $newEmail; // mutate first
$this->recordEvent(new UserEmailUpdatedEvent(...)); // record after
}Version and Optimistic Locking
Every aggregate starts at version = 0. The repository increments it on every successful save:
new User() version = 0
save() version = 1 (INSERT)
save() version = 2 (UPDATE WHERE version = 1)
save() version = 3 (UPDATE WHERE version = 2)If two processes load version 2 and both try to save, the second save fails because the database version is already 3. See Optimistic Locking for handling strategies.
Never call incrementVersion() or restoreVersion() directly. incrementVersion() is called by DbalStore after a successful save; restoreVersion() is called automatically by DbalStore::hydrate() when loading from the database.
The reconstruct() Pattern
reconstruct() is a plain named constructor — it does not need to restore lock_version. DbalStore calls hydrate() after fromRow(), which restores the version via Closure::bind() automatically:
// Aggregate — no lock_version parameter needed
public static function reconstruct(
UserId $id,
Email $email,
string $name,
string $passwordHash,
): self {
return new self($id, $email, $name, $passwordHash);
}// DbalMapper::fromRow() — no (int) $row['lock_version'] needed
public function fromRow(array $row): AggregateRoot
{
return User::reconstruct(
id: UserId::fromString($row['id']),
email: new Email($row['email']),
name: $row['name'],
passwordHash: $row['password_hash'],
);
}DbalStore::hydrate() is what actually calls restoreVersion() — never call it yourself. For custom query methods in your repository, use $this->store->hydrate($row) instead of $this->store->mapper()->fromRow($row) directly, so the version is always restored.
EventSourcedAggregateRoot
EventSourcedAggregateRoot is stubbed but not yet implemented. Extending it throws \LogicException. Use AggregateRoot for all production code.
Private Constructor
Use a private constructor and named static factories (register(), reconstruct()). This prevents invalid aggregates from being created — the factory enforces all invariants. Never expose a public constructor.