examples/onboarding in the repository is a React Router 8 app on Bounda. It runs on SQLite out
of the box and on PostgreSQL when DATABASE_URL is set.
git clone https://github.com/bounda-dev/boundacd bounda && pnpm install && pnpm build && pnpm generatecd examples/onboardingpnpm testpnpm devWhat happens when a user registers
Section titled “What happens when a user registers”- The
/registeraction dispatchesregisterUserand redirects to/users/:userId. The page already shows the user: the app in the context reads its own writes. UserRegisteredstarts the processuser-onboarding. A minute later the delayed policysend-welcome-email-on-user-registeredsends the email through the user’semailSenderport (itsconsoleimplementation in the demo,memoryin the tests) and dispatchesrecordWelcomeEmailSent, which appendsWelcomeEmailSent.- Activating the user completes the process. A registration nobody activates within a week hits
the process timeout, which dispatches
expireRegistration. - Two read models follow along:
users-directory, paginated bylistUsers, anduser-details, with the timestamps of every step.
Things worth copying
Section titled “Things worth copying”The integration in one line. vite.config.ts adds bounda() before reactRouter(); the
plugin generates the types and serves @bounda-dev/react-router/app, which root.tsx and the
routes import. Nothing in the app knows how Bounda boots.
Storage and ports from the environment. bounda.config.ts picks the adapter and
the email sender:
const url = process.env.DATABASE_URL;
export default defineConfig({ storage: url === undefined ? sqlite({ path: "./data/onboarding.db" }) : postgresql({ url }), ports: { user: { emailSender: process.env.EMAIL_SENDER === "memory" ? "memory" : "console" }, },});An implementation built from the environment. The console email sender exports create
instead of a default: it reads its sender address from EMAIL_FROM in env and writes through
the app’s logger, once, when the app starts:
import type { CreateImplementation } from "@bounda-dev/core";import type { EmailSender } from "../../email-sender.ts";
export const create: CreateImplementation<EmailSender> = ({ env, logger }) => { const from = env.EMAIL_FROM ?? "welcome@onboarding.localhost"; return async ({ to, name, idempotencyKey }) => { logger.info("welcome email", { from, to, name, idempotencyKey }); };};A delayed effect. The policy exports delay, read from WELCOME_EMAIL_DELAY, so the runtime
runs it a minute after UserRegistered was stored, with an idempotencyKey that survives its
retries. It is a policy rather than the process because the email does not depend on what happens
in that minute: a user activated before it is up completes the process and still gets the email.
A paginated query with defaults. Fields with .default() are optional for callers and always
present in the repository and the handler:
export const payload = ({ z }: Query.PayloadArgs) => z.object({ page: z.int().positive().default(1), pageSize: z.int().positive().max(100).default(20), });
export const repository = async ({ table, page, pageSize }: Query.RepositoryArgs) => { const [users, total, active] = await Promise.all([ table.findMany({ orderBy: { field: "registeredAt", direction: "desc" }, limit: pageSize, offset: (page - 1) * pageSize, }), table.count(), table.count({ status: "active" }), ]); return { users, total, active };};The loader calls listUsers({ page }) and the component gets users, total, active, page
and pages typed.
Domain errors as form feedback. Every action returns failure(error) from its catch, which
turns ValidationError into a 400 with the issues and a command’s rejection, a DomainError, into
a 409 with its code; the route components render actionData.error.
Time in tests. tests/onboarding.test.ts registers a user, advances the clock a minute and
checks the welcome email was sent; advances a week and checks the registration expired:
clock.advance(7 * DAY);await app.runUntilIdle();expect(await app.queries.getUserDetails({ userId })).toMatchObject({ status: "expired" });The domain is tested on the in-memory adapter; the web app is compiled in CI with
react-router typegen, tsc and react-router build.