Commands
What is a Command?
A command expresses an intent to change state. It carries the data needed to validate and perform a domain action, but it does not guarantee the action will succeed. Commands are validated, passed to a handler, and — if business rules are satisfied — produce one or more events.
Commands in Bounda
Commands live inside domain/<aggregate>/commands/ and are defined as individual TypeScript files. Each command file exports:
payload— a function that receives Zod’szand returns a validation schema for the command data.handler— an async function that contains the business logic and returns event(s).
domain/
order/
commands/
place-order.ts
cancel-order.ts
Defining a Command
import type { Command } from "./+types/place-order";
export const payload: Command.PayloadFunction = ({ z }) =>
z.object({
customerId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
})),
});
export async function handler({ command, events }: Command.HandlerArgs) {
const { customerId, items } = command.payload;
const total = items.reduce((sum, item) => sum + item.quantity * item.price, 0);
return events.orderPlaced({
aggregateId: command.aggregateId,
customerId,
items,
total,
});
}
The payload function defines what data the command accepts. Bounda validates incoming data against this schema before the handler runs. If validation fails, the handler is never called.
Handler Arguments
The handler function receives an object with:
| Argument | Description |
|---|---|
command | The validated command with aggregateId, payload, and metadata |
events | Typed event builders for the aggregate (e.g., events.orderPlaced(...)) |
stateResolver | Async function that returns the current aggregate state |
Using State in Handlers
When a command needs to enforce rules based on existing state, use stateResolver():
export async function handler({ command, events, stateResolver }: Command.HandlerArgs) {
const state = await stateResolver();
if (state.status === "cancelled") {
throw new Error("Cannot confirm a cancelled order");
}
return events.orderConfirmed({ aggregateId: command.aggregateId });
}
Dispatching Commands
After bootstrapping the application, commands are available on the app.commands object. The method name is the camelCase form of the command file name:
await app.commands.placeOrder({
aggregateId: "order-123",
customerId: "cust-456",
items: [
{ productId: "prod-1", quantity: 2, price: 29.99 },
],
});
Commands with Collaborators
When a command needs external dependencies (e.g., an email sender, a payment gateway), use a folder structure instead of a single file:
domain/
order/
commands/
send-confirmation/
index.ts
notification-sender.console.ts
notification-sender.sendgrid.ts
The index.ts contains the payload and handler exports. The collaborator files provide swappable implementations. See Collaborators for details.
Returning Multiple Events
A handler can return an array of events when a single command produces multiple state changes:
export async function handler({ command, events }: Command.HandlerArgs) {
return [
events.orderPlaced({ aggregateId: command.aggregateId, ...command.payload }),
events.inventoryReserved({ aggregateId: command.aggregateId, items: command.payload.items }),
];
}
Related Concepts
- Events — the immutable facts produced by command handlers
- Collaborators — injecting external dependencies into commands
- Scheduled Commands — deferring command execution with a delay