Event sourcing
without the ceremony.

Your events.
Your logic.
Zero boilerplate.

The setup is on us. Traditional implementations bury your domain under layers of infrastructure: command buses, aggregate repositories, event dispatchers, projection runners. Bounda handles all of that wiring. You write only what matters — handlers, appliers, and projections — the runtime connects them.

Types inferred, never written. Bounda derives TypeScript types directly from your Zod schemas — no manual interfaces, no duplication. Every function signature is fully typed and autocompleted in your editor the moment you define the schema. Change a field and the compiler tells you every place that breaks — across commands, events, projections, and queries — before you ship.

Sagas are first-class citizens. Multi-step business workflows, retries, timeouts, and compensations belong in dedicated sagas and policies — not in ad-hoc state machines buried inside handlers. Bounda gives them a home and a lifecycle.

Bounda
in a nutshell

01 Define the command
// domain/order/commands/place-order.ts
  
export async function handler({ command, events }: Command.HandlerArgs) {
  const { aggregateId, customerId, items } = command.payload;
  const total = items.reduce((s, i) => s + i.quantity * i.price, 0);
  
  return events.orderPlaced({aggregateId, customerId, items, total});
}
// domain/order/commands/place-order.ts
  
export async function handler({ command, events }: Command.HandlerArgs) {
  const { aggregateId, customerId, items } = command.payload;
  const total = items.reduce((s, i) => s + i.quantity * i.price, 0);
  
  return events.orderPlaced({aggregateId, customerId, items, total});
}
02 Define the event
// domain/order/order-placed.ts

export const payload: Event.PayloadFunction = ({ z }) => {
  return z.object({
    customerId: z.string(),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number(),
      price: z.number(),
    })),
    total: z.number().positive(),
  });
};

export function apply({ state, event }: Event.ApplierArgs) {
  return { ...state, ...event.payload, status: "placed", placedAt: event.timestamp };
}
// domain/order/order-placed.ts

export const payload: Event.PayloadFunction = ({ z }) => {
  return z.object({
    customerId: z.string(),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number(),
      price: z.number(),
    })),
    total: z.number().positive(),
  });
};

export function apply({ state, event }: Event.ApplierArgs) {
  return { ...state, ...event.payload, status: "placed", placedAt: event.timestamp };
}
03 Project the read model
// read/my-orders/projections/order-placed.ts

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,
  });
}
// read/my-orders/projections/order-placed.ts

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,
  });
}
04 Define the view
// read/my-orders/views/view.ts

export const fields: View.FieldsFunction = () => ({
  id: { type: "string", primaryKey: true },
  customerId: { type: "string", index: true },
  status: { type: "string", index: true },
  total: { type: "number", required: true },
  placedAt: { type: "string", required: true },
});
// read/my-orders/views/view.ts

export const fields: View.FieldsFunction = () => ({
  id: { type: "string", primaryKey: true },
  customerId: { type: "string", index: true },
  status: { type: "string", index: true },
  total: { type: "number", required: true },
  placedAt: { type: "string", required: true },
});
05 Query the result
// read/my-orders/queries/get-order.ts

export const payload: Query.PayloadFunction = ({ z }) => {
  return 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) {
  if (!repositoryData) return null;
  
  return { ...repositoryData, items: JSON.parse(repositoryData.items) };
}
// read/my-orders/queries/get-order.ts

export const payload: Query.PayloadFunction = ({ z }) => {
  return 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) {
  if (!repositoryData) return null;
  
  return { ...repositoryData, items: JSON.parse(repositoryData.items) };
}

Up and running in seconds

$ npx create-bounda@latest my-app
Read the docs →