Event sourcing and CQRS for TypeScript without the ceremony.
Your tables keep the last write. Bounda keeps every change, so any view can be rebuilt from it and any past state explained. Commands, events and projections are plain TypeScript modules, every type is inferred from them, and it all runs on one database with no broker, or on one Cloudflare Durable Object per tenant.
npm create bounda@latest my-appbounda.dev · Documentation · Getting started · Core concepts · Deploy to Cloudflare
A command decides; the event it returns becomes the state. The file's place and name are the
declaration, and bounda generate writes the types next to it:
// app/domain/order/commands/place-order.ts
import type { Command } from "./+types/place-order";
export const payload = ({ z }: Command.PayloadArgs) =>
z.object({ orderId: z.uuid(), customerId: z.string().min(1), total: z.number().positive() });
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");
return [events.orderPlaced({ customerId: command.payload.customerId, total: command.payload.total })];
};command.payload is typed from the schema, state from the order's events, events only offers
this aggregate's events and reject only the codes above. A projection turns OrderPlaced into a
row a query reads; the getting started guide builds
the whole slice in fifteen minutes.
- Types are inferred. A file's place and name are its declaration, and
bounda generateinfers every type from them: no command bus, repository or registry to keep in sync. Change a field and the compiler points at every place that breaks. - Read models rebuild. A wrong dashboard is a fixed projection and
bounda rebuild, not a migration script. The events are never touched. - Workflows have a home. A workflow of several steps is a process next to its aggregate, with its own state and deadlines, not a state machine buried in a handler. Policies react to events, and retries, dead letters and upcasters come with the runtime.
- Nothing else to run. Events, read models and the work still pending share one database: PostgreSQL, SQLite or libSQL. Start there; when a read model outgrows it, it gets a database of its own. On Cloudflare, the database is a Durable Object per tenant.
React Router has its own integration, and the runtime reports OpenTelemetry spans and metrics. How Bounda runs says how far one store goes, and what is not there yet is one honest list.
0.x. Until 1.0 the API can still change between minor versions (0.1 to 0.2), and every change that breaks something is called out in the changelog of the package it touches.
| Package | Purpose |
|---|---|
@bounda-dev/core |
Runtime and public API, with the docs as Markdown for your agent |
@bounda-dev/cli |
bounda CLI: reads the layout, writes the registry and the types |
@bounda-dev/sqlite |
SQLite and libSQL storage |
@bounda-dev/postgresql |
PostgreSQL storage |
@bounda-dev/cloudflare |
A Durable Object per tenant on Cloudflare |
@bounda-dev/react-router |
React Router integration and its Vite plugin |
create-bounda |
Project scaffolder |
Each package README carries its own mutation score; the badge above is the whole repository.
@bounda-dev/cloudflare has none: its tests run inside workerd, where Stryker cannot mutate.
Two runnable examples live here: examples/storefront, on Node and SQLite,
and examples/onboarding, on React Router.
Issues and pull requests are welcome. CONTRIBUTING.md says how to set the repository up, what a pull request needs and how to report a security problem.
Apache 2.0. See LICENSE.