Building Read Models
Overview
The write side (commands and events) is optimized for enforcing business rules. The read side is optimized for answering questions. Bounda separates these concerns using the CQRS pattern: Command Query Responsibility Segregation.
A read model consists of three parts:
- View — defines the schema (columns and types) of the read-side table.
- Projections — listen for events and write data into the view.
- Queries — read data from the view and return it to callers.
In this step, you will build a task-board read model that tracks all tasks and supports filtering by status.
Define the View
The view describes the shape of the data that will be stored. Create src/read/task-board/view.ts:
import type { View } from "@bounda-dev/core";
export const fields: View.FieldsFunction = () => ({
id: { type: "string", primaryKey: true },
title: { type: "string", required: true },
description: { type: "string" },
status: { type: "string", required: true, index: true },
createdAt: { type: "string", required: true },
completedAt: { type: "string" },
});
Each field declares its type and optional constraints. The index: true on status tells Bounda to create a database index for faster filtering.
Bounda uses this definition to create the underlying SQLite table automatically — you do not write any SQL for table creation.
Create the Task Created Projection
Projections listen for specific events and update the read model. Create src/read/task-board/projections/task-created.ts:
import type { Projection } from "./+types/task-created";
export function project({ event, projector }: Projection.HandlerArgs) {
projector.insert({
id: event.aggregateId,
title: event.payload.title,
description: event.payload.description ?? "",
status: "open",
createdAt: event.timestamp,
});
}
When a task-created event is published, this projection inserts a new row into the task board table.
Note: The projection file name must match the event name it handles.
task-created.tsin the projections directory automatically subscribes totask-createdevents.
Create the Task Completed Projection
Create src/read/task-board/projections/task-completed.ts:
import type { Projection } from "./+types/task-completed";
export function project({ event, projector }: Projection.HandlerArgs) {
projector.update(
{ id: event.aggregateId },
{ status: "completed", completedAt: event.timestamp },
);
}
This projection finds the existing row by aggregate ID and updates its status and completion timestamp. The projector object provides insert, update, and delete operations that are type-safe against the view schema.
Create the Get Tasks Query
Queries define how callers retrieve data from the read model. Create src/read/task-board/queries/get-tasks.ts:
import type { Query } from "./+types/get-tasks";
export const payload: Query.PayloadFunction = ({ z }) =>
z.object({
status: z.enum(["open", "completed"]).optional(),
});
export const repository: Query.Repository = ({ status, client }) => {
if (status) {
return client.prepare("SELECT * FROM task_board WHERE status = ?").all(status);
}
return client.prepare("SELECT * FROM task_board").all();
};
export function handler({ repositoryData }: Query.HandlerArgs) {
return repositoryData ?? [];
}
A query file exports up to three things:
payload— validates the query parameters (here, an optional status filter).repository— executes the database query using the SQLite client.handler— transforms or enriches the raw repository data before returning it.
The separation between repository and handler keeps database access isolated from business logic. You can test the handler independently by providing mock repository data.
Regenerate and Use
Run the generator to pick up the new view, projections, and query:
npx bounda generate
Now you can query the read model after dispatching commands:
import { boot } from "@bounda-dev/core";
const app = await boot();
await app.commands.createTask({
aggregateId: "task-1",
title: "Write documentation",
});
await app.commands.createTask({
aggregateId: "task-2",
title: "Review pull request",
});
await app.commands.completeTask({ aggregateId: "task-1" });
const openTasks = await app.queries.getTasks({ status: "open" });
const allTasks = await app.queries.getTasks({});
The openTasks result contains only "task-2". The allTasks result contains both tasks with their current statuses.
Write Side vs. Read Side
| Aspect | Write side | Read side |
|---|---|---|
| Purpose | Enforce rules, record facts | Answer queries efficiently |
| Storage | Event store (append-only) | SQLite table (mutable) |
| Updated by | Command handlers | Projection handlers |
| Read by | stateResolver() in commands | Queries |
The two sides are eventually consistent. Events flow from the write side to the read side through projections. In the in-memory event store used here, this happens synchronously. With a production event store, there may be a short delay.
What We Built
- Defined a
task-boardview with typed fields and an indexed status column. - Created projections for
task-createdandtask-completedthat keep the read model in sync with events. - Created a
get-tasksquery with optional status filtering. - Queried the read model to retrieve tasks by status.
The read model gives you a fast, queryable view of your domain data. Next, you will learn how to react to events with policies to trigger side effects.
Next step: Reacting to Events with Policies