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 }],
});
await app.processUntilIdle();
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 and returns. The event dispatcher then asynchronously runs the projection, updating the read model. Calling app.processUntilIdle() ensures all pending events are processed before querying. In a running application (e.g., a web server) with the dispatcher active, the read model updates automatically within milliseconds. See Eventual Consistency for details.