Why a service layer exists
Travel logic is full of rules that have nothing to do with HTTP: a booking cannot be confirmed without a current customer confirmation, an invoice is settled only after payment state is durable, a hold expires after a window. If those rules live inside route handlers, they are trapped behind one transport. The moment a workflow step, a CLI command, or a background job needs the same rule, you either duplicate it or call the route over the network to talk to your own process. Voyant avoids that by treating the service as the real interface to a subdomain.The mental model is the same one described in Architecture: packages hold reusable business logic, schemas, services, routes, and adapters, while starters own UI, auth wiring, and deployment shape. The service is the heart of “reusable business logic.”
What a service owns
A module service owns the behavior of one subdomain. For a module such as@voyant-travel/finance or @voyant-travel/bookings, the service is where you find:
- The domain operations, expressed as methods that read like business language (
reconcileSettlement(...),generateInvoiceDocument(...),sendBookingDocuments(...)). - Invariant enforcement: the checks that must hold before a state transition is allowed.
- Persistence through the module’s Drizzle schema, usually inside a transaction when correctness depends on it.
- Domain event emission, after the durable state change the event describes.
- HTTP request parsing, status codes, or error envelopes. That belongs to the route.
- Auth resolution. Identity and actor context arrive already resolved (see Auth).
- UI concerns, query keys, or view models. Those live in the matching
-reactpackage.
Services are framework-agnostic
Core packages, including the domain services, do not depend on Hono, on Cloudflare Workers, on TanStack Start, or on Better Auth. They depend on a database client and on the shared core contracts. That is a deliberate boundary: the same service code runs whether you deploy to your own Node and Postgres or to Voyant Cloud’s runtime, and whether the call originated from an admin route, a public route, a workflow step, or a one-off script. A service typically receives its dependencies (the database client, an event bus, any adapters it needs) explicitly rather than reaching for globals. That is what makes it portable: the caller decides what to inject, so the service does not assume a runtime.How a route consumes a service
The API route authoring guide is explicit about this: routes should validate the request, resolve runtime services from the request context, call the service or workflow, and serialize the response. Business orchestration that belongs in a service must not leak into the route body. A route handler is therefore short and predictable:invoice.settled event. The service does all of that. If the rule changes, you change it in one place and every caller benefits.
The same service across transports
Because the service is the real interface, the same code path is reachable from every execution surface:HTTP routes
Admin and public Hono routes validate input, then delegate to the service. See API routes.
Workflows
Durable workflow steps call the same service methods to perform business work, with retries and resumability around them. See Workflows.
Events and subscribers
Subscribers react to domain events the service emits, without becoming part of the correctness boundary. See Events.
Scripts and tooling
CLI commands, migrations helpers, and one-off scripts construct the service with a db client and call it directly.
reconcileSettlement(...).
Services and events: emit after the durable change
Services are the natural place to emit domain events, and the event delivery policy gives a clear rule: emit the event after the durable state transition it describes, never before. The standard service pattern is:- Perform the write (often inside a transaction).
- Persist the supporting records (the rendition, the payment row, the delivery row).
- Emit the event with an intentional
category(domainorinternal) andsource: "service".
Designing a good service
When you add or review a module service, keep these properties in mind.Express operations in domain language
Method names should read like the business action (
cancelBooking, issueCreditNote), not like CRUD plumbing. The service is part of the ubiquitous language.Accept dependencies explicitly
Take the db client, event bus, and any adapters as constructor or factory arguments. Do not reach for runtime globals, so the service stays portable across HTTP, workflows, and scripts.
Keep persistence and invariants together
Enforce the subdomain’s rules where the write happens. A route or workflow should not be able to bypass an invariant by calling persistence directly.
Return intentional shapes
The value a service returns is a contract for its callers. Shape it on purpose rather than leaking raw table rows, the same discipline routes apply to responses.
Next steps
API routes
How thin transport routes call into services over Hono.
Events
How services emit domain events and where durable work belongs.
Workflows
Durable orchestration that reuses module services from its steps.
Module catalog
Every domain module and the subdomain its service owns.