The layered boundary
Voyant keeps one hard separation, and almost everything else follows from it.- Packages (
@voyant-travel/*) hold reusable business logic: schemas, domain services, route surfaces, contracts, and adapters. They are framework-agnostic. A package does not know whether it runs on Cloudflare Workers or a Node server, and it does not own deployment shape. - App shells (the starters, and your forked or generated deployment) own the things a package cannot: UI, auth wiring, the deployment manifest, runtime configuration, secrets, and the migration history.
- Transport adapters stay thin. A Hono route handler validates input, calls a shared domain service, and shapes the response. It does not hold business logic. The same service can be driven by a different transport without rewriting the domain.
This boundary is why the same
@voyant-travel/bookings logic runs unchanged whether you self-host on Node or deploy to Voyant Cloud on Workers. The domain code never learned where it lives.The runtime: Cloudflare Workers
The first-partyoperator starter runs on a serverless-first stack.
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers (edge isolates) |
| Database | PostgreSQL, accessed through Drizzle ORM |
| API transport | Hono, with optional framework route helpers |
| Frontend | TanStack Start and React |
| Auth | Better Auth in the starters; core packages stay auth-provider agnostic |
Entrypoints must stay thin
Because module-scope imports run during startup validation, the imports in a Cloudflare SSR entrypoint count against the startup CPU limit, even when the request only serves SSR HTML. The operator starter can install enough modules, schemas, workflows, and local API routes that eagerly importing the whole API graph pushes startup validation over the CPU limit. So the rule is strict: Instead, the entrypoint imports only lightweight constants and lazy-loads the heavy graph inside the branch that needs it.- Cache a dynamic
import("./api/app")inside the/api/*branch. - Cache dynamic workflow and client imports inside the API or scheduled branches.
- Import only lightweight constants from leaf files.
- Route scheduled events by cron string, then dynamic-import the matching job.
import { app as apiApp } from "./api/app", a side-effecting import "./workflows.js", or importing a package root just to read one runtime constant.
A mechanical check (scripts/check-cloudflare-entrypoints.mjs, part of pnpm verify:architecture) enforces this, and a Wrangler startup profiler lane (check:startup and measure:startup) lets you measure the budget before and after entrypoint changes. Lazy loading does not hurt warm performance: once an isolate has imported the API graph on its first /api hit, it stays cached for subsequent requests.
The request lifecycle
A request to a deployed Voyant app travels through a predictable set of layers.Isolate startup (once per cold isolate)
Cloudflare loads the Worker, executes module global scope, and validates the startup budget. Only lightweight constants are present at this point. Nothing domain-heavy has loaded yet.
Entrypoint routing
The
fetch handler inspects the URL. SSR HTML, /api/*, and other branches are separated here. The heavy branch lazy-imports its graph.Transport (Hono)
For
/api/*, the Hono app receives the request. Middleware runs, the route matches, and the handler parses and validates input against the module’s contract.Domain service
The route calls a shared domain service from the owning module. This is where business rules live. The service talks to its own tables through Drizzle.
Database
Drizzle issues SQL against the single Postgres database for this deployment. Cross-module reads stitch through links and fetchers (see Links), not through cross-module foreign keys.
- Workflows are durable, step-based orchestrations for multi-step business processes with waits, retries, and resumability. They run on an edge-friendly durable runtime or on a self-host Node runtime. See Workflows.
- Schedules are triggers. A schedule decides when something starts. It should start a workflow or poke a daemon, never reimplement the business process inline.
How a deployed app is composed
A Voyant deployment is not a runtime plugin loader. It is a build-time composition, which the Workers constraint requires: there is no dynamicrequire of arbitrary modules at the edge.
The deployment declares what it is in voyant.config.ts: branding and locale, the modules it mounts, additionalSchemas, plugins, featureFlags, and admin metadata. The framework reads that manifest to assemble the standard module set, derive the schema list, mount the generated routes, and wire the admin shell. Provider choices (payments, storage, connectors) are injected by the deployment, never baked into the framework.
The direction the framework is moving captures the intent precisely: a standard deployment shrinks to its identity.
- Config-derived wiring (which modules, mount order, surfaces) belongs to the framework, derived from
config.modules. - Provider and deployment wiring (db, env, KMS, the injected card-payment starter, connectors) stays in the deployment and is passed in.
src/modules, src/extensions, src/links, src/admin) that are auto-discovered at build time. None of it edits a framework-owned file, so it survives upgrades. See custom modules for how that discovery works.
One database, one runtime per organization
Voyant’s tenancy model is deliberately simple and is the most important architectural decision in the framework.One Postgres database plus one runtime per organization. Tenancy is enforced at the deployment boundary, not by in-process middleware.This is ADR-0001, and it is an explicit, accepted decision rather than an accident.
What it means concretely
The framework ships no in-process organization-scoping. There is norequireOrgId middleware in @voyant-travel/hono, no org-scoped wrapper around the Drizzle client, and no query interceptor that auto-applies an organizationId filter. When you ask “how is one customer’s data isolated from another’s?”, the answer is separate database, separate compute runtime.
Voyant Cloud’s provisioning is the enforcement: it issues one database and one Worker (or one Node runtime) per customer organization, and its provisioning code must refuse to point two organizations at the same database. Self-hosters inherit the identical topology and own that invariant themselves.
A column named
organizationId is still fine as data: “which organization owns this booking” within one deployment is metadata, not isolation. The forbidden use is threading organizationId through a query to partition data across customers. That is the deployment boundary’s job, and there is no cross-tenant data in the database to partition in the first place.Why this model
The trade-off was weighed and chosen for clear reasons.- No per-query overhead. Every read stays one round-trip with no injected predicate.
- No confused-deputy bugs. There is no shared schema where a forgotten filter could leak across tenants, because there is no cross-tenant data in the same database.
- Hard to bypass accidentally. A misconfigured Worker cannot read another customer’s database unless someone hard-codes the wrong connection string.
- Module authors carry no tenancy concern. A package author writes a list query and ships it; isolation is provisioning’s problem.
Self-host versus Voyant Cloud
The framework is the same in both; only ownership of the runtime differs.Self-host
You own the infrastructure: the Postgres database, the Worker or Node runtime, secrets, and the deployment lifecycle. You run the same one-database-per-organization topology and are responsible for the isolation invariant. Workflows and daemons run on runtimes you choose per execution class.
Voyant Cloud
The platform provisions and manages the per-organization database and runtime for you, enforces the no-shared-database invariant in provisioning, and hosts workflows on its durable runtime. The domain packages and your config are identical to the self-host case.
Next steps
Modules
The anatomy of a module: what it owns and how modules compose.
Configuration
How
voyant.config.ts drives build-time composition.Data models
Schema authoring, money, indexes, and migrations.
Workflows
Durable, step-based orchestration off the request path.