Skip to main content
This page explains how a Voyant application actually runs. It goes deeper than the architecture overview: where the code lives, how a request travels through it, how a deployment is assembled at build time, and why tenancy is a deployment fact rather than a runtime check. Read it once and the rest of the fundamentals will click into place.

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 three concerns map to three different lifecycles too. Packages version independently and you consume them as ordinary dependencies. The app shell is yours to own and upgrade. Transport adapters are the seam where the two meet, so they are deliberately boring.

The runtime: Cloudflare Workers

The first-party operator starter runs on a serverless-first stack.
LayerTechnology
RuntimeCloudflare Workers (edge isolates)
DatabasePostgreSQL, accessed through Drizzle ORM
API transportHono, with optional framework route helpers
FrontendTanStack Start and React
AuthBetter Auth in the starters; core packages stay auth-provider agnostic
Workers are not long-lived Node processes. Each isolate has a startup budget, and Cloudflare parses and executes a Worker’s module global scope before any request routing happens. That single fact shapes how entrypoints are written.

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:
Do not statically import the Hono API app, scheduled-job modules, or workflow definition files from a Cloudflare SSR entrypoint.
Instead, the entrypoint imports only lightweight constants and lazy-loads the heavy graph inside the branch that needs it.
// Entrypoint: keep module scope tiny, lazy-load per branch.
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(request.url)

    if (url.pathname.startsWith("/api/")) {
      // Cached dynamic import: heavy API graph loads on first /api hit,
      // then stays warm in the isolate. It never runs at startup.
      const { app } = await import("./api/app")
      return app.fetch(request, env, ctx)
    }

    // ...SSR branch, also lazy where it can be...
  },

  async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext) {
    // Route by cron string, then dynamic-import only the matching job.
    const job = await import(`./jobs/${matchCron(event.cron)}`)
    return job.run(env, ctx)
  },
}
The patterns to prefer:
  • 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.
The patterns to avoid: a static 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.
1

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

Entrypoint routing

The fetch handler inspects the URL. SSR HTML, /api/*, and other branches are separated here. The heavy branch lazy-imports its graph.
3

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

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

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

Response shaping

The service result is shaped to the wire contract and returned. The -contracts package keeps that shape identical on both ends, so the -react client deserializes it with no guesswork.
Two execution shapes sit alongside the request path and are deliberately kept out of it:
  • 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.
Voyant owns the execution model (workflow, schedule, daemon) but not every runtime implementation. Each execution class has its own runtime adapters, so Voyant Cloud and self-hosters can pick different backends per class without changing the model. The placement rule is short: if it is a multi-step business flow with retries and resumability, it is a workflow; if it only decides when something starts, it is a schedule; if it is a long-running stateful integration loop, it is a daemon.

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 dynamic require 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.
// The shape of a thin deployment: config + the providers it chose + its own 20%.
export default createOperatorApp({
  config: voyantConfig, // branding, locale, modules, flags
  providers: { card: netopiaCardStart, storage: r2(env), flights: demoConnector(env) },
  extensions: [], // deployment-local customizations, never framework-owned files
})
The split is the whole point:
  • 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.
Custom work, the “20%”, layers through first-class seams (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 no requireOrgId 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.
The honest costs were accepted too: there is no defense in depth, self-hosters must understand they own the isolation guarantee, and a future shared-tier would require revisiting this ADR before any such work starts. Adding in-process scoping later is feasible; ripping it out of every read path if it bloated performance would be harder, so the framework defaults to the real product reality of single-tenant deployments.

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.
Because the boundary is the same, moving between the two is a deployment-and-provisioning concern, not a rewrite. The app shell, the config manifest, and the packages do not change.

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.