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
- Project Structure — Learn the full set of conventions and directory patterns.