Validation is a gate, not a vibe
Refuse bad HTTP input with ValidationPipe or Zod before the service runs. Trusting body shape inside the use case is guidance, not a gate.
Validation is a gate. Bad input should die at the HTTP edge. If the service sees an untrusted body and "validates later," you have guidance, not a gate.
I mean that the way I mean a pre-write lint gate for agent edits. A README that says "always validate DTOs" is advice. A global pipe that refuses the request before the use case runs is enforcement. Same thesis, smaller surface: shape errors never become service arguments.
The failure mode
A Nest handler that trusts req.body compiles. TypeScript types on the parameter are erased. At runtime you have a plain object. Fields may be missing, wrong-typed, or carrying keys you never named. That is not a theoretical edge case — it is the default until you wire a pipe.
@Post('transfer')
transfer(@Body() body: TransferDto) {
// Without ValidationPipe, TransferDto is a lie at runtime
return this.payments.transfer(body.fromId, body.toId, body.amount);
}The service then does defensive checks: if (!amount), if (typeof amount !== 'number'). Those checks are late. They sit next to business rules. They are easy to skip on the next endpoint — especially when someone is rushing a sibling route and copies the happy path. They return 500s when someone forgot a branch, or 200s with coerced junk when someone used Number(undefined). In a payments-shaped flow, inventing a zero transfer because the field was missing is worse than a loud 400.
Refuse before the call. The service receives a value that already passed the allowlist. Domain code can talk about accounts and balances instead of "is this even an int?"
ValidationPipe as the default gate
Wire it once globally so every controller inherits the same refusal behavior:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: false },
}),
);
await app.listen(3000);
}whitelist strips unknown properties. forbidNonWhitelisted turns extras into 400s instead of silent drops. Silent drops feel polite until a client sends amountCents and you strip it, then debit amount as undefined somewhere softer. transform instantiates the DTO class so decorators run on a real instance. Keep implicit conversion off unless you accept string-to-number surprises from query params — prefer @Type(() => Number) on the fields you mean to coerce. Explicit beats clever when the wire is a string by design.
DTO:
export class TransferDto {
@IsUUID()
fromId!: string;
@IsUUID()
toId!: string;
@IsInt()
@Min(1)
amount!: number;
}Controller stays thin:
@Post('transfer')
transfer(@Body() dto: TransferDto) {
return this.payments.transfer({
fromId: dto.fromId,
toId: dto.toId,
amount: dto.amount,
});
}The service types its command with the same fields. It does not re-run @IsUUID(). It assumes the gate held. That assumption is only safe if every HTTP entrypoint uses the pipe — including microservice hybrids and custom adapters where you might forget useGlobalPipes. When I audit a Nest app, the first question is not "do you have DTOs?" It is "does every body actually hit the pipe?"
Zod at the edge
class-validator is the Nest default. Zod works when you want a single schema for parse + type inference. I reach for it when the same shape needs to live outside Nest too — a shared package, a worker, a script that should fail the same way the API does.
const TransferSchema = z.object({
fromId: z.string().uuid(),
toId: z.string().uuid(),
amount: z.number().int().positive(),
});
type TransferInput = z.infer<typeof TransferSchema>;
@Post('transfer')
transfer(@Body() body: unknown) {
const dto = TransferSchema.parse(body); // throws → 400 via filter
return this.payments.transfer(dto);
}Or wrap parse in a pipe so controllers stay free of try/catch:
@Injectable()
export class ZodPipe implements PipeTransform {
constructor(private readonly schema: z.ZodType) {}
transform(value: unknown) {
const parsed = this.schema.safeParse(value);
if (!parsed.success) {
throw new BadRequestException(parsed.error.flatten());
}
return parsed.data;
}
}
@Post('transfer')
transfer(@Body(new ZodPipe(TransferSchema)) dto: TransferInput) {
return this.payments.transfer(dto);
}Either library is fine. The rule is the same: parse at the boundary, pass plain data inward. Do not make the service import Zod or class-validator just to re-check what the edge already refused.
What does not count as a gate
- JSDoc on the service. Comments are advisory. Agents and tired humans both skip them.
- "We use TypeScript." Types do not validate JSON. A typed parameter is a compile-time story; the wire is runtime.
- Optional chaining through the stack.
body?.amount ?? 0invents a transfer of zero. That is not validation. That is inventing a business event to avoid a null check. - Validating inside the repository. The DB constraint is a last backstop. It is not your HTTP contract. Unique violations become 500s unless you map them. Prefer 400 at the pipe for shape; use DB constraints for races and invariants the app cannot see yet.
- A partial DTO without
forbidNonWhitelisted. Attackers and buggy clients send fields you ignore. Ignoring is not refusing when those fields change meaning later — mass assignment is how "ignore unknown" turns into "accept role: admin."
Service code after a real gate
Once the edge holds, service code reads like the domain. That is the payoff you feel in review: the use case file stops being a second validation layer and starts being the rules you actually care about.
async transfer(cmd: { fromId: string; toId: string; amount: number }) {
if (cmd.fromId === cmd.toId) {
return err('same_account');
}
// balance checks, ledger writes — not "is amount an int?"
}Domain errors stay domain errors. Shape errors never arrive. Map err('same_account') to 409 or 400 in the controller or an interceptor. Do not make the service throw ConflictException just because the only caller today is HTTP. Tomorrow's caller might be a job. Keep status codes at the HTTP wall.
Same thesis, different stack
Agents that write Nest code will copy soft validation if the repo already trusts body: any. Humans do too. I have been on both sides of that copy. Docs that say "always validate DTOs" are guidance. A global ValidationPipe (or a Zod pipe on every body) is a gate: bad input never becomes a service argument. That is the same shape as gates, not guidance — refuse before the work runs — applied to HTTP instead of agent edits. One layer of enforcement beats another paragraph in the README.
Checklist
- Global ValidationPipe (or equivalent) on every HTTP app bootstrap
whitelist+forbidNonWhitelistedon- DTO per command; no shared blob for create/update/query
- Services take commands/types without class-validator decorators required to run
- No
body: any, no@Body() body: Record<string, unknown>passed straight through - Query and param DTOs get the same pipe, not only POST bodies
- Filters map Zod / class-validator failures to 400 with a stable error body
If a bad payload can reach a prisma.create call, you do not have a gate yet. You have a vibe. Vibes work until the first weird client. Then you wish the edge had refused louder.
Related
- Nest skill is the boundary — where ValidationPipe sits in the layer stack
- Types that stop at the wall — brands and Results after the gate