Event sourcing and CQRS for TypeScript

Event sourcing without the ceremony.

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-app

Command handler

A command asks.

PlaceOrder arrives with a payload its schema checks. The handler reads the order's current state and decides.

Event store

The answer is a fact.

OrderPlaced is appended to the global stream at position 4,212. Facts are never edited or deleted.

Evolve

State is a fold.

The global stream interleaves every aggregate. evolve folds only order/7f3a's events, in order: created, item added, placed.

Projection

Facts become rows.

The order-placed projection upserts one row into the orders read model.

Read model

Queries read rows.

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

Replay from position 1.

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

Same events. Same rows.

Change a projection, rebuild, and the read model catches up from history.

The code

Three files. Their place and name are the declaration.

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
Command handlerdecides
// 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 }),
  ];
};
Eventopens the order
// 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,
});
Projectionwrites a row
// 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

Nothing is rewritten.

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.

Streamorder/7f3a
PositionEventAmount
004 198OrderCreated–
004 206ItemAdded129.00
004 212OrderPlaced–
004 219PriceCorrected(20.00)
Total109.00
Positions are global. The gaps between them are other streams' facts.

bounda generate

Types are inferred.

File place and name are the declaration. There is no registry to maintain.

bounda rebuild

Read models rebuild.

A wrong dashboard is a fixed projection and a rebuild, not a migration script. The events are never touched.

Processes and policies

Workflows have a home.

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

Nothing else to run.

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

Start with one aggregate.

One command scaffolds a working app: an aggregate, a read model and a test that already passes.

npm create bounda@latest my-app