/ Docs

Defining Your First Aggregate

Overview

An aggregate groups related state changes around a single identity. In this tutorial, that identity is a task. Instead of storing the current state directly, Bounda stores the events that produce the state. The current state is rebuilt by replaying those events in order.

In this step, you will define two events — task-created and task-completed — that together describe the full lifecycle of a task so far.

How Events Work

Each event file exports two things:

  1. payload — a function that receives Zod and returns a schema describing the data the event carries.
  2. apply — a pure function that takes the current state and the event, and returns the next state.

When Bounda needs the current state of a task, it loads all events for that aggregate ID and calls each apply function in sequence, starting from an empty object.

Create the Task Created Event

Create the file src/domain/task/task-created.ts:

import type { Event } from "./+types/task-created";

export const payload: Event.PayloadFunction = ({ z }) =>
  z.object({
    title: z.string().min(1),
    description: z.string().optional(),
  });

export function apply({ state, event }: Event.ApplierArgs) {
  return {
    ...state,
    title: event.payload.title,
    description: event.payload.description ?? "",
    status: "open",
    createdAt: event.timestamp,
  };
}

The payload schema enforces that every task-created event must carry a non-empty title. The description is optional and defaults to an empty string in the apply function.

The apply function sets the initial state of a task: a title, a description, a status of "open", and a creation timestamp sourced from the event itself.

Note: The +types/ directory is generated by the Bounda CLI. You will never create files there manually.

Create the Task Completed Event

Create the file src/domain/task/task-completed.ts:

import type { Event } from "./+types/task-completed";

export const payload: Event.PayloadFunction = ({ z }) => z.object({});

export function apply({ state }: Event.ApplierArgs) {
  return { ...state, status: "completed", completedAt: new Date().toISOString() };
}

This event carries no payload — the act of completing a task requires no additional data beyond the aggregate ID. The apply function transitions the status from "open" to "completed" and records when it happened.

Regenerate Types

After adding new event files, run the code generator so that TypeScript picks up the new types:

npx bounda generate

You should see output confirming that types for task-created and task-completed were generated inside .bounda.

How State Replay Works

Imagine a task with aggregate ID "task-1" that has two events stored:

  1. task-created with { title: "Write docs", description: "Tutorial content" }
  2. task-completed with {}

Bounda replays them in order:

{}  -->  apply(task-created)  -->  { title: "Write docs", description: "Tutorial content", status: "open", createdAt: "..." }
    -->  apply(task-completed) --> { title: "Write docs", description: "Tutorial content", status: "completed", createdAt: "...", completedAt: "..." }

The final object is the current state of the aggregate. No mutable database row was updated — the state was derived entirely from the event history.

What We Built

  • Created the task-created event with a validated payload (title and optional description) and an apply function that sets the initial task state.
  • Created the task-completed event with an empty payload and an apply function that marks the task as completed.
  • Learned that aggregate state is rebuilt by replaying events in sequence.

With events in place, the aggregate knows what can happen to a task. Next, you will write commands that decide when those events should happen.

Next step: Writing Commands