/ Docs

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.ts in the projections directory automatically subscribes to task-created events.

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

AspectWrite sideRead side
PurposeEnforce rules, record factsAnswer queries efficiently
StorageEvent store (append-only)SQLite table (mutable)
Updated byCommand handlersProjection handlers
Read bystateResolver() in commandsQueries

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-board view with typed fields and an indexed status column.
  • Created projections for task-created and task-completed that keep the read model in sync with events.
  • Created a get-tasks query 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