/ Docs

Aggregates

What is an Aggregate?

An aggregate groups related state changes around a single identity. It acts as a consistency boundary: all mutations to a piece of domain state flow through the aggregate’s events, ensuring that state transitions are explicit, ordered, and reproducible.

In event-sourced systems the aggregate does not store its current state directly. Instead, the state is rebuilt by replaying every event that has occurred for a given aggregate instance, in order, through their respective apply functions.

Aggregates in Bounda

Bounda defines aggregates implicitly. Rather than declaring an aggregate class, you create a directory under domain/ named after the aggregate and populate it with event files. Each event file exports a payload (a Zod schema) and an apply function that describes how that event transforms the aggregate state.

domain/
  order/
    order-placed.ts
    order-confirmed.ts
    order-shipped.ts
    commands/
      place-order.ts

The directory name (order) becomes the aggregate name. The event files at the aggregate root level define the full set of state transitions the aggregate supports.

Defining Events for an Aggregate

Each event file lives directly inside domain/<aggregate>/ and exports two things:

  • payload — a function that receives Zod’s z and returns a schema describing the event data.
  • apply — a pure function that takes the current state and the event, and returns the next state.
import type { Event } from "./+types/order-placed";

export const payload: Event.PayloadFunction = ({ z }) =>
  z.object({
    customerId: z.string(),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number().positive(),
      price: z.number().positive(),
    })),
    total: z.number().positive(),
  });

export function apply({ state, event }: Event.ApplierArgs) {
  return {
    ...state,
    customerId: event.payload.customerId,
    items: event.payload.items,
    status: "placed",
    total: event.payload.total,
  };
}

How State is Built

Aggregate state starts as an empty object. When you need the current state of an aggregate instance, Bounda loads every event for that aggregateId and applies them sequentially:

{} -> apply(orderPlaced) -> apply(orderConfirmed) -> apply(orderShipped) -> current state

Each apply call is pure — it takes the previous state and the event, and returns the new state without side effects. This makes the state deterministic and fully reproducible from the event history.

Accessing State from Commands

Command handlers can access the current aggregate state through stateResolver(). This function replays all existing events for the aggregate and returns the resulting state.

export async function handler({ command, events, stateResolver }: Command.HandlerArgs) {
  const state = await stateResolver();

  if (state.status === "shipped") {
    throw new Error("Cannot modify a shipped order");
  }

  return events.orderConfirmed({ aggregateId: command.aggregateId });
}

Note: stateResolver() is async because it may need to load events from the event store. Call it only when you need the current state.

Guidelines

  • Keep aggregates focused on a single domain concept. If a directory accumulates many unrelated events, consider splitting into separate aggregates.
  • The apply function must be pure. It receives state and event, returns new state. No async operations, no side effects.
  • All state fields should originate from event payloads. The apply function is the single source of truth for state shape.
  • Use commands to enforce business rules before events are produced. Once an event is emitted, it is treated as an immutable fact.
  • Events — the building blocks that define aggregate state transitions
  • Commands — express intent to change aggregate state