Reacting to Events with Policies
Overview
Projections update read models. Policies handle side effects — things that should happen in response to an event but are not part of the aggregate’s state. Sending notifications, dispatching follow-up commands, and integrating with external systems are all policy responsibilities.
In this step, you will add a task-assigned event, an assign-task command, and a policy that reacts when a task is assigned.
Add the Task Assigned Event
Create src/domain/task/task-assigned.ts:
import type { Event } from "./+types/task-assigned";
export const payload: Event.PayloadFunction = ({ z }) =>
z.object({
assignee: z.string().min(1),
});
export function apply({ state, event }: Event.ApplierArgs) {
return { ...state, assignee: event.payload.assignee };
}
This event records who a task was assigned to and adds the assignee field to the aggregate state.
Add the Assign Task Command
Create src/domain/task/commands/assign-task.ts:
import type { Command } from "./+types/assign-task";
export const payload: Command.PayloadFunction = ({ z }) =>
z.object({
assignee: z.string().min(1),
});
export async function handler({ command, stateResolver, events }: Command.HandlerArgs) {
const state = await stateResolver();
if (state.status === "completed") {
throw new Error("Cannot assign a completed task");
}
return events.taskAssigned({
aggregateId: command.aggregateId,
assignee: command.payload.assignee,
});
}
The command validates that a completed task cannot be reassigned, then emits the taskAssigned event.
Create the Policy
Policies follow a naming convention: <action>-on-<event>.ts. This makes it clear what the policy does and which event triggers it.
Create src/domain/task/policies/notify-assignee-on-task-assigned.ts:
import type { Policy } from "./+types/notify-assignee-on-task-assigned";
export async function handler({ event }: Policy.HandlerArgs) {
console.log(`Notification: Task ${event.aggregateId} assigned to ${event.payload.assignee}`);
}
Whenever a task-assigned event is published, this policy runs. Right now it logs to the console. In a real application, you would call an email service, push notification API, or dispatch another command.
Note: Policies run after the event has been persisted. If a policy fails, the event is not rolled back. This is by design — events represent facts that already happened.
Regenerate Types
npx bounda generate
Try It Out
import { boot } from "@bounda-dev/core";
const app = await boot();
await app.commands.createTask({
aggregateId: "task-1",
title: "Write documentation",
});
await app.commands.assignTask({
aggregateId: "task-1",
assignee: "alice",
});
After the assignTask call, you will see the log output from the policy:
Notification: Task task-1 assigned to alice
Policy Retry Configuration
Policies that call external services can fail. Bounda supports retry configuration to handle transient failures. Update your bounda.config.ts to add a retry strategy:
import type { Config } from "@bounda-dev/config";
export default {
rootDir: "src",
domain: {
task: {
eventStore: { type: "in-memory" },
},
},
read: {
"task-board": {
type: "sqlite",
path: "./db/task-board.db",
},
},
policies: {
retry: { retryStrategy: "exponential", maxRetries: 3 },
maxChainDepth: 5,
},
} satisfies Config;
The retryStrategy: "exponential" setting increases the delay between retries. The maxRetries caps the number of attempts.
The maxChainDepth setting limits how many times policies can trigger commands that trigger more events that trigger more policies. This prevents infinite loops when policies dispatch follow-up commands.
Policies Can Dispatch Commands
A policy handler also receives a commands object, allowing it to dispatch new commands in response to an event:
export async function handler({ event, commands }: Policy.HandlerArgs) {
await commands.someFollowUpCommand({
aggregateId: event.aggregateId,
someData: event.payload.someField,
});
}
This creates an event-driven chain: event A triggers policy X, which dispatches command B, which emits event C. The maxChainDepth configuration prevents this chain from running indefinitely.
What We Built
- Added the
task-assignedevent andassign-taskcommand to expand the task aggregate. - Created a policy that reacts to task assignment events and logs a notification.
- Configured retry behavior and chain depth limits for policies.
- Learned that policies can dispatch commands to create event-driven workflows.
Policies handle individual reactions to events. For workflows that span multiple events over time, you need process managers. That is the next step.