Aggregates
An Aggregate is a cluster of entities and value objects treated as a single unit for data changes. The Aggregate Root is the only entry point for modifications and the only object referenced externally.
Why Aggregates Matter
Without aggregate boundaries, any object can modify any other object, leading to inconsistent state and tangled dependencies. Aggregates enforce a consistency boundary: all invariants within the aggregate are guaranteed after each operation.
Aggregate Roots in the Demo Application
The demo application defines three aggregate roots:
| Aggregate Root | Module | Composition |
|---|---|---|
Book | Library | Title, Author, ISBN, BookStatus |
User | User | Email, HashedPassword, UserGroup |
Loan | Loan | LoanStatus, references Book and User by ID |
Each aggregate root extends EntityAbstract and implements EntityInterface.
Key Rules
Reference Other Aggregates by ID
An aggregate never holds a direct reference to another aggregate. It stores the ID only:
final class Loan extends EntityAbstract implements EntityInterface
{
public function __construct(
private readonly IdInterface $id,
private readonly IdInterface $userId, // ID, not User
private readonly IdInterface $bookId, // ID, not Book
// ...
) {}
}
This prevents one aggregate from reaching into another's internals and keeps aggregates independently loadable and persistable.
One Repository per Aggregate
Each aggregate root has exactly one repository interface (port) in the Domain layer:
BookRepositoryfor the Book aggregateUserRepositoryfor the User aggregateLoanRepositoryfor the Loan aggregate
Child entities within an aggregate are persisted through the root's repository, never independently.
Modify One Aggregate per Transaction
A command handler modifies a single aggregate and dispatches events for other aggregates to react. In BorrowBookHandler, the handler creates a Loan aggregate and dispatches a LoanCreatedEvent. A separate event listener in the Library module reacts by updating the Book aggregate:
// LoanCreatedEventHandler (Library module) reacts to a Loan event
public function __invoke(DomainEventInterface $event): void
{
$loan = $event->getLoan();
$command = new UpdateBookStatusCommand($loan->getBookId(), BookStatus::Borrowed);
$this->commandBus->dispatch($command);
}
This keeps aggregates decoupled: the Loan module does not modify Book directly.
Cross-Aggregate Queries with Read Models
Queries that span multiple aggregates use Read Models instead of traversing aggregate boundaries. LoanWithDetails combines data from Loan, Book, and User into a single read-optimized structure:
final readonly class LoanWithDetails
{
public function __construct(
public string $loanId,
public string $userId,
public string $userEmail, // from User aggregate
public string $bookId,
public string $bookTitle, // from Book aggregate
public string $borrowedAt,
public string $dueAt,
public ?string $returnedAt,
public string $status,
public bool $isOverdue,
) {}
}
The repository fetches this data via a database JOIN, avoiding N+1 queries and respecting aggregate boundaries.
See Also
- Entities - Aggregate root base class and identity
- Domain Events - Cross-aggregate communication
- Repository Interfaces - One repository per aggregate
- CQRS - Read Models for cross-aggregate queries