Bounda is event sourcing and CQRS for TypeScript: commands, events and projections are small files, every type is inferred, and the app runs on one database. This page takes a new project from nothing to a tested command; Core concepts defines the words it uses.
Create a project
Section titled “Create a project”npm create bounda@latest my-shop asks where the app runs, which framework it uses and, on
Node, which database. Pass the answers after -- to skip the questions:
A Node script, on SQLite or PostgreSQL.
npm create bounda@latest my-shop -- --runtime node --framework none --database sqlitecd my-shopA React Router app with a page that dispatches from an action and reads from a loader, on SQLite or PostgreSQL.
npm create bounda@latest my-shop -- --runtime node --framework react-router --database sqlitecd my-shopA Cloudflare Worker with a JSON API and a Durable Object per tenant, whose store is the object’s own SQLite, so it takes no database.
npm create bounda@latest my-shop -- --runtime cloudflare --framework nonecd my-shopThe same React Router app, on Cloudflare: React Router in the Worker, and the store in a Durable Object per tenant.
npm create bounda@latest my-shop -- --runtime cloudflare --framework react-routercd my-shop@latest on purpose: npx reuses a create-bounda it cached earlier, and a stale one pins the
Bounda packages to whatever version it shipped with. With the defaults, a Node script on SQLite,
you get a project with one aggregate, one read model and a test:
Directoryapp/
Directorydomain/
Directoryorder/ the order aggregate
- order-placed.ts an event: payload and begin, which opens the order
Directorycommands/
- place-order.ts a command: payload and handler
Directoryread/
Directoryorders/ a read model
- view.ts its fields
Directoryprojections/
Directoryorder/
- order-placed.ts projects the order’s OrderPlaced
Directorybounda/
- personal-data-forgotten.ts clears what a forgotten customer wrote
Directoryqueries/
- list-orders.ts
Directorytests/
- orders.test.ts the app on an in-memory adapter
Directorysrc/
- main.ts boots the app and places an order
- bounda.config.ts storage and ports
Installing ran bounda generate for you (it is the prepare script), so the types are already
there. Run the test and the demo:
npm testnpm startWhat a module looks like
Section titled “What a module looks like”Every file exports the functions its concept needs and gets its argument types from a generated
+types module next to it:
import type { Command } from "./+types/place-order";
export const payload = ({ z, person, personal }: Command.PayloadArgs) => z.object({ orderId: z.uuid(), customerId: person(z.string().min(1)), total: z.number().positive(), deliveryNote: personal(z.string().min(1)).optional(), });
export const rejections = ({ command }: Command.RejectionsArgs) => ({ AlreadyPlaced: `Order ${command.aggregateId} was already placed`,});
export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => { if (state.status !== undefined) return reject("AlreadyPlaced"); const { customerId, total, deliveryNote } = command.payload; return [events.orderPlaced({ customerId, total, deliveryNote })];};command.payload is typed from the Zod schema above it, state from what the order’s events
return, and events only offers the events of this aggregate. rejections declares how the
command may say no, and reject only takes those codes. person and personal mark the
customer’s id and the note they wrote as personal data: stored
encrypted, gone for good with app.forget(customerId), which tests/orders.test.ts checks with
expectForgotten. order-placed.ts opens the order
with begin, so before it every field of state is undefined, and after it status and the
rest are always set. Nothing here is registered anywhere: the file’s place
and name are the declaration. The project layout guide has the whole
map.
Add a command
Section titled “Add a command”Let customers cancel.
-
Start the generator in watch mode in one terminal:
Terminal windowShell npm run dev -
Add the event:
app/domain/order/order-cancelled.tsTypeScript import type { Event } from "./+types/order-cancelled";export const payload = ({ z }: Event.PayloadArgs) => z.object({ reason: z.string() });export const evolve = () => ({ status: "cancelled" as const });evolvereturns the fields the event changes, merged over the order’s state. The generator adds"cancelled"to the type ofstatuson its own. -
Add the command:
app/domain/order/commands/cancel-order.tsTypeScript import type { Command } from "./+types/cancel-order";export const payload = ({ z }: Command.PayloadArgs) =>z.object({ orderId: z.uuid(), reason: z.string().min(1) });export const rejections = ({ state }: Command.RejectionsArgs) => ({NotPlaced: `Only placed orders can be cancelled; this one is ${state.status ?? "new"}`,});export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => {if (state.status === "cancelled") return [];if (state.status !== "placed") return reject("NotPlaced");return [events.orderCancelled({ reason: command.payload.reason })];};The watcher wrote
+types/order-cancelled.tsand+types/cancel-order.tsas you saved, andevents.orderCancelledappeared in every handler of the aggregate. -
Reflect the cancellation in the read model with a projection named after the event, in the folder of its aggregate:
app/read/orders/projections/order/order-cancelled.tsTypeScript import type { Projection } from "./+types/order-cancelled";export const project = async ({ event, table }: Projection.Args) => {await table.delete({ orderId: event.aggregateId });};
Test it
Section titled “Test it”createTestApp runs the whole app on an in-memory adapter with a clock that only moves when you
tell it to. runUntilIdle runs projections, policies and processes until nothing is left:
import { createTestApp } from "@bounda-dev/core/testing";import { expect, it } from "vitest";import { registry } from "../.bounda/registry.ts";
it("removes a cancelled order from the list", async () => { const { app } = await createTestApp({ registry }); const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01"; await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); await app.commands.cancelOrder({ orderId, reason: "changed my mind" }); await app.runUntilIdle();
expect(await app.queries.listOrders({ customerId: "ada" })).toEqual({ orders: [], total: 0 }); await app.stop();});npm testTesting goes further: advancing the clock for reminders and timeouts, running against a real database, and choosing which implementation of a dependency a test gets.
Run it for real
Section titled “Run it for real”src/main.ts shows the production path: boot() reads bounda.config.ts and the generated
registry, opens the database and returns the app, typed for your project. A script handles its
own exit, so it passes signals: false and stops the app when it is done:
import { boot } from "@bounda-dev/core/node";
const app = await boot({ signals: false });await app.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 });await app.runUntilIdle();await app.stop();Point bounda.config.ts at PostgreSQL when one process is not enough; the app does not change.
See adapters.
Working with an agent
Section titled “Working with an agent”A new project carries an AGENTS.md, which Claude Code, Codex, Cursor and other coding agents
read, telling the agent to read the docs before it writes a module. The docs it points at ship inside @bounda-dev/core, as
plain Markdown in node_modules/@bounda-dev/core/docs/, so the agent reads the ones for the
version you installed, not whatever it remembers. Every page here also has
Copy Markdown and Open in ChatGPT or Claude, and
llms.txt lists the whole site for an agent that is still
deciding.
Where next
Section titled “Where next”- Core concepts: every word the docs use, one line each.
- Project layout: every kind of module, with a template each.
- Read models: the view, the projections that fill it and the queries that read it.
- Reacting to events: when a policy is enough, when the work needs a process, deadlines and retries.
- Calling the outside world: where an effect goes, and how the idempotency key keeps it from happening twice.
- Sagas and compensation: a business transaction of several steps, and how each one is undone when a later one fails.
- Dead letters and failed processes: retrying or discarding a run that failed for good.
- Testing: an app in memory, a clock you move by hand, and assertions that do not flake.
- Changing an event’s shape: an upcaster next to the event, applied as old events are read.
- Bounda with React Router: actions that dispatch, loaders that query, and the onboarding example that puts it together.
- Deployment: roles, several instances, rebuilding a read model and observability.
- The storefront example: policies, a checkout that compensates when the payment fails, an effect after the commit and hand-written SQL in one small app.
- How Bounda runs: one global stream per store, the ceiling with numbers, and the way out when you reach it.
- Reference: the CLI, every configuration key, the conventions the generator reads, errors, observability and what is not there yet.