Business Logic
Your procedure validates input and runs a handler. But real mutations charge cards, update inventory, emit events, and need to roll back on failure. The procedure builder has first-class support for all of this.
Domain Errors
Section titled “Domain Errors”Your handler throws new Error('Not enough stock'). The client gets a generic 500 with no structured data to act on.
Define a DomainError subclass with a typed code and data payload:
import { DomainError } from '@veloxts/core';
export class InsufficientStock extends DomainError<{ sku: string; requested: number; available: number;}> { readonly code = 'INSUFFICIENT_STOCK'; readonly status = 422; readonly message = 'Not enough inventory';}
export class PaymentFailed extends DomainError<{ reason: string; chargeId?: string;}> { readonly code = 'PAYMENT_FAILED'; readonly status = 422; readonly message = 'Payment could not be processed';}Throw it in your handler — the framework serializes code and data in the response automatically:
throw new InsufficientStock({ sku: input.sku, requested: input.quantity, available: stock.available,});// → { statusCode: 422, code: "INSUFFICIENT_STOCK", data: { sku, requested, available } }Declare which errors a procedure can throw with .throws():
createOrder: procedure() .input(CreateOrderSchema) .throws(InsufficientStock, PaymentFailed) .mutation(async ({ input, ctx }) => { // handler may throw InsufficientStock or PaymentFailed return ctx.db.order.create({ data: input }); }).throws() does two things:
- Documents the procedure’s error contract (appears in OpenAPI output).
- Enables typed error narrowing on the client — see Client Error Narrowing.
Transactions
Section titled “Transactions”Your mutation creates an order and updates inventory. If the inventory update fails, the order record is orphaned.
Add .transactional() to wrap the handler in a database transaction:
createOrder: procedure() .input(CreateOrderSchema) .transactional() .mutation(async ({ input, ctx }) => { const order = await ctx.db.order.create({ data: input }); await ctx.db.inventory.update({ where: { sku: input.sku }, data: { stock: { decrement: input.quantity } }, }); // If inventory update fails, order is rolled back return order; })ctx.db becomes the transactional client inside the handler. On throw: automatic rollback. On return: automatic commit.
Pass options for isolation level or timeout:
.transactional({ isolationLevel: 'Serializable', timeout: 10_000 })Domain Events
Section titled “Domain Events”Order is created. Email service and analytics need to know, but importing those services into your handler creates tight coupling.
Defining events
Section titled “Defining events”import { DomainEvent } from '@veloxts/events';
export class OrderCreated extends DomainEvent<{ orderId: string; customerId: string; total: number;}> {}DomainEvent carries typed data, a timestamp, and an optional correlationId for tracing event chains.
Declarative emission with .emits()
Section titled “Declarative emission with .emits()”Add .emits() to fire an event after the handler succeeds:
createOrder: procedure() .emits(OrderCreated, (result) => ({ orderId: result.id, customerId: result.customerId, total: result.total, })) .mutation(async ({ input, ctx }) => { return ctx.db.order.create({ data: input }); })The mapper function transforms the handler result into the event payload. When .transactional() is used, events fire after commit.
Emission errors are caught and logged — they never fail the request. The mutation already succeeded.
Imperative emission
Section titled “Imperative emission”For conditional or multiple events, emit directly inside the handler:
fulfillOrder: procedure() .mutation(async ({ input, ctx }) => { const order = await ctx.db.order.update({ ... });
if (order.items.every(i => i.shipped)) { ctx.events.emit(new OrderFulfilled({ orderId: order.id, trackingNumber: order.tracking, })); }
return order; })Listening for events
Section titled “Listening for events”Register listeners at app level — typically in your module setup or plugin:
// In your app setup or domain modulectx.events.on(OrderCreated, async (event) => { await sendConfirmationEmail(event.data.customerId);});
ctx.events.on(OrderCreated, async (event) => { await allocateInventory(event.data.orderId);});Listeners run concurrently by default. For sequential execution:
ctx.events.on(OrderCreated, handler, { sequential: true });Listener errors are logged but never propagate — each listener is isolated.
Pipelines
Section titled “Pipelines”Before creating the order, you need to validate stock, reserve it, and charge the card. If the charge fails, unreserve the stock.
Defining steps
Section titled “Defining steps”import { defineStep, defineRevert } from '@veloxts/router';
const validateStock = defineStep('validateStock', async ({ input, ctx }) => { const stock = await ctx.db.inventory.findUnique({ where: { sku: input.sku } }); if (!stock || stock.available < input.quantity) { throw new InsufficientStock({ sku: input.sku, requested: input.quantity, available: stock?.available ?? 0, }); } return { ...input, stock }; });
const reserveStock = defineStep('reserveStock', async ({ input, ctx }) => { await ctx.db.inventory.update({ where: { sku: input.stock.sku }, data: { reserved: { increment: input.quantity } }, }); return input; });Steps run in order. Each step’s return becomes the next step’s input.
External steps and reverts
Section titled “External steps and reverts”Steps that call external services run outside the database transaction. Mark them with { external: true }:
const chargePayment = defineStep( { name: 'chargePayment', external: true }, async ({ input, ctx }) => { const charge = await stripe.charges.create({ amount: input.total }); return { ...input, chargeId: charge.id }; });
const refundPayment = defineRevert('refundPayment', async ({ input, ctx }) => { await stripe.refunds.create({ charge: input.chargeId }); });Attach a revert to a step with .onRevert() — returns a new step (immutable):
chargePayment.onRevert(refundPayment)Wiring on the procedure
Section titled “Wiring on the procedure”createOrder: procedure() .input(CreateOrderSchema) .transactional() .through(validateStock, reserveStock, chargePayment.onRevert(refundPayment)) .emits(OrderCreated) .mutation(async ({ input, ctx }) => { return ctx.db.order.create({ data: input }); }).through() prepares, .mutation() commits. The pipeline doesn’t replace the handler — it transforms the input before it.
Failure and compensation
Section titled “Failure and compensation”If a step fails after other steps have completed:
- Revert actions run in reverse order for completed steps.
- Each revert receives the output of the step being reverted (e.g.,
chargeIdfor refunds). - The original error propagates to the client.
Two-phase execution with .transactional()
Section titled “Two-phase execution with .transactional()”When .transactional() and .through() are combined:
- Phase A: DB steps + handler run inside the transaction.
ctx.dbis the transactional client. - Phase B: External steps run after commit, outside any transaction.
.through( validateStock, // Phase A (DB, in transaction) reserveStock, // Phase A (DB, in transaction) chargePayment.onRevert(refundPayment), // Phase B (external, after commit)).mutation(handler) // Phase A (DB, in transaction)Without .transactional(), all steps execute in declaration order regardless of type.
Post-Handler Hooks
Section titled “Post-Handler Hooks”After the order is created, log an audit entry and invalidate a cache.
createOrder: procedure() .mutation(async ({ input, ctx }) => { return ctx.db.order.create({ data: input }); }) .useAfter(({ input, result, ctx }) => { auditLog('order.created', { orderId: result.id, userId: ctx.user.id }); }) .useAfter(({ result }) => { cache.invalidate(`orders:${result.id}`); }).useAfter() hooks run after the handler succeeds (and after events are emitted). Multiple hooks chain in registration order. Errors are caught and logged — they never fail the response. Hooks cannot modify the result.
Client Error Narrowing
Section titled “Client Error Narrowing”On the frontend, you catch an error but error.code is string | undefined — you can’t narrow on it.
Use InferProcedureErrors to extract the declared error types from a procedure:
import type { InferProcedureErrors } from '@veloxts/client';import { isVeloxClientError } from '@veloxts/client';
type OrderErrors = InferProcedureErrors<typeof client.orders.createOrder>;// → { code: 'INSUFFICIENT_STOCK'; data: { sku: string; requested: number; available: number } }// | { code: 'PAYMENT_FAILED'; data: { reason: string; chargeId?: string } }Use it in your catch block:
try { const order = await client.orders.createOrder(data);} catch (error) { if (isVeloxClientError(error)) { switch (error.code) { case 'INSUFFICIENT_STOCK': showStockWarning(error.data.available); break; case 'PAYMENT_FAILED': showPaymentError(error.data.reason); break; } }}Error types flow automatically from .throws() on the server through ClientFromCollection to the client callable. No code generation or manual type definitions needed.
Full Chain
Section titled “Full Chain”Putting it all together — a single procedure declaration that validates, authorizes, orchestrates, and reports:
import { procedure } from '@veloxts/velox';import { authenticated } from '@veloxts/auth';
const createOrder = procedure() .input(CreateOrderSchema) // validate .guard(authenticated) // authorize .policy(OrderPolicy.create) // policy check .throws(InsufficientStock, PaymentFailed) // declare errors .transactional() // DB atomicity .through( // prepare validateStock, reserveStock, chargePayment.onRevert(refundPayment), ) .emits(OrderCreated) // side effects .mutation(async ({ input, ctx }) => { // commit return ctx.db.order.create({ data: input }); }) .useAfter(auditLog) // post-handlerEach line is one concern. The builder chain reads top-to-bottom as a declaration of intent.
Related Content
Section titled “Related Content”- Procedures — Builder chain reference
- Error Handling — VeloxError hierarchy and DomainError
- Policies — Resource authorization with
.policy() - Middleware —
.use()and.useAfter() - Events — Broadcast events (WebSocket/SSE)
- Client Package — Error handling on the frontend