/ Docs

Writing Commands

Overview

Events describe what happened. Commands express intent — they are requests to make something happen. A command handler validates the request, checks business rules against the current aggregate state, and returns the events that should be recorded.

In this step, you will create two commands: create-task and complete-task.

How Commands Work

Each command file exports:

  1. payload — a Zod schema that validates the incoming data.
  2. handler — an async function that receives the validated command, a state resolver, and an event factory. It returns one or more events.

The handler does not write to a database directly. It returns events, and Bounda persists them to the event store.

Create the Create Task Command

Create the file src/domain/task/commands/create-task.ts:

import type { Command } from "./+types/create-task";

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

export async function handler({ command, events }: Command.HandlerArgs) {
  return events.taskCreated({
    aggregateId: command.aggregateId,
    title: command.payload.title,
    description: command.payload.description,
  });
}

This command accepts a title and an optional description, then emits a taskCreated event. There are no preconditions to check because a new task has no prior state.

Notice that the events object provides type-safe factory functions for every event defined in the aggregate. The function names are camelCase versions of the event file names.

Create the Complete Task Command

Create the file src/domain/task/commands/complete-task.ts:

import type { Command } from "./+types/complete-task";

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

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

  if (state.status === "completed") {
    throw new Error("Task is already completed");
  }

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

This command has a business rule: a task that is already completed cannot be completed again. The stateResolver function loads the current aggregate state by replaying all stored events for the given aggregate ID. If the rule passes, the handler emits a taskCompleted event.

Note: stateResolver() is a function, not a property. Bounda defers state loading until you call it, so commands that do not need state (like create-task) avoid the overhead entirely.

Regenerate Types

Run the generator after adding new command files:

npx bounda generate

Try It Out

Create a script or use a REPL to boot the application and dispatch commands:

import { boot } from "@bounda-dev/core";

const app = await boot();

await app.commands.createTask({
  aggregateId: "task-1",
  title: "Write documentation",
});

await app.commands.completeTask({ aggregateId: "task-1" });

The first call stores a task-created event. The second call loads the aggregate state, verifies the task is not already completed, and stores a task-completed event.

If you try to complete the same task again:

await app.commands.completeTask({ aggregateId: "task-1" });
// throws: Error("Task is already completed")

The business rule enforced in the handler prevents the invalid state transition.

The Command Flow

Here is the full lifecycle of a command dispatch:

  1. The payload is validated against the Zod schema.
  2. The handler function is called with the validated command.
  3. The handler optionally loads aggregate state via stateResolver().
  4. The handler applies business rules and returns events.
  5. Bounda persists the returned events to the event store.
  6. The events are published to projections, policies, and process managers.

What We Built

  • Created the create-task command that validates input and emits a taskCreated event.
  • Created the complete-task command that loads aggregate state, enforces a business rule, and emits a taskCompleted event.
  • Booted the application and dispatched commands to verify the flow end to end.

Commands are the write side of the application. Next, you will build the read side — a query-optimized view of your task data.

Next step: Building Read Models