Skip to main content
A module is a new bounded capability. An extension is the seam for everything that hangs off an existing one: a route that the owning module does not ship, a vertical detail table attached to a core record, behavior that fires around a module’s lifecycle. The defining test is that an extension adds to a module without introducing a new canonical capability of its own. If the thing you are building owns canonical state and behavior, it is a module; if it decorates a module that already exists, it is an extension. Bookings is the clearest example. The core @voyant-travel/bookings module owns the booking record and its lifecycle, but the routes mounted under /v1/admin/bookings come from several packages: quotes, inventory, finance, distribution, and supplier handling each contribute their own slice. None of them forks bookings. Each ships a HonoExtension that names bookings as its target, and the deployment composes them onto the same surface.

What an extension owns

An extension can contribute three kinds of thing to its target module:
  • Routes. Admin or public Hono routes mounted under the target module’s prefix, so a booking-tax route from finance lands under /v1/admin/bookings without finance hard-coding that path.
  • Its own tables. A 1:1 detail table that hangs off a core record (for example booking_quote_details keyed by booking_id), keeping vertical columns out of the slim core table.
  • Lifecycle behavior. A bootstrap run once per isolate, and hooks the platform can dispatch around module operations.
What an extension does not own is the core record itself. The dependency direction is one-way: the extension package depends on the module it attaches to (for the foreign key and the contract), never the reverse. That is what lets a deployment drop the extension without the module knowing it existed.

The HonoExtension shape

An extension is a small descriptor plus the routes it contributes. The extension field carries the metadata; the route fields carry the surface.
The extension descriptor itself is the core Extension type:
The single most important field is extension.module. It names the module being extended, and the platform derives the mount prefix from it: an extension with module: "bookings" and adminRoutes is mounted at /v1/admin/bookings, the same prefix the bookings module uses. Because many extensions can target one module, give your extension routes their own sub-paths so they sit alongside the module’s own routes rather than colliding with them.
An older routes field mounts a single router at the legacy /v1/{module} surface. Prefer adminRoutes / publicRoutes so your contribution lands on the correct surface.

Extending a module in a deployment

A deployment extends a module by dropping a HonoExtension into its discovery seam. The CLI scaffolds the whole folder — pass the extension name and the module it attaches to:
That writes the shape below; the directory name becomes the extension’s composition key. (You can also create it by hand.)
The index.ts uses defineDeploymentExtension, which accepts a ready HonoExtension or a factory that receives the deployment’s injected capabilities:
The routes are an ordinary Hono router. Give them a sub-path so they coexist with the bookings module’s own routes on the shared surface:
That is the whole wiring. The deployment’s composition picks the folder up at build time through a Vite glob — the same mechanism custom modules use — and createApp mounts it onto the bookings surface:
If the extension needs an injected provider or resolver, take the factory form instead, the same way modules do:
Everything under the deployment’s src/extensions is yours, and the platform owns none of it. voyant upgrade bumps the platform packages and migration bundle and leaves your extensions and deployment migrations untouched. That is what makes extending a module upgrade-safe instead of a fork.

Extension tables

When an extension needs to persist data, it adds its own table rather than widening the core record. The convention is a 1:1 detail table keyed by the core record’s id — this is exactly how the platform keeps the bookings table slim while quotes, products, and finance each attach their columns through a dedicated booking_*_details table. A deployment-owned extension table is a deployment migration source, so it follows the same two rules a custom module’s schema does:
  • Do not hard-FK across module boundaries. Reference the core booking with a plain text("booking_id") column, not a cross-package .references(). Pair the association with a link when you need it resolved in the query graph.
  • Prefix deployment-owned tables (for example acme_booking_notes) so they stay clear of the platform’s global table namespace.

Transactions

If an extension route runs an interactive transaction (db.transaction(...)), set requiresTransactionalDb on the descriptor. Extensions mount under their target module’s prefix, so this flag forces the transaction-capable db client onto that surface even when the module itself does not declare it:

Lifecycle behavior

Beyond routes, the Extension descriptor can carry a bootstrap (run once per app isolate on the first request where bindings are available) and hooks the platform dispatches around module operations. For reacting to domain events more broadly — booking.created, invoice.issued, and the like — the event bus and its subscribers are the documented path, and a packaged integration ships that wiring as part of an adapter or plugin bundle rather than a bare extension.

Package-shipped extensions

A deployment-local extension is the right tool for behavior that only your app needs. When an extension is reusable across deployments, it ships from a package instead and the platform references it by registry key. The bookings surface is assembled from exactly these:
Each entry is a package that exposes its extension on a dedicated sub-path (for example @voyant-travel/quotes/booking-extension) so a deployment can import just the extension without pulling in the package’s full surface. Whether an extension ships as a package or lives in src/extensions, its runtime semantics are identical: it names a target module and the platform mounts it onto that module’s surface. Packaging is a distribution decision, not a different mechanism — see the module/provider/extension/plugin taxonomy.
An extension that adds a route or a detail table to an existing module is an extension, not a plugin. Reserve plugin bundles for genuinely reusable cross-project packages. Starting at “plugin” inflates a one-route seam into a meta-framework.

Next steps

Modules

The capability an extension attaches to, and how to author a whole new one.

API routes

The surface split, route authoring, and how an app composes module and extension routes.

Data models

How an extension table authors its schema, keys, and migrations.

Events

The event bus for reacting to domain events across modules.