/ Docs

Quickstart

Build a Minimal Order System

This guide walks through building a tiny event-sourced order system from scratch. By the end you will have a command that places an order, an event that records it, a read model that projects it, and a query that retrieves it.

Step 1: Install Dependencies

npm install @bounda-dev/core @bounda-dev/config
npm install --save-dev @bounda-dev/codegen @bounda-dev/ops

Step 2: Create the Configuration

Create bounda.config.ts at your project root. This sets up an in-memory event store for the order aggregate and a SQLite-backed read model for the order summary view:

import type { Config } from "@bounda-dev/config";

export default {
  domain: {
    order: {
      eventStore: { type: "in-memory" },
    },
  },
  read: {
    "order-summary": { type: "sqlite", path: "./db/order-summary.db" },
  },
} satisfies Config;

Step 3: Define an Event

Create app/domain/order/order-placed.ts. Events describe what happened in your domain. Each event exports a payload schema and an apply function that updates the aggregate state:

import type { Event } from "./+types/order-placed";

export const payload: Event.PayloadFunction = ({ z }) =>
  z.object({
    customerId: z.string(),
    items: z.array(
      z.object({
        productId: z.string(),
        quantity: z.number().positive(),
        price: z.number().positive(),
      }),
    ),
    total: z.number().positive(),
  });

export function apply({ state, event }: Event.ApplierArgs) {
  return {
    ...state,
    customerId: event.payload.customerId,
    items: event.payload.items,
    status: "placed",
    total: event.payload.total,
    placedAt: event.timestamp,
  };
}

Step 4: Define a Command

Create app/domain/order/commands/place-order.ts. Commands validate input and emit events:

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,
  });
}

Step 5: Define a View

Create app/read/order-summary/view.ts. A view declares the schema for your read model table:

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

export const fields: View.FieldsFunction = () => ({
  id: { type: "string", primaryKey: true },
  customerId: { type: "string", required: true },
  status: { type: "string", required: true },
  total: { type: "number", required: true },
  placedAt: { type: "string", required: true },
});

Step 6: Define a Projection

Create app/read/order-summary/projections/order-placed.ts. Projections handle events and write to the read model:

import type { Projection } from "./+types/order-placed";

export function project({ event, projector }: Projection.HandlerArgs) {
  projector.insert({
    id: event.aggregateId,
    customerId: event.payload.customerId,
    status: "placed",
    total: event.payload.total,
    placedAt: event.timestamp,
  });
}

Step 7: Define a Query

Create app/read/order-summary/queries/get-order-summary.ts. Queries fetch data from the read model and return it:

import type { Query } from "./+types/get-order-summary";

export const payload: Query.PayloadFunction = ({ z }) =>
  z.object({ orderId: z.string().uuid() });

export const repository: Query.Repository = ({ orderId, client }) => {
  return client.prepare("SELECT * FROM order_summary WHERE id = ?").get(orderId) ?? null;
};

export async function handler({ repositoryData }: Query.HandlerArgs) {
  return repositoryData ?? null;
}

Step 8: Generate Types and Boot

Run code generation to produce the +types/ files referenced in your imports:

npx bounda generate

Then create an entry point (for example, app/main.ts) and boot the application:

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

const app = await boot();

Step 9: Dispatch and Query

With the application booted, dispatch a command and query the read model:

await app.commands.placeOrder({
  aggregateId: "order-1",
  customerId: "cust-1",
  items: [{ productId: "prod-1", quantity: 1, price: 30.0 }],
});

const summary = await app.queries.getOrderSummary({ orderId: "order-1" });
console.log(summary);

The command handler validates the input, calculates the total, and emits an orderPlaced event. Bounda persists the event, updates the aggregate state, runs the projection against the read model, and makes the data available through the query.

Next Steps