Skip to content

Domain Modules

As your application grows beyond a handful of resources, flat procedure files become difficult to navigate and prone to merge conflicts. A billing feature might need its own procedures, Stripe service, validation schemas, domain middleware, and cleanup hooks — but today these live in separate directories with no formal connection between them.

Domain modules solve this by bundling everything a feature needs into a single mountable unit.

Before defineModule(), you can build domain modules using existing Velox TS primitives. The pattern combines definePlugin() for lifecycle, procedures() for endpoints, and scoped procedure builders for domain middleware. This approach gives you full control over every detail.

src/modules/billing/
├── index.ts # Feature plugin (definePlugin)
├── procedures.ts # billingProcedures collection
├── schemas.ts # Zod input/output schemas
├── services/
│ └── StripeService.ts # Domain service class
└── middleware.ts # Domain-scoped middleware

Services are plain classes. The module plugin manages their lifecycle — no singletons, no global state.

modules/billing/services/StripeService.ts
export class StripeService {
private client: Stripe;
constructor(secretKey: string) {
this.client = new Stripe(secretKey);
}
async getInvoices(customerId: string) {
return this.client.invoices.list({ customer: customerId });
}
async createCharge(params: ChargeParams) {
return this.client.charges.create(params);
}
async disconnect() {
// Clean up connections
}
}

Create a factory that accepts the service instance and returns a scoped procedure builder. The .use() middleware injects the service into procedure context via next({ ctx }) — only pass new properties to avoid conflicts with existing context fields like db:

modules/billing/middleware.ts
import { procedure } from '@veloxts/velox';
import { authenticated } from '@veloxts/auth';
import type { StripeService } from './services/StripeService';
// Factory — accepts the service, returns a scoped procedure builder
export function createBillingProcedure(stripe: StripeService) {
return procedure()
.guard(authenticated)
.use(async ({ ctx, next }) => {
const customer = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: { stripeCustomerId: true },
});
return next({
ctx: { stripe, stripeCustomerId: customer?.stripeCustomerId },
});
});
}

Use the factory so procedures receive the service through the middleware chain:

modules/billing/procedures.ts
import { procedures } from '@veloxts/velox';
import { z } from 'zod';
import { createBillingProcedure } from './middleware';
import type { StripeService } from './services/StripeService';
import { InvoiceSchema, ChargeSchema } from './schemas';
export function createBillingProcedures(stripe: StripeService) {
const billingProcedure = createBillingProcedure(stripe);
return procedures('billing', {
listInvoices: billingProcedure
.output(z.array(InvoiceSchema))
.query(async ({ ctx }) => {
return ctx.stripe.getInvoices(ctx.stripeCustomerId);
}),
createCharge: billingProcedure
.input(ChargeSchema)
.mutation(async ({ input, ctx }) => {
return ctx.stripe.createCharge({
customer: ctx.stripeCustomerId,
...input,
});
}),
});
}

The plugin ties everything together — service creation, route registration, and cleanup. There are two patterns for making services available: context injection for procedure handlers, and request decoration for Fastify-level hooks.

Service flows from module → factory → .use() middleware → ctx.stripe. No globals, no request decoration needed.

modules/billing/index.ts
import { definePlugin } from '@veloxts/core';
import { rest } from '@veloxts/router';
import { StripeService } from './services/StripeService';
import { createBillingProcedures } from './procedures';
interface BillingOptions {
apiPrefix: string;
}
export const billingModule = definePlugin<BillingOptions>({
name: 'module-billing',
version: '1.0.0',
async register(server, options) {
const stripe = new StripeService(process.env.STRIPE_SECRET!);
const billingProcedures = createBillingProcedures(stripe);
await server.register(rest([billingProcedures]), {
prefix: `${options.apiPrefix}/billing`,
});
server.addHook('onClose', async () => {
await stripe.disconnect();
});
},
});
index.ts
import { veloxApp } from '@veloxts/velox';
import { billingModule } from './modules/billing';
import { analyticsModule } from './modules/analytics';
import { inventoryModule } from './modules/inventory';
const app = await veloxApp({ port: 3030 });
// Each module is self-contained
app.register(billingModule, { apiPrefix: config.apiPrefix });
app.register(analyticsModule);
app.register(inventoryModule);
await app.start();

Following consistent conventions makes modules predictable across teams and projects:

ConventionExample
Directorysrc/modules/billing/
Plugin namemodule-billing
REST prefix/billing (matches module name)
Procedure namespacebilling (from procedures('billing', ...))
Service lifetimeCreated in register(), destroyed in onClose
Scoped buildercreateBillingProcedure(stripe) factory (shared by all procedures in module)

When a module decorates a service on the Fastify server, sibling scopes can see it. For true isolation, register the module inside a child scope:

// Isolated — stripe is only visible within this scope
app.server.register(async (billingScope) => {
billingScope.register(billingModule);
});

An admin module can be locked down at three layers:

// Layer 1: All procedures require admin role
import { authenticated, hasRole } from '@veloxts/auth';
export const adminProcedure = procedure()
.guard(authenticated)
.guard(hasRole('admin'));
export const adminProcedures = procedures('admin', {
listAllUsers: adminProcedure
.query(async ({ ctx }) => ctx.db.user.findMany()),
deleteUser: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.guard(hasRole('super-admin')) // Layer 2: individual procedure guard
.use(auditLogMiddleware)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.delete({ where: { id: input.id } });
}),
});

Security layers summary:

LayerMechanismScope
Module middlewareScoped procedure builder with guardsAll procedures in the module
Procedure guards.guard(hasRole('super-admin'))Individual procedures
Fastify encapsulationserver.register(scope => ...)Network-level isolation

Modules can share services through Fastify’s decoration system. Use dependencies to ensure correct registration order:

modules/orders/index.ts
import { definePlugin } from '@veloxts/core';
import { rest } from '@veloxts/router';
import { orderProcedures } from './procedures';
export const orderModule = definePlugin({
name: 'module-orders',
version: '1.0.0',
dependencies: ['module-billing'], // ensures billing registers first
async register(server) {
// Access billing's stripe service (decorated on server by billing module)
const stripe = server.stripe;
await server.register(rest([orderProcedures]), { prefix: '/orders' });
},
});

Modules organize code into features. The procedure builder handles orchestration.

A billing procedure that manually orchestrates a charge:

// Before — manual orchestration in the handler
createCharge: billingProcedure
.input(ChargeSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.$transaction(async (tx) => {
const charge = await ctx.stripe.createCharge(input);
const record = await tx.payment.create({
data: { ...input, chargeId: charge.id },
});
await ctx.events.emit(new ChargeCompleted({
paymentId: record.id,
amount: input.amount,
}));
return record;
});
}),

The same procedure with builder primitives:

// After — builder declares the orchestration
createCharge: billingProcedure
.input(ChargeSchema)
.transactional()
.through(chargePayment.onRevert(refundPayment))
.emits(ChargeCompleted, (result) => ({
paymentId: result.id,
amount: result.amount,
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.payment.create({
data: { ...input, chargeId: input.chargeId },
});
}),

The service still handles Stripe — chargePayment is a defineStep that calls stripe.charges.create(). The difference: transaction wrapping, event emission, and compensation are declared on the chain instead of hand-wired in the handler.

See Business Logic for the full guide on .transactional(), .through(), .emits(), and .useAfter().

The manual pattern above works but involves repetitive wiring. The defineModule() API automates the boilerplate while preserving full type safety.

import { defineModule } from '@veloxts/core';
import { rest } from '@veloxts/router';
import { StripeService } from './services/StripeService';
import { billingProcedures } from './procedures';
export const billingModule = defineModule('billing', {
// Services auto-injected into request context
services: {
stripe: {
factory: () => new StripeService(process.env.STRIPE_SECRET!),
close: (s) => s.disconnect(),
},
},
// Applied to all routes in this module's scope
middleware: [requireAuth, requireStripeCustomer],
// Routes — typically rest() from @veloxts/router
routes: rest([billingProcedures]),
// Lifecycle hooks
async boot(services) {
await services.stripe.warmCache();
},
async shutdown(services) {
await services.stripe.flushPendingCharges();
},
});
// Mount with one line
app.module(billingModule);
  • Service lifecycle — factory creation, per-request context injection, and onClose cleanup
  • Route prefix — auto-derived from module name (/billing), custom string, or prefix: false to disable
  • Module middleware — applied as onRequest hooks to all routes in the module scope
  • Boot hooks — run between server.ready() and server.listen(), after all plugins load
  • Shutdown hooks — run during graceful shutdown via beforeShutdown()
  • Duplicate detection — registering the same module name twice throws DUPLICATE_MODULE
OptionTypeDescription
servicesRecord<string, ServiceDefinition>Service factories and cleanup functions
middlewareModuleMiddleware[]onRequest hooks applied to all module routes
routesFastifyPluginAsyncRoute plugin, typically rest([...]) from @veloxts/router
prefixstring | falseRoute prefix (default: /${name}, false to disable)
boot(services) => Promise<void>Called after all plugins load, before server listens
shutdown(services) => Promise<void>Called during graceful shutdown

Use InferModuleServices to extract the resolved service types from a module, for example when extending the request context via declaration merging:

import type { InferModuleServices } from '@veloxts/core';
type BillingServices = InferModuleServices<typeof billingModule>;
// { stripe: StripeService }

The following features are planned for a future release:

  • Type-inferred contextctx.stripe typed as StripeService without declaration merging
  • Inter-module DI — typed imports / exports for service sharing between modules
  • Service encapsulation — services private by default, explicitly exported for other modules
  • tRPC namespace — optionally prefixed with module name
// Inter-module DI (planned — not yet available)
export const orderModule = defineModule('orders', {
imports: [billingModule],
services: {
orderProcessor: {
factory: (imported) => new OrderProcessor(imported.billing.stripe),
},
},
routes: rest([orderProcedures]),
});

See the full design document at .plans/domain-modules-rfc.md.

Application sizeRecommended pattern
1–5 resourcesFlat procedures/*.ts files
5–15 resourcesSplit handlers or action pattern
15+ resources or distinct domainsDomain modules
Publishable featuresDomain modules (npm-ready)

Modules are progressive — you can extract a module from existing flat procedures without rewriting your app. Start simple, modularize when a domain boundary becomes clear.