Skip to content

Aggregates

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

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({ streamName: 'account' })
export 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 takes an options object that specifies how the streamId of events and snapshots should be named. e.g. @Aggregate({ streamName: 'account' }) would create the following streamId:

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

If the stream name isn’t provided in the decorator (you can also use @Aggregate() without any argument), the name of the class will automatically be used in lowercase.

By default, applying an event that the aggregate has no event handler for throws a MissingEventHandlerException and leaves the aggregate unchanged. If the stream of an aggregate can hold events it doesn’t need to handle, for example events that only an older version of the aggregate used, set missingHandler: 'ignore' (since 4.0):

@Aggregate({ streamName: 'account', missingHandler: 'ignore' })
export class Account extends AggregateRoot {}

The events are then applied without a handler: they still count towards the version of the aggregate.

The AggregateRoot counts the events of the aggregate, and keeps the ones that aren’t stored yet until the repository saves them (since 4.0):

Member
version The version of the aggregate: committedVersion plus the number of uncommitted events.
committedVersion The version of the last event that was loaded from the event store or marked as committed: the version the stream is expected to have when the aggregate is saved.
getUncommittedEvents() A copy of the events that were applied since the aggregate was loaded or last saved.
markCommitted(events) Marks the events that getUncommittedEvents() returned as committed, once the repository appended them. Events applied in the meantime stay uncommitted. Without events, it marks every uncommitted event as committed.
loadFromHistory(events) Applies stored events, as committed events: the batches of eventStore.getEvents() or an array of events.

applyEvent() runs the event handler first and only then counts the event, so a handler that throws leaves the version and the uncommitted events as they were. loadFromHistory() and the version setter throw an UncommittedEventsException while the aggregate has uncommitted events: the history would otherwise be applied after events that were raised on an older state. So does markCommitted(events) when events aren’t the first uncommitted events, in order: pass it the array that getUncommittedEvents() returned.

commit(), which returned the uncommitted events and cleared them before they were appended, is deprecated and will be removed in 5.0.

Apply follow-up events from the method that applies the first one where you can: the decision then lives in one place, and the event handlers only change state. An event handler may still call applyEvent() itself (since 4.0, with these rules):

  • The event is applied once the handler returns, after the handler’s own event. When a handler applies several events, they follow in that order, each followed by the events that its own handler applied.
  • While the aggregate is loaded from its events, the calls are ignored: the stored events already include the events the handler applied, right where they were recorded.
  • When a handler throws, the events it applied are dropped. The events whose handlers returned stay applied.
@EventHandler(AccountOpenedEvent)
onAccountOpened(event: AccountOpenedEvent) {
this.openedOn = event.openedOn;
// Applied after AccountOpenedEvent; ignored when the account is loaded from its events
this.applyEvent(new AccountCreditedEvent(event.welcomeBonus));
}

A handler that reads state after its applyEvent() call doesn’t see the changes of the event it applied yet: that event’s handler runs later, as it does when the aggregate is loaded.

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

Section titled “Guidelines for adding logic to your Aggregate”
  1. 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 applied to the current state of the aggregate and recorded as a pending change. The events are persisted to the event store when the aggregate is saved through its repository.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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:

    @EventHandler(AccountOwnerAddedEvent)
    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.