Skip to main content
A Voyant module is more than a database schema and a set of HTTP routes. The part that actually knows the rules of a subdomain is its service layer. Services own business logic. Everything else, transport included, is a thin shell that translates an external request into a service call and translates the result back out. This page explains how a module’s service is structured, why it stays framework-agnostic, and how the same service gets reused across HTTP routes, workflows, and scripts.

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.
A service does not own:
  • 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 -react package.

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.
import { createFinanceSettlementService } from "@voyant-travel/finance"

// The caller supplies the runtime dependencies.
const settlement = createFinanceSettlementService({ db, events })

// The method reads like the business operation it performs.
const result = await settlement.reconcileSettlement({
  invoiceId,
  paymentAmount,
  paymentReference,
})
The exact factory and method names belong to each module. The shape is consistent: construct the service with runtime dependencies, then call domain methods that enforce the subdomain’s rules.

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:
import { parseJsonBody, requireActor } from "@voyant-travel/hono"
import { reconcileSettlementSchema } from "./schemas"

adminRoutes.post("/settlements", async (c) => {
  // 1. Auth and actor context were resolved by shared middleware.
  requireActor(c)

  // 2. Validate the request through the shared helper.
  const body = await parseJsonBody(c, reconcileSettlementSchema)

  // 3. Resolve the service from the request context and call it.
  const settlement = c.var.container.financeSettlementService
  const result = await settlement.reconcileSettlement(body)

  // 4. Serialize an intentional response shape.
  return c.json({ settlement: result }, 201)
})
Notice what the route does not do: it does not implement the settlement rules, it does not decide what makes an invoice settled, and it does not emit the 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.
This is the payoff of keeping services framework-agnostic. A reserve-and-confirm booking workflow does not re-implement booking rules; it calls the bookings service from inside its steps. A reconciliation script does not POST to your own API; it constructs the finance service and calls 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:
  1. Perform the write (often inside a transaction).
  2. Persist the supporting records (the rendition, the payment row, the delivery row).
  3. Emit the event with an intentional category (domain or internal) and source: "service".
async function reconcileSettlement(input: ReconcileSettlementInput) {
  // 1 + 2: create the payment and update invoice state durably first.
  const settled = await db.transaction(async (tx) => {
    const payment = await recordPayment(tx, input)
    const invoice = await markInvoiceSettled(tx, input.invoiceId, payment)
    return { payment, invoice }
  })

  // 3: only now announce the business fact.
  await events.emit({
    name: "invoice.settled",
    data: { invoiceId: settled.invoice.id },
    metadata: { category: "domain", source: "service" },
  })

  return settled
}
The event is a signal to observers, not the mechanism that makes the invoice settled. If a follow-up reaction ever needs durable retries, it moves onto a workflow or job path rather than changing the event semantics. Keeping that boundary inside the service keeps the rest of the system honest about what is guaranteed.

Designing a good service

When you add or review a module service, keep these properties in mind.
1

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

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

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

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

Emit events after durable writes

Persist first, then emit. Choose the event category deliberately and keep subscribers outside the correctness boundary.
If you find yourself writing the same domain rule in a route and again in a workflow step, that is the signal the logic belongs in a service that both call. Duplication across transports is the smell the service layer exists to remove.

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.