@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/bookingswithout finance hard-coding that path. - Its own tables. A 1:1 detail table that hangs off a core record (for example
booking_quote_detailskeyed bybooking_id), keeping vertical columns out of the slim core table. - Lifecycle behavior. A
bootstraprun once per isolate, andhooksthe platform can dispatch around module operations.
The HonoExtension shape
An extension is a small descriptor plus the routes it contributes. Theextension field carries the metadata; the route fields carry the surface.
extension descriptor itself is the core Extension type:
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 aHonoExtension into its discovery seam. The CLI scaffolds the whole folder — pass the extension name and the module it attaches to:
index.ts uses defineDeploymentExtension, which accepts a ready HonoExtension or a factory that receives the deployment’s injected capabilities:
createApp mounts it onto the bookings surface:
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 dedicatedbooking_*_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, theExtension 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:@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.
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.