Skip to main content
A module is the primary unit of architecture in Voyant. Almost everything you build or compose is a module, and the rest of the vocabulary (providers, adapters, extensions, plugins) exists to keep that primary unit clean. This page goes deep on what a module is, the package family around it, how modules stay isolated yet compose, and how to tell a module apart from the things that are not modules.

What a module owns

A module packages one bounded capability and owns everything that capability needs to function on its own. A module owns:
  • Its data model. The Drizzle tables that hold the canonical state of its domain, plus the local foreign keys, indexes, constraints, and relations between its own tables.
  • Its services. The domain logic that enforces the rules of that subdomain. This is where business behavior lives, not in the transport layer.
  • Its route surfaces. The Hono routes that expose those services over HTTP. The routes are thin: validate, call a service, shape the response.
  • Its local domain logic. Validation, view-model helpers, and the small internal helpers a capability needs.
The test is ownership of canonical state. If a table represents the canonical state of one capability, it belongs inside that capability’s module rather than being spread across packages.
// Canonical-state ownership in practice
bookings/   →  bookings, bookingParticipants, bookingItems
finance/    →  invoices, payments
legal/      →  contracts, contractAttachments

Travel modules versus infrastructure modules

Modules fall into two broad categories, and naming them apart keeps the architecture legible.
  • Travel (domain) modules define the travel language of the framework: bookings, catalog, commerce, inventory, operations, finance, legal, distribution, quotes, and the verticals like cruises, charters, and accommodations.
  • Infrastructure modules provide technical support capabilities: notifications, auth/identity, storage, and verification.
Business capabilities are travel modules; technical support capabilities are infrastructure modules. Both are still modules, with the same anatomy.

The package family

A single domain rarely ships as one package. A mature module is usually a small family of packages that version together but separate concerns.

Core module

@voyant-travel/bookingsSchema, services, and routes. Framework-agnostic and headless. The source of truth for the capability.

Contracts

@voyant-travel/bookings-contractsThe wire types shared between server and client, so request and response shapes have one source of truth.

React

@voyant-travel/bookings-reactHooks, clients, providers, query keys, view-model helpers, and reusable components built on the contract.
The -react package is consumed as an ordinary dependency, the same way you would consume any other library. It wraps the module’s HTTP contract so you get typed hooks instead of hand-written fetch calls. Some families expose extra entry points where a surface needs them, for example bookings requirements under @voyant-travel/bookings-react/requirements or checkout UI under @voyant-travel/finance-react/checkout-ui.
The contracts package is what makes the React client typed and stable. The server route and the React hook both import the same -contracts shape, so a change to the wire format breaks the build on both sides at once instead of silently drifting.
Package names are chosen to reveal the role: @voyant-travel/bookings is a core module, @voyant-travel/storefront-react is a frontend runtime package, @voyant-travel/admin is the shared admin shell, @voyant-travel/plugin-netopia is a distribution bundle. The name does not have to carry the full taxonomy, but it should not hide the role either.

Module isolation

A module is a separately publishable package, and that boundary is enforced through one rule about relationships.
Intra-domain foreign keys are fine. Cross-domain references must go through a link.
Inside a module boundary you use ordinary relational modeling: real Postgres foreign keys with .references(() => other.id, ...), Drizzle relations, and junction tables where needed. Do not avoid normal relational design inside a module just because Voyant also has a link system. Across a module boundary you do not add a direct foreign key, and you do not import another module’s schema to establish a hard relational dependency. Instead each side exports a linkable definition and the deployment declares the association with defineLink. See Links for the full mechanism. The reason is the package boundary itself: a cross-package .references() call forces schema co-installation. A consumer who installs one vertical but not the module that owns the referenced table cannot create that foreign key, which breaks module-as-a-package portability. Links keep the wiring explicit at the deployment layer, where it belongs. A module should also expose one main public service surface. It will contain many internal helpers, but those are not the supported cross-package API. Keep the public package surface smaller than the internal implementation surface.

How modules compose

Composition is build-time, because Cloudflare Workers compose statically. The framework reads voyant.config.ts, takes the modules listed in config.modules, and assembles them into the standard set: their schemas feed the derived schema list, their routes mount under the API, and their admin metadata feeds the dashboard nav. See Configuration for how the manifest drives this. Custom modules a deployment adds are discovered the same way, statically, through a Vite glob over src/modules/*/index.ts. Vite compiles import.meta.glob to static imports at build time, so it satisfies the Workers constraint with no runtime resolution, and the glob costs nothing while it is empty.
// src/api/composition.ts (deployment-owned): build-time discovery
const discoveredModules = modulesFromGlob<OperatorCapabilities>(
  import.meta.glob("../modules/*/index.ts", { eager: true }),
)
export const deploymentLocalModules = { ...discoveredModules /* + hand-wired */ }
Modules read each other’s data through the query graph, not through schema coupling: base records come from each module’s own fetchers, associations resolve through the shared link service, and the final read shape is stitched in runtime code. Application-layer traversal is the default cross-module read path. A projection (a derived, denormalized read model) is introduced only when one concrete, measured read path proves traversal is the real bottleneck, and even then it is owned, narrow, and never a second source of truth.

Modules versus providers, adapters, extensions, and plugins

The vocabulary is intentionally small. When you introduce a new reusable capability, choose the narrowest category that fits, in this order: provider, adapter, extension, module, plugin bundle. Starting at “plugin” inflates a simple seam into a meta-framework.
CategoryWhat it isWhen you reach for it
ProviderA narrow, swappable implementation behind a contract”How do I swap one implementation for another?” Payments, notifications, storage, bank-transfer instruction resolvers.
AdapterAn integration package that talks to an external vendorThe package exists primarily to talk to an outside system: Netopia, SmartBill, a CMS sync. It may expose providers, a small extension, and webhook wiring.
ExtensionAdds or modifies behavior around an existing moduleFinance sync hooks, a supplier-specific booking tweak, an admin widget for an existing module. It does not introduce a new bounded capability.
ModuleA new bounded capability with its own records and lifecycleThe thing you are building has canonical state and behavior of its own.
PluginA distribution bundleYou want to ship a reusable bundle (modules, extensions, providers, routes, admin contributions) across projects.
Two points worth holding onto:
  • Distribution is a separate decision from runtime semantics. A package can ship as a plugin bundle while still being, at runtime, primarily a provider or an extension. “Should this be packaged as a plugin?” and “is this a module, provider, or extension?” are different questions.
  • A provider is the swap point. Providers stay injected by the deployment rather than baked into the framework. That is how a deployment chooses Netopia for payments or R2 for storage without the framework knowing which vendor it got.
Do not make “plugin” the default answer to every customization. An extension that adds one route to an existing module is an extension, not a plugin. Reserve plugins for genuinely reusable cross-project bundles.

Generating a module

Scaffold a new core module package with the CLI:
voyant generate module loyalty
This creates a package under packages/loyalty with the standard module layout (schema, service, routes, and the inferred-type exports). From there you author the capability and register it in voyant.config.ts so the framework composes it. To extend a deployment without forking the framework, generate the module into the deployment’s discovery seam instead:
voyant generate module loyalty --dir src/modules
The directory name (loyalty) becomes the module’s composition key. The folder shape is small and predictable:
src/modules/loyalty/
  index.ts       # default-exports the module → auto-mounted
  schema.ts      # Drizzle tables (optional)  → auto-migrated
  routes.ts      # Hono routes
  service.ts     # business logic
  validation.ts
You default-export the module from index.ts. defineDeploymentModule accepts a ready module or a factory that receives the deployment’s injected capabilities.
import { defineDeploymentModule } from "@voyant-travel/framework"
import { loyaltyRoutes } from "./routes.js"

export default defineDeploymentModule({
  module: { name: "loyalty" },
  adminRoutes: loyaltyRoutes, // → /v1/admin/loyalty/*
  // publicRoutes            → /v1/public/loyalty/*
  // lazyAdminRoutes / lazyPublicRoutes for cold-start weight
})
If the module needs an injected provider, take the factory form:
export default defineDeploymentModule((ctx) => ({
  module: { name: "loyalty" },
  adminRoutes: createLoyaltyRoutes(ctx.capabilities),
}))
A custom module that owns tables defines them in schema.ts. Those tables are a deployment migration source, so do not hard-FK across modules: reference a framework entity with a plain text("person_id") column and pair it with a defineLink in src/links, the same decoupling the standard modules use. Generate and apply the migration as a deployment source:
pnpm db:generate:deployment   # emit the migration → migrations-d1/
pnpm db:migrate               # collector: framework bundle, then deployment
To add routes to an existing module rather than a whole new capability, drop a HonoExtension in src/extensions/<name>/. It is discovered and mounted the same way, with extension.module naming the module it attaches to.
Everything you add under the deployment’s src/ is yours, and the framework owns none of it. voyant upgrade bumps the framework packages and migration bundle; your src/modules, src/extensions, and deployment migrations are untouched, and the collector’s content-hash ledger never re-runs or collides with them. That is what makes custom modules upgrade-safe.
A few limits keep this seam honest: table names are global (prefix deployment-owned tables, for example acme_*, to stay clear of the standard schema), discovery is build-time (a new module needs a rebuild and redeploy), and a custom module adds tables rather than re-shaping framework tables.

Next steps

Data models

How a module authors its schema, money, indexes, and migrations.

Links

Connect entities across module boundaries without coupling schemas.

Configuration

Register modules and drive build-time composition.

Command reference

Every voyant generate and voyant db command.