Skip to content

Service Layer

Most documentation shows simple CRUD — fetch a record, return it. Real applications are different: they call payment APIs, send emails, orchestrate multi-step workflows, and handle failures gracefully. Procedures handle all of this. The procedure wrapper manages the HTTP boundary (validation, auth, routing), while the handler is a regular async function where your architecture lives.

For anything beyond trivial CRUD, extract business logic into a service layer. Procedures stay thin — they validate, authorize, and delegate:

src/
├── procedures/
│ └── orders.ts # Thin: validate → delegate → respond
├── services/
│ ├── orders.ts # Business logic and orchestration
│ ├── payments.ts # Stripe integration
│ └── notifications.ts # Email and push notifications
└── lib/
└── stripe.ts # Client configuration

Create configured clients in src/lib/:

src/lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-01-27.acacia',
});
src/lib/resend.ts
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY!);

Services contain the business logic. They receive what they need as arguments — no framework coupling:

src/services/payments.ts
import { stripe } from '../lib/stripe.js';
import type { PrismaClient } from '@prisma/client';
export async function createSubscription(
db: PrismaClient,
userId: string,
priceId: string,
) {
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
// Create or retrieve Stripe customer
let stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
stripeCustomerId = customer.id;
await db.user.update({
where: { id: userId },
data: { stripeCustomerId },
});
}
// Create subscription
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: priceId }],
});
// Record locally
return db.subscription.create({
data: {
userId,
stripeSubscriptionId: subscription.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}

The procedure validates input, checks authorization, and calls the service:

src/procedures/orders.ts
import { procedures, procedure } from '@veloxts/velox';
import { z } from '@veloxts/velox';
import { authenticated } from '@veloxts/auth';
import { createSubscription } from '../services/payments.js';
export const orderProcedures = procedures('orders', {
subscribe: procedure()
.input(z.object({ priceId: z.string() }))
.guard(authenticated)
.mutation(async ({ input, ctx }) => {
return createSubscription(ctx.db, ctx.user.id, input.priceId);
}),
});

Integrating with third-party APIs is straightforward. The handler is async — call whatever you need.

For most cases, import configured clients directly:

import { stripe } from '../lib/stripe.js';
import { resend } from '../lib/resend.js';
export const userProcedures = procedures('users', {
createUser: procedure()
.input(CreateUserSchema)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.create({ data: input });
// Send welcome email via Resend
await resend.emails.send({
from: 'onboarding@myapp.com',
to: user.email,
subject: 'Welcome!',
html: `<p>Hi ${user.name}, welcome aboard.</p>`,
});
return user;
}),
});

For services that need request-scoped state (tenant ID, trace ID, user locale), use defineContextPlugin from @veloxts/core to inject them into the request context:

import { defineContextPlugin } from '@veloxts/core';
export const analyticsPlugin = defineContextPlugin({
name: '@myapp/analytics',
version: '1.0.0',
contextKey: 'analytics',
create: () => new Analytics(process.env.ANALYTICS_KEY!),
close: (a) => a.flush(),
});
// After registration: ctx.analytics.track(...) in any procedure handler

This eliminates the boilerplate of Symbol.for(), decorateRequest(), and addHook() that context-injecting plugins require. For full details, see Context.

External services fail. Network timeouts, rate limits, and transient errors are inevitable. Handle them explicitly in your services.

Use Velox TS error classes to return meaningful HTTP responses. The framework provides built-in error classes for common HTTP status codes:

import {
ConflictError,
ServiceUnavailableError,
UnprocessableEntityError,
ValidationError,
VeloxError,
} from '@veloxts/core';
export async function chargeCustomer(db: PrismaClient, userId: string, amount: number) {
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
if (!user.stripeCustomerId) {
throw new ValidationError('No payment method on file. Please add a card first.');
}
try {
return await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: 'usd',
customer: user.stripeCustomerId,
});
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
throw new UnprocessableEntityError(`Payment declined: ${error.message}`);
}
throw new ServiceUnavailableError('Payment service unavailable. Please try again.');
}
}

Available error classes: ConflictError (409), ForbiddenError (403), UnauthorizedError (401), ServiceUnavailableError (503), TooManyRequestsError (429), UnprocessableEntityError (422), plus existing ValidationError (400) and NotFoundError (404).

Use the db() wrapper from @veloxts/orm to automatically convert Prisma errors into appropriate Velox TS error classes:

import { db } from '@veloxts/orm';
export async function createUser(prisma: PrismaClient, data: { name: string; email: string }) {
// P2002 → ConflictError, P2025 → NotFoundError, P2003 → ValidationError
return db(() => prisma.user.create({ data }));
}

The db() wrapper handles the most common Prisma errors automatically. For custom error messages, catch and re-throw:

import { db, handlePrismaError } from '@veloxts/orm';
import { ConflictError } from '@veloxts/core';
export async function createTeamMember(prisma: PrismaClient, data: { email: string; teamId: string }) {
try {
return await prisma.teamMember.create({ data });
} catch (error) {
// Custom message for this specific context
handlePrismaError(error); // throws ConflictError, NotFoundError, etc.
}
}

For transient failures, use the built-in withRetry utility:

import { withRetry } from '@veloxts/core';
const result = await withRetry(
() => externalApi.sendWebhook(payload),
{ attempts: 3, delayMs: 1000, exponential: true },
);

Use retryIf to avoid retrying non-transient errors (like 400 Bad Request):

const result = await withRetry(
() => stripe.paymentIntents.create({ amount, currency: 'usd' }),
{
attempts: 3,
retryIf: (error) => {
// Only retry server errors, not client errors
if (error instanceof Stripe.errors.StripeError) {
return error.statusCode !== undefined && error.statusCode >= 500;
}
return true;
},
onRetry: (error, attempt) => {
console.warn(`Stripe retry ${attempt}:`, error);
},
},
);

Complex endpoints often orchestrate several services. Keep the procedure as the coordinator, calling services in sequence:

src/procedures/checkout.ts
import { fireAndForget } from '@veloxts/core';
import { createOrder } from '../services/orders.js';
import { chargePayment } from '../services/payments.js';
import { sendOrderConfirmation } from '../services/notifications.js';
import { updateInventory } from '../services/inventory.js';
export const checkoutProcedures = procedures('checkout', {
complete: procedure()
.input(CheckoutSchema)
.guard(authenticated)
.mutation(async ({ input, ctx }) => {
// 1. Create order (database transaction)
const order = await createOrder(ctx.db, ctx.user.id, input.items);
// 2. Charge payment (Stripe)
const payment = await chargePayment(ctx.db, ctx.user.id, order.total);
// 3. Update inventory (database)
await updateInventory(ctx.db, input.items);
// 4. Send confirmation (non-blocking — don't fail the request if email fails)
fireAndForget(
sendOrderConfirmation(ctx.user.email, order),
{ label: 'order-confirmation' },
);
return { order, paymentId: payment.id };
}),
});

Receiving webhooks from external services (Stripe, GitHub, etc.) fits naturally into procedures. Use .webhook() to define a POST endpoint at a custom path:

src/procedures/webhooks.ts
import { procedures, procedure } from '@veloxts/velox';
import { stripe } from '../lib/stripe.js';
import { handlePaymentSuccess, handlePaymentFailure } from '../services/payments.js';
export const webhookProcedures = procedures('webhooks', {
handleStripe: procedure()
.webhook('/webhooks/stripe')
.mutation(async ({ ctx }) => {
// rawBody (Buffer) is available via rawBodyPlugin
// Stripe signature comes from the request header
const event = stripe.webhooks.constructEvent(
ctx.request.rawBody!,
ctx.request.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(ctx.db, event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(ctx.db, event.data.object);
break;
}
return { received: true };
}),
});

The service layer makes testing straightforward. Services receive db as a parameter, so you can pass a mock directly — no HTTP layer needed:

src/services/__tests__/orders.test.ts
import { describe, it, expect, vi } from 'vitest';
import { placeOrder } from '../orders.js';
describe('placeOrder', () => {
it('creates order with correct total', async () => {
const mockDb = {
$transaction: vi.fn((fn) => fn(mockDb)),
product: {
findMany: vi.fn().mockResolvedValue([
{ id: 'prod-1', name: 'Widget', price: 29.99, stock: 10 },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
order: {
create: vi.fn().mockResolvedValue({
id: 'order-1',
total: 59.98,
items: [],
}),
},
};
const order = await placeOrder(mockDb, 'user-1', [
{ productId: 'prod-1', quantity: 2 },
]);
expect(order.total).toBe(59.98);
expect(mockDb.order.create).toHaveBeenCalledOnce();
});
it('throws when stock is insufficient', async () => {
const mockDb = {
$transaction: vi.fn((fn) => fn(mockDb)),
product: {
findMany: vi.fn().mockResolvedValue([
{ id: 'prod-1', name: 'Widget', price: 29.99, stock: 0 },
]),
},
};
await expect(
placeOrder(mockDb, 'user-1', [{ productId: 'prod-1', quantity: 1 }]),
).rejects.toThrow('Insufficient stock');
});
});

For integration tests, use createTestApp from @veloxts/testing to test procedures through HTTP with app.inject(). See Testing Patterns for details.

ConcernPattern
Business logicExtract to src/services/ — procedures delegate
External APIsImport clients from src/lib/, call in services
Request-scoped servicesdefineContextPlugin() from @veloxts/core
Transactions & orchestration.transactional(), .through(), .emits() — see Business Logic
Concurrency safetyAtomic updateMany with where guards, not read-check-write
Prisma errorsdb() wrapper from @veloxts/orm for automatic conversion
Transient failureswithRetry from @veloxts/core or @veloxts/queue for background work
Non-blocking side effectsfireAndForget() from @veloxts/core
Query performancePrefer select over include, batch with findMany
Webhooks.webhook() builder method for custom POST endpoints
TestingUnit test services with mocks, integration test procedures via HTTP