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.
The Service Layer
Section titled “The Service Layer”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 configurationConfiguring External Clients
Section titled “Configuring External Clients”Create configured clients in src/lib/:
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia',});import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY!);Writing Services
Section titled “Writing Services”Services contain the business logic. They receive what they need as arguments — no framework coupling:
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), }, });}Thin Procedures
Section titled “Thin Procedures”The procedure validates input, checks authorization, and calls the service:
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); }),});Calling External APIs
Section titled “Calling External APIs”Integrating with third-party APIs is straightforward. The handler is async — call whatever you need.
Direct Imports
Section titled “Direct Imports”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; }),});Context-Based Services
Section titled “Context-Based Services”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 handlerThis eliminates the boilerplate of Symbol.for(), decorateRequest(), and addHook() that context-injecting plugins require. For full details, see Context.
Error Handling
Section titled “Error Handling”External services fail. Network timeouts, rate limits, and transient errors are inevitable. Handle them explicitly in your services.
Typed Errors
Section titled “Typed Errors”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).
Handling Prisma Errors
Section titled “Handling Prisma Errors”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. }}Retry Logic
Section titled “Retry Logic”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); }, },);Composing Multiple Services
Section titled “Composing Multiple Services”Complex endpoints often orchestrate several services. Keep the procedure as the coordinator, calling services in sequence:
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 }; }),});Webhook Handlers
Section titled “Webhook Handlers”Receiving webhooks from external services (Stripe, GitHub, etc.) fits naturally into procedures. Use .webhook() to define a POST endpoint at a custom path:
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 }; }),});Testing
Section titled “Testing”The service layer makes testing straightforward. Services receive db as a parameter, so you can pass a mock directly — no HTTP layer needed:
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.
Summary
Section titled “Summary”| Concern | Pattern |
|---|---|
| Business logic | Extract to src/services/ — procedures delegate |
| External APIs | Import clients from src/lib/, call in services |
| Request-scoped services | defineContextPlugin() from @veloxts/core |
| Transactions & orchestration | .transactional(), .through(), .emits() — see Business Logic |
| Concurrency safety | Atomic updateMany with where guards, not read-check-write |
| Prisma errors | db() wrapper from @veloxts/orm for automatic conversion |
| Transient failures | withRetry from @veloxts/core or @veloxts/queue for background work |
| Non-blocking side effects | fireAndForget() from @veloxts/core |
| Query performance | Prefer select over include, batch with findMany |
| Webhooks | .webhook() builder method for custom POST endpoints |
| Testing | Unit test services with mocks, integration test procedures via HTTP |
Related Content
Section titled “Related Content”- Business Logic - Transactions, pipelines, domain events
- Procedures - Core procedure builder API
- Domain Modules - Bundle services, procedures, and lifecycle into vertical feature slices
- Middleware - Cross-cutting concerns
- Context - Request-scoped state
- Error Handling - Error classes and HTTP codes
- Queue - Background job processing