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.
Manual Module Pattern
Section titled “Manual Module Pattern”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.
Directory Structure
Section titled “Directory Structure”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 middlewareService Class
Section titled “Service Class”Services are plain classes. The module plugin manages their lifecycle — no singletons, no global state.
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 }}Domain Middleware
Section titled “Domain Middleware”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:
import { procedure } from '@veloxts/velox';import { authenticated } from '@veloxts/auth';import type { StripeService } from './services/StripeService';
// Factory — accepts the service, returns a scoped procedure builderexport 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 }, }); });}Procedures
Section titled “Procedures”Use the factory so procedures receive the service through the middleware chain:
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, }); }), });}Module Plugin
Section titled “Module Plugin”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.
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(); }); },});decorateRequest puts the service on the Fastify request, not on procedure ctx. Use this for cross-cutting concerns (analytics, metrics, tracing) that hook into Fastify’s request lifecycle on all routes — not just procedure handlers.
import { definePlugin } from '@veloxts/core';import { AnalyticsTracker } from './services/AnalyticsTracker';
export const analyticsModule = definePlugin({ name: 'module-analytics', version: '1.0.0', async register(server) { const tracker = new AnalyticsTracker(process.env.ANALYTICS_KEY!);
server.decorateRequest('analytics', undefined); server.addHook('onRequest', async (request) => { (request as Record<string, unknown>).analytics = tracker.startSpan(request.url); });
server.addHook('onResponse', async (request, reply) => { (request as Record<string, unknown>).analytics?.end(reply.statusCode); });
server.addHook('onClose', async () => { await tracker.flush(); }); },});Mounting the Module
Section titled “Mounting the Module”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-containedapp.register(billingModule, { apiPrefix: config.apiPrefix });app.register(analyticsModule);app.register(inventoryModule);
await app.start();Module Conventions
Section titled “Module Conventions”Following consistent conventions makes modules predictable across teams and projects:
| Convention | Example |
|---|---|
| Directory | src/modules/billing/ |
| Plugin name | module-billing |
| REST prefix | /billing (matches module name) |
| Procedure namespace | billing (from procedures('billing', ...)) |
| Service lifetime | Created in register(), destroyed in onClose |
| Scoped builder | createBillingProcedure(stripe) factory (shared by all procedures in module) |
Module Encapsulation & Security
Section titled “Module Encapsulation & Security”Service Visibility
Section titled “Service Visibility”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 scopeapp.server.register(async (billingScope) => { billingScope.register(billingModule);});Private Modules (Admin Example)
Section titled “Private Modules (Admin Example)”An admin module can be locked down at three layers:
// Layer 1: All procedures require admin roleimport { 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 } }); }),});// Layer 3: Restrict to internal networkapp.server.register(async (adminScope) => { // Only accessible from internal IPs adminScope.addHook('onRequest', async (request, reply) => { const ip = request.ip; if (!isInternalNetwork(ip)) { reply.status(403).send({ error: 'Forbidden' }); } });
// Separate CORS, rate limits, etc. adminScope.register(adminModule);});Security layers summary:
| Layer | Mechanism | Scope |
|---|---|---|
| Module middleware | Scoped procedure builder with guards | All procedures in the module |
| Procedure guards | .guard(hasRole('super-admin')) | Individual procedures |
| Fastify encapsulation | server.register(scope => ...) | Network-level isolation |
Inter-Module Communication
Section titled “Inter-Module Communication”Modules can share services through Fastify’s decoration system. Use dependencies to ensure correct registration order:
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' }); },});Declarative Business Logic
Section titled “Declarative Business Logic”Modules organize code into features. The procedure builder handles orchestration.
A billing procedure that manually orchestrates a charge:
// Before — manual orchestration in the handlercreateCharge: 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 orchestrationcreateCharge: 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().
defineModule() API
Section titled “defineModule() API”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 lineapp.module(billingModule);What defineModule() handles
Section titled “What defineModule() handles”- Service lifecycle — factory creation, per-request context injection, and
onClosecleanup - Route prefix — auto-derived from module name (
/billing), custom string, orprefix: falseto disable - Module middleware — applied as
onRequesthooks to all routes in the module scope - Boot hooks — run between
server.ready()andserver.listen(), after all plugins load - Shutdown hooks — run during graceful shutdown via
beforeShutdown() - Duplicate detection — registering the same module name twice throws
DUPLICATE_MODULE
Configuration options
Section titled “Configuration options”| Option | Type | Description |
|---|---|---|
services | Record<string, ServiceDefinition> | Service factories and cleanup functions |
middleware | ModuleMiddleware[] | onRequest hooks applied to all module routes |
routes | FastifyPluginAsync | Route plugin, typically rest([...]) from @veloxts/router |
prefix | string | false | Route 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 |
Type utilities
Section titled “Type utilities”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 }Planned enhancements
Section titled “Planned enhancements”The following features are planned for a future release:
- Type-inferred context —
ctx.stripetyped asStripeServicewithout declaration merging - Inter-module DI — typed
imports/exportsfor 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.
When to Use Modules
Section titled “When to Use Modules”| Application size | Recommended pattern |
|---|---|
| 1–5 resources | Flat procedures/*.ts files |
| 5–15 resources | Split handlers or action pattern |
| 15+ resources or distinct domains | Domain modules |
| Publishable features | Domain 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.
Related Content
Section titled “Related Content”- Architectural Patterns & Scalability — scaling progression from flat files to modules
- Service Layer — business logic extraction and external APIs
- Plugins — the underlying plugin system that modules compose
- Procedures — the procedure API that modules wrap
- Middleware — request processing middleware patterns
- Guards — route-level authorization