Getting started
Aggregates

Aggregates

An aggregate models an individual concept that has a unique identity in your application, e.g. an account.

Creating an Aggregate

To create an aggregate using this library you will need to make your aggregate class:

  • inherit the AggregateRoot class
  • apply the @Aggregate() decorator
import { Aggregate, AggregateRoot } from '@ocoda/event-sourcing';
 
@Aggregate('account')
class Account extends AggregateRoot {
    ...
}

The AggregateRoot class is responsible for how the aggregate handles events (applying, committing & loading) and keeps track of the version of the aggregate.

The @Aggregate() decorator marks the class as an aggregate and optionally specifies how the streamId of events and snapshots should be named. e.g. @Aggregate({ streamName: 'account' }) would create the following streamId:

account-<aggregateId> // e.g. account-d46fb0f9-02dc-4d11-a282-ab00f7fffeff

If the stream name isn't provided in the decorator, the name of the class will automatically be used in lowercase.

⚠️

Whether the stream name is provided or if it's derived from the class-name, the maximum length shouldn't exceed 50 characters.

Adding logic to the Aggregate

When working with aggregates in a Domain-Driven Design (DDD) context, the aggregate serves as the central point for enforcing business rules and managing consistency boundaries. Aggregates should encapsulate business logic and ensure that any changes to the aggregate are made through domain events.

In the example below, we define an Account aggregate with logic for adding and removing owners, crediting and debiting the balance, and opening or closing an account. Each method that changes the state of the aggregate raises an event which captures the change.

Guidelines for adding logic to your Aggregate

Mutate state through events

Instead of directly modifying properties inside your aggregate, encapsulate state changes in events. This ensures that all state changes are tracked and can be replayed if necessary.

public credit(amount: number) {
    this.applyEvent(new AccountCreditedEvent(amount));
}

This ensures that the event is both applied to the current state and persisted to the event store.

Ensure business invariants

Aggregates are responsible for enforcing business rules (also known as invariants) within the boundaries of their consistency. For instance, you might enforce that an account cannot be overdrawn:

public debit(amount: number) {
    if (this.balance - amount < 0) {
        throw new Error('Insufficient funds');
    }
    this.applyEvent(new AccountDebitedEvent(amount));
}

Here, the business rule (no overdrafts) is enforced at the aggregate level before applying the event.

Use Events to Communicate Changes

Each event should capture a meaningful change in the domain. Events are used to communicate what happened, not why it happened or how the change was made. For example, instead of naming an event BalanceChangedEvent, use AccountCreditedEvent or AccountDebitedEvent to make the event more expressive and tied to the business logic.

Handle Events to Update State

Each event should have a counterpart that mutates the aggregate's state based on the event that needs to be applied to the aggregate (methods decorated with @EventHandler(...)). This pattern decouples the business logic from how the state is updated.

@EventHandler(AccountCreditedEvent)
applyAccountCreditedEvent(event: AccountCreditedEvent) {
    this.balance += event.amount;
}

This method updates the balance when an AccountCreditedEvent is applied. Notice how the state mutation is handled separately from the business logic that applies the event. See Event Handling for more information.

Keep aggregates focused on domain logic

Aggregates should encapsulate domain logic and business rules. Avoid adding infrastructure concerns (e.g., database access, HTTP calls) to your aggregates. Keep your aggregates focused on the domain and delegate infrastructure concerns to application services and repositories.

Event handling should be idempotent

Make sure that event handlers can safely be replayed without causing inconsistencies. Since aggregates are rehydrated by replaying events from an event store, you need to ensure that handling an event multiple times results in the same state.

For instance:

onAccountOwnerAddedEvent(event: AccountOwnerAddedEvent) {
    if (!this.ownerIds.find(({ value }) => value === event.accountOwnerId)) {
        this.ownerIds.push(AccountOwnerId.from(event.accountOwnerId));
    }
}

Here, we check if the owner is already added before mutating the state, ensuring that replaying the event doesn't cause duplicate owners.