Skip to content

    Getting started01

    Getting started

    Create a project, run it, add a command, test it. Fifteen minutes.

    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.

    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.

    Terminal windowShell
    npm create bounda@latest my-shop -- --runtime node --framework none --database sqlite
    cd 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:

    Terminal windowShell
    npm test
    npm start

    Every file exports the functions its concept needs and gets its argument types from a generated +types module next to it:

    app/domain/order/commands/place-order.tsTypeScript
    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.

    Let customers cancel.

    1. Start the generator in watch mode in one terminal:

      Terminal windowShell
      npm run dev
    2. 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 });

      evolve returns the fields the event changes, merged over the order’s state. The generator adds "cancelled" to the type of status on its own.

    3. 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.ts and +types/cancel-order.ts as you saved, and events.orderCancelled appeared in every handler of the aggregate.

    4. 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 });
      };

    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:

    tests/cancel.test.tsTypeScript
    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();
    });
    Terminal windowShell
    npm test

    Testing goes further: advancing the clock for reminders and timeouts, running against a real database, and choosing which implementation of a dependency a test gets.

    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:

    TypeScript
    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.

    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.