Validation Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
validation-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Four packages compose:
@modularityjs/validation-schema—Schema<T>interface,ValidationResult<T>, and the native-schema side-channel registry (recordNativeSchema/getNativeSchema). Zero deps, no@Module, infrastructure-tier. Apps doing OpenAPI documentation only need this (noValidationModulewiring).@modularityjs/validation—Validatorabstract (checkreturnsValidationResult<T>,assertthrowsValidationException),ValidationModule(declares theValidatorcontract). Import this only when an app injectsValidatorat runtime (e.g. viaHttpValidationModule).@modularityjs/validation-zod—ZodValidatordriver bound viapreferences: [{ provide: Validator, useClass: ZodValidator }], pluszodSchema(zodType)adapter that wraps a Zod type into a frameworkSchema<T>. CallsrecordNativeSchema(schema, 'zod', zodType)so OpenAPI drivers can recover the Zod schema for documentation.@modularityjs/http-validation—HttpValidationModule+ the@ValidatedBody/@ValidatedQuery/@ValidatedParams/@ValidatedHeadersparameter decorators.
HttpValidationModule registers its @Validated* parameter resolvers declaratively via preferences (not afterLoad) and depends on a Validator driver being wired. Include a driver (ValidationZodModule) somewhere in modules: [...]; the loader topologically sorts modules, so relative order does not matter — boot fails only if no Validator driver is present at all, never because of ordering.
The decorators take a Schema<T> value, not a class — call zodSchema(z.object({...})) once at module scope (or import-level constant), pass the result. Each call to a @Validated* decorator registers the schema in a module-scoped registry; declaring the schema inline inside the controller method (e.g. @ValidatedBody(zodSchema(z.object({...}))) re-evaluated per request) leaks a new registry entry each time the decorator factory runs — only fine because decorators run once at class-evaluation time, not per request.
On schema failure the underlying validator.assert throws ValidationException, which FrameworkExceptionFilter (from @modularityjs/http-fastify) maps to 422 with body { code: 'VALIDATION_FAILED', message, errors: [{ field, message, code }] }. The Fastify adapter applies this filter as an implicit fallback for every FrameworkException, so a ValidationException maps to 422 even without registering the filter — the explicit @UseErrorFilter(FrameworkExceptionFilter) below is optional and only needed to override or customize that default.
import { Inject, Injectable } from '@modularityjs/di';
import {
Controller,
Get,
HttpControllersPool,
HttpModule,
Post,
UseErrorFilter,
} from '@modularityjs/http';
import {
FrameworkExceptionFilter,
HttpFastifyModule,
} from '@modularityjs/http-fastify';
import {
HttpValidationModule,
ValidatedBody,
ValidatedQuery,
} from '@modularityjs/http-validation';
import { Module } from '@modularityjs/modularity';
import { ValidationModule } from '@modularityjs/validation';
import { ValidationZodModule, zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';
const createUserSchema = zodSchema(
z.object({
name: z.string().min(1),
age: z.number().int().nonnegative(),
}),
);
const listFilterSchema = zodSchema(
z.object({
limit: z.coerce.number().int().positive().max(100), // query strings are strings — coerce
}),
);
@Injectable()
@Controller('/users')
@UseErrorFilter(FrameworkExceptionFilter) // turns ValidationException into 422
class UsersController {
@Post('/')
create(@ValidatedBody(createUserSchema) body: { name: string; age: number }) {
return { created: body };
}
@Get('/')
list(@ValidatedQuery(listFilterSchema) filter: { limit: number }) {
return { filter };
}
}
@Module({
name: 'users',
imports: [HttpModule],
providers: [UsersController, FrameworkExceptionFilter],
pools: [
{ pool: HttpControllersPool, key: 'users', useClass: UsersController },
],
})
class UsersModule {}
// modules: [
// ModularityModule,
// HttpModule.forRoot({ port: 3000 }),
// HttpFastifyModule,
// ValidationModule, // contract
// ValidationZodModule, // driver — provides Validator (order-independent)
// HttpValidationModule, // registers @Validated* resolvers via preferences
// UsersModule,
// ]To validate outside HTTP (jobs, CLI), inject Validator directly: validator.assert(schema, value) returns the typed value or throws; validator.check(schema, value) returns { ok: true, value } | { ok: false, errors } for inline handling. Don't write fallback null-checks around @Inject(Validator) — boot fails if no driver is bound, so the dependency is always present at runtime.