A Bounda app is a tree of small modules. Each file is one concept and exports the functions that
concept needs; the folder it sits in and its name say what it is. bounda generate reads the tree
and writes the registry and the types, so there is no central file to keep in sync. Only app/domain and
app/read are read; anything else in app/, such as the routes of a web framework, is ignored.
Directoryapp/
Directorydomain/
Directoryorder/ an aggregate
- state.ts optional: initialState and aggregateId
- order-placed.ts an event: payload, and begin or evolve
- order-placed.upcast.ts optional: how older payloads become today’s
- order-paid.ts
- inventory.ts a port: the interface of what the handlers call outside
- money.ts any other module: a value object, a domain service
Directorycommands/
- pay-order.ts a command: payload and handler
- place-order.ts
Directorypolicies/
- issue-invoice-on-order-paid.ts reacts to OrderPaid
- notify-on-order-placed.ts
Directoryprocesses/
Directoryorder-payment/ a process
- index.ts config and state
- on-order-placed.ts handler for OrderPlaced
- on-order-paid.ts handler for OrderPaid
- at-next-reminder.ts handler for the deadline nextReminder
- at-timeout.ts handler for the timeout
Directoryinfrastructure/
Directoryinventory/ the implementations of the port inventory.ts
- http.ts an implementation
- fake.ts another; bounda.config.ts picks one
Directoryread/
Directoryorder-summary/ a read model
- view.ts fields
- rates.ts a port, for its queries
Directoryprojections/
Directoryorder/ events of the order aggregate
- order-placed.ts reacts to order’s OrderPlaced
Directoryqueries/
- get-order.ts payload, repository, handler
Directoryinfrastructure/
Directoryrates/ the implementations of the port rates.ts
- ecb.ts
File and directory names are kebab-case: lower-case letters, digits and dashes. The generator turns them into the names your code sees:
| File | Registry key | Type name |
|---|---|---|
order-placed.ts |
orderPlaced |
OrderPlaced |
place-order.ts |
placeOrder |
PlaceOrder |
issue-invoice-on-order-paid.ts |
issueInvoiceOnOrderPaid |
reacts to OrderPaid |
on-order-paid.ts |
handler for orderPaid |
|
audit-log.ts with infrastructure/audit-log/ |
port auditLog |
AuditLog, exported by audit-log.ts |
infrastructure/audit-log/in-memory.ts |
implementation "in-memory", as the config names it |
|
policies/payment/refund-on-payment-failed.ts |
paymentRefundOnPaymentFailed |
reacts to payment’s PaymentFailed |
order-summary/ |
orderSummary |
OrderSummaryRow |
Files that start with _ or ., tests (*.test.ts, *.test-d.ts), declarations (*.d.ts) and
+types directories are ignored.
An event belongs to its aggregate: order’s Cancelled and subscription’s Cancelled are two
events, and a handler that reacts to one never sees the other. A file refers to the events of the
aggregate it sits in; a folder named after another aggregate holds what reacts to that
aggregate’s events. A read model has no aggregate of its own, so its projections always sit in one
of those folders.
Aggregates: app/domain/<aggregate>/
Section titled “Aggregates: app/domain/<aggregate>/”A module at the root of the aggregate is an event when it exports payload, begin or evolve,
and nothing else at run time: those names are an event’s alone. It is a port when
infrastructure/ holds a directory named after it. Anything else there, files and directories
alike, is yours: a value object, a domain service, a helper the handlers import, which the
generator leaves alone. state.ts and <event>.upcast.ts are the only other names it reads.
A mistake that leaves the layout valid, such as an event that lost its evolve export or a
directory named policy instead of policies, gets a warning instead of a failure; the full list
is in Conventions.
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,});import type { Event } from "./+types/order-paid";
export const evolve = ({ event }: Event.EvolveArgs) => ({ status: "paid" as const, paidAt: event.timestamp,});payload is optional; an event without one has an empty payload. Its PayloadArgs carry
personal and person besides z, for the fields that hold
personal data. An event returns the state
fields it sets, merged over the state one level deep: a field returned as undefined keeps its
value, so clear one with null. begin opens the aggregate and gets the event alone, since
there is no state before it; evolve folds a later event and gets the state too. An event that can
do both exports both.
Once one of an aggregate’s events exports begin, the aggregate starts with such an event. A
command that would start it with another event, or put an event that only exports begin on an
aggregate that exists, throws CreationOrderError and stores nothing: a bug in the handler, which
a reaction does not retry.
When a payload changes shape after events are stored, <event>.upcast.ts next to it brings the
old ones up to date as they are read: see Changing an event’s shape.
Without state.ts, the generator infers the aggregate’s state from what its events return, each
field typed as the union of what the events assign. With the two events above, a command handler
sees either an order that does not exist yet, every field undefined, or one where the fields
begin sets are always there: status: "placed" | "paid", customerId: string and
total: number, with paidAt?: string. Any field begin sets tells the two apart:
if (state.status === undefined) return reject("NotPlaced");state.total; // numberevolve only runs on an aggregate that exists, so its state is the created one. Without any
begin, every field is optional, since any event could come first.
Add state.ts when you want an initial value before the first event, or when a field’s type is
not visible from outside its module:
export const initialState = { status: "new" as "new" | "placed" | "paid", customerId: "", total: 0,};export const aggregateId = "orderId";aggregateId names the payload field that identifies the aggregate. It defaults to
<aggregate>Id, so orderId for order.
Ports: <port>.ts
Section titled “Ports: <port>.ts”A port is what the aggregate’s handlers call outside the app: an inventory service, a mailer, a
payment provider. A read model has ports too, for its queries
(read models). It is a module at the root of the aggregate that
exports its interface, named after the file in PascalCase (a type alias of that name works too),
and every handler of the aggregate receives it under that name: its commands, its policies and
every handler of its processes. Its implementations live in infrastructure/<port>/, one file
each, with a default export or with a create function.
export interface AvailableArgs { readonly skus: readonly string[];}
export interface Inventory { available(args: AvailableArgs): Promise<boolean>;}import type { Inventory } from "../../inventory.ts";
export default { available: async () => true,} satisfies Inventory;An implementation is plain TypeScript: it imports the port and fulfils it, and the satisfies
gives completion and an error in place. The generated registry checks every implementation against
the interface anyway, so one that does not fulfil it fails tsc either way. Each operation takes
one object, XxxArgs, as every handler of Bounda does: a field can be added without breaking a
call, and idempotencyKey travels as one more. A port with one operation can be callable instead,
export interface Notifier { (args: NotifierArgs): Promise<void> }, and the handler then calls
notifier({ ... }).
An implementation with state, such as a client, a connection pool or a secret, exports create
instead of a default, never both, typed as CreateImplementation of its port. The app calls it
once when it is created, and every handler receives what it returns; it may be async.
import type { CreateImplementation } from "@bounda-dev/core";import type { Inventory } from "../../inventory.ts";
export const create: CreateImplementation<Inventory> = ({ env, logger }) => { const url = env.INVENTORY_URL; if (url === undefined) throw new Error("INVENTORY_URL is not set"); return { available: async ({ skus }) => { const response = await fetch(`${url}/available`, { method: "POST", body: JSON.stringify(skus), }); logger.debug("inventory checked", { status: response.status }); return response.ok; }, };};create receives the host’s environment as env (process.env after .env is loaded under
boot(), the Durable Object’s env on Cloudflare, what a test passes to createTestApp), the
name of the store the app serves as tenant (the Durable Object’s name on Cloudflare, what a test
passes, none under boot(): see
an account per tenant), the app’s
logger and its clock. It runs once per app: once per process under boot(), once per Durable
Object, once per createTestApp. When what it returns has [Symbol.asyncDispose],
app.stop() calls it after closing the storage, the last one built first; one that fails to
close is logged and the rest still close. A default export is never closed, since every app in
the process shares the module.
Open no connection and read no environment at the top of an implementation module: the registry
imports every implementation, also those the configuration does not choose. Only the chosen one’s
create runs. Keeping SDKs and clients under infrastructure/ also keeps the rest of the
aggregate free of them, which a lint rule can hold by path.
bounda.config.ts picks one implementation per port, by aggregate or read model and port in camelCase, with
the implementation’s file name as the value. The generator emits the type of that section, so a
name that does not exist does not compile, and a port with several implementations must be
named: no implementation is a default. A port with one may be left out.
export default defineConfig({ storage: sqlite({ path: "./data/app.db" }), ports: { order: { inventory: process.env.INVENTORY === "fake" ? "fake" : "http" }, },});The value has to be one of the names, so a choice made from the environment says both branches,
as above; a plain process.env.INVENTORY ?? "http" is a string and does not compile.
A port cannot be named after an event of the aggregate, nor after an argument a handler already
receives (reserved names); the generator says
which. Domain logic the handlers share but
that has no implementations to choose from is not a port: it is a module of the aggregate like any
other, which the handlers import. A provider two aggregates use is two ports, one in each, with the
contract each aggregate needs; the client they share lives outside app/domain, in
app/lib/stripe.ts for instance, and each implementation imports it.
The runtime chooses once, when the app is created, and a config that names a port, an implementation, an aggregate or a read model that does not exist fails at boot as well.
Tests do not read this section: createTestApp takes its own ports, a double or a file
name per port, and gives a port it leaves out no implementation at all
(Testing).
Commands: commands/
Section titled “Commands: commands/”A command is commands/<name>.ts.
import type { Command } from "./+types/place-order";
export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid(), customerId: z.string(), skus: z.array(z.string()).min(1) });
export const rejections = ({ command }: Command.RejectionsArgs) => ({ AlreadyPlaced: `Order ${command.aggregateId} was already placed`, OutOfStock: "Some of the items are out of stock",});
export const handler = async ({ command, state, events, inventory, reject }: Command.HandlerArgs) => { if (state.status !== undefined) return reject("AlreadyPlaced"); if (!(await inventory.available({ skus: command.payload.skus }))) return reject("OutOfStock"); return [events.orderPlaced({ customerId: command.payload.customerId, skus: command.payload.skus })];};The handler receives the aggregate’s ports next to command, state and events. In a
command they are what the handler reads from outside to decide: stock, prices, a feature flag.
Effects on the world (a charge, an email) belong in the policy or process that reacts to the
event, after it is stored. A handler can run more than once for one command and has a time
limit, so it also receives idempotencyKey and signal for those calls; see
calling the outside world.
Rejections
Section titled “Rejections”A command that may say no declares how in rejections: each key is a code, named after the reason
(NotOpen, never as a fact like OrderClosed), and each value its message. The runtime calls it
when the handler rejects, with the command and the state the handler saw, so the message can
tell why. Only a module that exports rejections gets reject, and reject only takes its codes.
reject(code) returns the DomainError the caller gets, with the code in rejected;
return reject(code) and throw reject(code) do the same, the second from a helper the handler
calls. Pass a second argument to give that rejection another message. A DomainError is never
built with new. If rejections throws, or has no message for the code, the rejection still
stands, with the code as its message, and the runtime logs a warning.
A rejection stores nothing and is not retried. app.commands throws it to the caller; a policy or
a process gets it as a value instead: commands.<name>() resolves with rejected set to the code,
or false when the aggregate decided (see
reacting to events).
Repeating what is already done is not a rejection: return [], so a retry, a webhook that arrives
twice or a reaction that runs again changes nothing. Before an aggregate exists state.status is
undefined, so a message reads it with a fallback, state.status ?? "new". A “no” the business
remembers, or that someone else listens to, is an event instead: see
rejection or event.
Policies: policies/
Section titled “Policies: policies/”A policy reacts to an event with commands. <action>-on-<event>.ts names the event: the longest
event the name ends with, so put-on-hold-on-payment-failed.ts reacts to PaymentFailed. A
policy whose name ends with no event exports on with the event type names it reacts to, and
bounda generate warns about one that has -on- but names no event, a misspelling most likely.
import type { Policy } from "./+types/issue-invoice-on-order-paid";
export const handler = async ({ event, commands }: Policy.HandlerArgs) => { await commands.issueInvoice({ orderId: event.aggregateId, method: event.payload.method });};A policy that reacts to another aggregate’s event sits in a folder named after that aggregate:
app/domain/order/policies/payment/cancel-order-on-payment-failed.ts is an order policy that
reacts to payment’s PaymentFailed. Its key carries the aggregate, paymentCancelOrderOnPaymentFailed,
so it never collides with an order policy of the same name. The policies and processes of an
aggregate need distinct keys, since the key is what the runtime records a handled event under,
and a policy cannot be named after an aggregate
(what the generator refuses).
Policies dispatch through commands, the typed facade of every command in the app. A command can
be delayed: commands.sendReminder({ orderId }, { delay: "24h" }). The call’s type follows: one
with delay resolves with scheduled: true and executeAt, one without with what the command
decided (eventTypes, and position from app.commands), with no scheduled case to rule out
first; see delaying a command.
A policy receives the aggregate’s ports next to event and commands: an order policy
gets order’s, also when it reacts to another aggregate’s event.
import type { Policy } from "./+types/notify-on-order-placed";
export const handler = async ({ event, mailer, idempotencyKey }: Policy.HandlerArgs) => { await mailer.send({ to: event.payload.customerId, text: `Order ${event.aggregateId} placed`, idempotencyKey, });};A policy that exports delay (export const delay = "1m", or asDuration(...) for a value
from the environment) runs that long after the event instead of as soon as it is read; see
delaying a policy.
A policy runs after the events it reacts to are stored, and at least once, with an inbox (what the runtime promises): a call it makes must be safe to repeat.
Processes: processes/<name>/
Section titled “Processes: processes/<name>/”A process follows an instance of its aggregate over time. index.ts says which events start and
complete it and how long it may stay open; on-<event>.ts handles an event while it is open;
at-<deadline>.ts runs when a deadline of its state comes due, and at-timeout.ts when the time
is up. config receives every event of the app by aggregate, events.order.OrderPlaced, so a
process can start, continue or finish on another aggregate’s events. A handler returns only the
fields of the state that change, or nothing.
import type { Process } from "./+types/index";
export const config = ({ events }: Process.ConfigArgs) => ({ startedBy: [events.order.OrderPlaced], completedBy: [events.order.OrderPaid, events.order.OrderCancelled], timeout: "48h",});
export const state = ({ z, deadline, instant }: Process.StateArgs) => z.object({ reminders: z.int().default(0), nextReminder: deadline(), paidAt: instant() });A field of the state declared with deadline() is a deadline, and at-<field>.ts is its handler:
nextReminder runs at-next-reminder.ts, which receives Process.DeadlineArgs. instant()
declares a moment the process only records, and runs nothing. The name timeout is kept for
config.timeout, whose handler is at-timeout.ts, and the two must come in pairs
(what boot refuses). How a handler sets, moves and cancels a
deadline, and when each runs, is in deadlines.
import type { Process } from "./+types/at-next-reminder";
export const handler = async ({ state, aggregateId, commands, after }: Process.DeadlineArgs) => { await commands.sendReminder({ orderId: aggregateId }); return { reminders: state.reminders + 1, nextReminder: state.reminders < 2 ? after("24h") : null, };};A handler for another aggregate’s event sits in a folder named after that aggregate,
processes/order-payment/payment/on-payment-failed.ts. Such an event carries that aggregate’s id,
not the order’s. When its payload declares the order’s id field, orderId (or the aggregateId
of the order’s state.ts), it belongs to the instance that field names, and to none when it is
null: nothing else to write. Boot reads the field from a plain z.object; behind a
.transform() what is stored cannot be read, so such an event needs correlate. Otherwise index.ts says which instance it belongs to in
correlate, which gets from and returns from.<aggregate>.<Event>(…) for each such event: a
function from the event to the id of the process’s own aggregate, or null to ignore it. It
also overrides the id field. Boot refuses an event of another aggregate the process listens to
with neither.
export const correlate = ({ from }: Process.CorrelateArgs) => [ from.shipping.ParcelLost((event) => event.payload.reference),];What happens to an event no open instance takes, and how the state a handler returns is checked, is in which instance an event reaches.
Every handler of the process, the at- ones included, receives the aggregate’s ports,
as its commands and policies do.
Read models: app/read/<read-model>/
Section titled “Read models: app/read/<read-model>/”A read model is view.ts (its table), projections/<aggregate>/<event>.ts (what fills it) and
queries/<name>.ts (what reads it), with ports of its own for its queries.
Read models shows each one.
Generated files
Section titled “Generated files”bounda generate writes .bounda/ and a +types/ directory next to each module. They are
derived from your code: keep them out of git, include .bounda/**/* in tsconfig.json and run
the generator before anything type-checks. Conventions
says what each file holds and how to set it up; create-bounda does it for you.