Command handler
A command asks.
PlaceOrder arrives with a payload its schema checks. The handler reads the order's current state and decides.
Event sourcing and CQRS for TypeScript
Your tables keep the last write. Bounda keeps every change. Rebuild any view from it, on one database.
Your tables keep the last write. Bounda keeps every change. Rebuild any view, explain any past state. Plain TypeScript modules, inferred types, one database, no broker.
npm create bounda@latest my-appCommand handler
PlaceOrder arrives with a payload its schema checks. The handler reads the order's current state and decides.
Event store
OrderPlaced is appended to the global stream at position 4,212. Facts are never edited or deleted.
Evolve
The global stream interleaves every aggregate. evolve folds only order/7f3a's events, in order: created, item added, placed.
Projection
The order-placed projection upserts one row into the orders read model.
Read model
Reads go to the read model and never touch the event store. Commands go in on one side, queries come out the other.
bounda rebuild orders
Drop a read model, rewind to the first fact and read the global stream into it again. The events stay exactly as they were.
Global stream
Change a projection, rebuild, and the read model catches up from history.
The code
A command handler decides, an event becomes the state, a projection writes a row. bounda generate reads the folders and infers every type, so there is no registry to keep in sync.
app/ ├─ domain/order/ │ ├─ commands/place-order.ts │ └─ order-placed.ts └─ read/orders/ └─ projections/order-placed.ts
// 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 }),
];
};// app/domain/order/order-placed.ts
import type { Event } from "./+types/order-placed";
export const payload = ({ z }: Event.PayloadArgs) =>
z.object({ customerId: z.string(), total: z.number().positive() });
export const begin = ({ event }: Event.BeginArgs) => ({
status: "placed" as const,
customerId: event.payload.customerId,
total: event.payload.total,
});// app/read/orders/projections/order-placed.ts
import type { Projection } from "./+types/order-placed";
export const project = async ({ event, table }: Projection.Args) => {
await table.upsert({
orderId: event.aggregateId,
customerId: event.payload.customerId,
total: event.payload.total,
placedAt: new Date(event.timestamp),
});
};The global stream
A wrong price is not edited. The correction is a new fact at the end of the stream, and the total is read from every fact in order.
order/7f3a| Position | Event | Amount |
|---|---|---|
| 004 198 | OrderCreated | – |
| 004 206 | ItemAdded | 129.00 |
| 004 212 | OrderPlaced | – |
| 004 219 | PriceCorrected | (20.00) |
| Total | 109.00 |
bounda generate
File place and name are the declaration. There is no registry to maintain.
bounda rebuild
A wrong dashboard is a fixed projection and a rebuild, not a migration script. The events are never touched.
Processes and policies
A workflow of several steps is a process next to its aggregate, with its own state, not a state machine buried in a handler.
One database
Events, read models and the work still pending share one database. Start there; when a read model outgrows it, it gets a database of its own.
Start
One command scaffolds a working app: an aggregate, a read model and a test that already passes.
npm create bounda@latest my-app