Skip to content

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:

  • BookRepository for the Book aggregate
  • UserRepository for the User aggregate
  • LoanRepository for 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