Validation Pipeline
Introduction
Section titled “Introduction”Use the Validation Pipeline to validate commands and queries before they reach the handler.
Xeno.JS supports two validation approaches:
- the built-in Zod validator;
- custom validation strategies.
The pipeline is used for both commands and queries.
Note: This page explains how to enable and register validation. For creating the Zod schema itself, see the CQRS documentation for Commands and Queries.
Before you start
Section titled “Before you start”You need:
- an
AppBuilderinstance; - a Command or Query;
- a request
intent; - a Zod schema registered for that intent, or a custom validation strategy.
If you are creating the request and its schema, start with:
Enable validation
Section titled “Enable validation”Validation is enabled through the addPipeline() configuration.
You do not need to register ValidationPipeline manually.
Configure either validation.zod or validation.customValidationStrategy:
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: {}, } }) .build()If at least one validation strategy is configured, Xeno.JS adds validation to both the command and query pipelines.
You can also configure Zod and custom validation at the same time.
Use the built-in Zod validator
Section titled “Use the built-in Zod validator”Xeno.JS provides a Zod-based validator that can be configured during application bootstrap.
The configuration is a map where:
- the key is the request
intent; - the value is the corresponding Zod schema.
The intent is the identifier associated with the registered command or query handler.
Register a Zod schema
Section titled “Register a Zod schema”For example, assume a command uses the intent:
CREATE_USER_HANDLERRegister its schema during bootstrap:
import { z } from 'zod'
import { AppBuilder } from '@xeno-js/shared'
import { createUserSchema } from './application/users/create-user.schema'
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CREATE_USER_HANDLER: createUserSchema, }, } }) .build()The schema itself belongs with the command or query it validates.
For example:
src/├── application/│ └── users/│ ├── create-user.command.ts│ ├── create-user.handler.ts│ └── create-user.schema.ts└── app.tsThe schema can be defined in create-user.schema.ts:
import { z } from 'zod'
export const createUserSchema = z.object({ email: z.email(), name: z.string().min(2),})The details of how the schema is associated with a Command or Query are documented in the corresponding CQRS guides:
Register multiple schemas
Section titled “Register multiple schemas”You can register schemas for multiple commands and queries in the same bootstrap configuration:
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CREATE_USER_HANDLER: createUserSchema, UPDATE_USER_HANDLER: updateUserSchema, GET_USER_HANDLER: getUserSchema, }, } }) .build()Each request is validated against the schema registered for its intent.
Understand schema lookup
Section titled “Understand schema lookup”The Zod validator uses the request intent to find its schema.
For example:
Request intent: CREATE_USER_HANDLER │ ▼validation.zod.schemas │ └── CREATE_USER_HANDLER → createUserSchemaThis means the registration key must match the request intent exactly.
If the request has:
{ intent: 'CREATE_USER_HANDLER',}the schema must be registered as:
schemas: { CREATE_USER_HANDLER: createUserSchema,}Missing schema
Section titled “Missing schema”A missing schema does not produce a validation failure.
If no schema is registered for the request intent, the built-in Zod validator:
- logs a warning;
- returns a successful validation result;
- allows the request to continue.
The warning identifies the missing intent.
Therefore, if a request is expected to be validated with Zod, make sure its intent is present in validation.zod.schemas.
Add custom validation
Section titled “Add custom validation”Use validation.customValidationStrategy when the validation logic cannot or should not be expressed as a Zod schema.
A custom validation strategy implements:
IStrategy<IRequest, boolean>and exposes:
execute(request)The method must return a Result.
Basic custom validator
Section titled “Basic custom validator”For example:
import type { IRequest, IStrategy } from '@xeno-js/shared'import { AppError, Result } from '@xeno-js/shared'
const validateUser: IStrategy<IRequest, boolean> = { async execute(request) { const email = (request as { email?: string }).email
if (!email?.endsWith('@example.com')) { return Result.fail( AppError.validationError( request.intent, 'Email must use the @example.com domain', ), ) }
return Result.ok(true) },}Register the strategy during bootstrap:
const app = new AppBuilder() .addPipeline((config) => { config.validation.customValidationStrategy = [ () => validateUser, ] }) .build()The factory function receives the active service scope.
This allows a custom strategy to resolve application services when validation requires dependencies.
For example:
import type { IRequest, IStrategy, IServiceScope,} from '@xeno-js/shared'
import { AppError, Result } from '@xeno-js/shared'
const validateUser = ( scope: IServiceScope,): IStrategy<IRequest, boolean> => { const userRepository = scope.resolve('USER_REPOSITORY')
return { async execute(request) { const email = (request as { email?: string }).email
if (!email) { return Result.fail( AppError.validationError( request.intent, 'Email is required', ), ) }
const exists = await userRepository.existsByEmail(email)
if (exists) { return Result.fail( AppError.validationError( request.intent, 'Email is already registered', ), ) }
return Result.ok(true) }, }}Register it with the application:
const app = new AppBuilder() .addPipeline((config) => { config.validation.customValidationStrategy = [ (scope) => validateUser(scope), ] }) .build()The exact dependency token and repository API depend on your application.
Register multiple custom validators
Section titled “Register multiple custom validators”You can register more than one custom validation strategy:
const app = new AppBuilder() .addPipeline((config) => { config.validation.customValidationStrategy = [ (scope) => validateUser(scope), (scope) => validateAccount(scope), (scope) => validateBusinessRules(scope), ] }) .build()Strategies are executed in registration order.
If a strategy returns a failed Result, validation stops immediately and the remaining strategies are not executed.
The request also does not continue to the next pipeline stage or handler.
Combine Zod and custom validation
Section titled “Combine Zod and custom validation”Zod and custom strategies can be used together.
For example:
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CREATE_USER_HANDLER: createUserSchema, }, }
config.validation.customValidationStrategy = [ (scope) => validateUser(scope), ] }) .build()This is useful when:
- Zod validates the structure and basic constraints of the request;
- custom validation checks application-specific rules.
For example:
CREATE_USER_HANDLER │ ▼ Zod validation │ ▼ custom validation │ ▼ HandlerIf any validation strategy fails, execution stops at that point.
What happens when validation succeeds?
Section titled “What happens when validation succeeds?”When every configured validation strategy returns a successful Result, the request continues through the remaining pipeline and eventually reaches its handler.
Conceptually:
Command / Query │ ▼Validation │ ├── failed → Result.fail(...) │ └── passed │ ▼ next pipeline │ ▼ HandlerThe validation pipeline does not modify a successful request result. It allows execution to continue.
What happens when validation fails?
Section titled “What happens when validation fails?”A failed validation returns a failed Result.
The next pipeline stage is not executed, and the handler is not called.
For a Zod validation failure, Xeno.JS produces a validation error containing the validation details.
For example, a Zod schema failure can result in an error describing the invalid field:
Validation failed for schema: [email] Invalid email addressMultiple Zod issues are combined into the validation error message.
The validation error uses the VALIDATION_FAILED error category and a 400 status.
Validation for commands and queries
Section titled “Validation for commands and queries”The same validation configuration is used for both commands and queries.
For example:
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CREATE_USER_HANDLER: createUserSchema, GET_USER_HANDLER: getUserSchema, }, } }) .build()Both requests can be validated through the same pipeline configuration:
Command │ └── Validation └── CREATE_USER_HANDLER → createUserSchema
Query │ └── Validation └── GET_USER_HANDLER → getUserSchemaFor request-specific schema definitions, see:
Troubleshooting
Section titled “Troubleshooting”Validation is not running
Section titled “Validation is not running”Check the following:
addPipeline()is called on theAppBuilder;- either
validation.zodorvalidation.customValidationStrategyis configured; - the Zod schema is registered under the correct request
intent; - the request is executed through the Xeno.JS mediator;
- the request reaches the configured application pipeline.
The Zod schema is never applied
Section titled “The Zod schema is never applied”Check the schema registry:
config.validation.zod = { schemas: { CREATE_USER_HANDLER: createUserSchema, },}
// Check if you added the handler in the container with the same keycontainer.addScoped('CREATE_USER_HANDLER', () => new CreateUserHandler())
// Check if you are using the correct intent in the command or queryexport class CreateUserCommand extends Command<null> { constructor() { super('CREATE_USER_HANDLER') }}
// Che if you are using the correct key in the registryexport type MyRegistry { CREATE_USER_HANDLER: CreateUserHandler}Then check the request intent:
request.intent === 'CREATE_USER_HANDLER'The two values must match exactly.
If the schema is missing for an intent, Xeno.JS logs a warning and continues without Zod validation.
The custom validator is not running
Section titled “The custom validator is not running”Check that the strategy is registered through:
config.validation.customValidationStrategy = [ (scope) => createCustomValidator(scope),]Also verify that:
- the factory returns an object implementing
execute(); execute()returns aResult;- the strategy is configured before
build().
One custom validator prevents the others from running
Section titled “One custom validator prevents the others from running”This is expected when the previous validator returns a failed Result.
Validation stops at the first failure. This prevents the request from continuing to the handler or to subsequent validation strategies.
Related docs
Section titled “Related docs”- Application Overview
- Create a Command
- Create a Query
- Create a Handler
- Exception Pipeline
- Performance Pipeline
- Logging Pipeline
Support Us
Section titled “Support Us”Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section
