examples/storefront in the repository is a complete app on Node and SQLite. It is small enough
to read in one sitting and touches everything Bounda does.
git clone https://github.com/bounda-dev/boundacd bounda && pnpm install && pnpm build && pnpm generatecd examples/storefrontpnpm testpnpm startWhat happens when an order is placed
Section titled “What happens when an order is placed”The customer pays through a payment link, in the style of Stripe: the app creates a payment intent, the customer pays it whenever they like, and the provider tells the app how it went through webhooks.
placeOrdervalidates the items, computes the total and appendsOrderPlaced.- The policy
send-confirmation-on-order-placedsends the confirmation through the order’snotifierport, then dispatchesrecordConfirmationSent, which appendsConfirmationSent.bounda.config.tspicks theconsoleimplementation for the demo, and each test passes its own double, which records what was sent. - The policy
schedule-reminder-on-order-placeddispatchessendReminderwith a delay of a day. The reminder is a scheduled command; when it runs, the handler appendsReminderSentonly if the order is stillplaced. - The process
order-lifecyclestarts and dispatchesrequestPayment, whose handler creates the intent through thepaymentaggregate’sgatewayport. The process gives the customer 72 hours to pay, as a deadline in its state. - The provider’s webhooks are commands on the payment:
markPaymentProcessing,settlePaymentanddeclinePayment. The process follows the payment’s events, which carry the order’s id asorderId, so they reach its instance with nocorrelate:PaymentProcessinglocks the order (paying, which refuses a cancellation),PaymentSettledmarks it paid andPaymentDeclinedcancels it. - Once the order is paid the process dispatches
fulfillOrder. When the order is cancelled, whoever cancelled it, the process cancels the payment, and a payment that settles after it was cancelled is refunded by the policyrefund-on-refund-requested. - Two read models follow along:
order-summary, with a query written in SQL and the payment’s status, andmy-orders.
Sagas and compensation walks through this flow step by step: what each step compensates, and what happens when the webhooks arrive late, twice or out of order.
Neither aggregate has a state.ts: their state is inferred from what their events return.
order-placed.ts and payment-requested.ts open their aggregates with begin, so a handler
that has checked state.status reads state.customerId or state.intentId as a string, and
state.status === undefined means the order or the payment does not exist yet.
Things worth copying
Section titled “Things worth copying”An effect after the commit. The confirmation goes out from a policy, once OrderPlaced is
stored, not from the command that places the order: a command handler can run again on a
concurrency conflict, and it runs before anything is decided. The policy hands the notifier its
idempotencyKey, the same on every retry, and reports back with a command whose handler ignores a
second report:
export const handler = async ({ event, commands, notifier, idempotencyKey }: Policy.HandlerArgs) => { await notifier({ orderId: event.aggregateId, customerId: event.payload.customerId, total: event.payload.total, idempotencyKey, }); await commands.recordConfirmationSent({ orderId: event.aggregateId });};A port with two implementations. order/notifier.ts declares the contract, a callable
Notifier that takes NotifierArgs; console.ts and memory.ts in
order/infrastructure/notifier/ implement it. The config decides,
and only one of the two names compiles:
export default defineConfig({ storage: sqlite({ path: process.env.STOREFRONT_DB ?? "./data/storefront.db" }), ports: { order: { notifier: process.env.NOTIFIER === "memory" ? "memory" : "console" }, },});A delay from the environment. The reminder’s delay comes from REMINDER_DELAY, a string
that asDuration checks where it is used
(delaying a command).
Time in tests. The clock of createTestApp moves only when told to, so a reminder a day away
and a payment window three days away are two lines:
clock.advance(24 * HOUR);await app.runUntilIdle();A query in SQL. list-orders-by-customer.ts reads the table directly through client and
still gets rows typed from the view’s fields:
export const repository = ({ client, customerId }: Query.RepositoryArgs) => client.all( "SELECT * FROM bounda_rm_order_summary WHERE customer_id = ? ORDER BY placed_at, order_id", [customerId], );Booting from the generated registry. tests/boot.test.ts starts the app twice on the same
SQLite file through boot() and reads back what the first run wrote.