Vishal.dev
Back
Backend

Validate at the Boundary or Pay for It Everywhere Else

March 12, 2026 3 min read
ZodValidationAPI Design

Validation is one of those things that everyone agrees is important but almost nobody does consistently. The most common failure pattern is defensive validation scattered throughout the codebase — every function checks inputs, every service re-validates the same data, and the result is duplicated logic with inconsistent error messages.

Boundary Validation Principle

Validate once, at the system boundary, with a clear schema. Everything inside the boundary can assume the data is structurally valid. This is the same principle that makes strongly-typed languages safer — the type checker validates at compile time; runtime validation at the API boundary does the same at runtime.

// Boundary: request arrives here
const schema = z.object({
  email: z.string().email(),
  age: z.number().int().positive().min(13),
  preferences: z.object({
    theme: z.enum(['light', 'dark', 'system']).optional(),
    notifications: z.boolean().default(true)
  })
});

const result = schema.parse(req.body);
// From here on, `result` is trusted data

After this point, no function should check typeof email === 'string' or age > 0. The schema guarantees it.

Service-Layer Overvalidation Is a Smell

When services re-validate data that's already been checked at the boundary, it signals one of two problems:

  1. The boundary validation is incomplete, and services are compensating.
  2. The validation logic is duplicated, and nobody knows which layer to trust.

To fix (1), strengthen the boundary schema. To fix (2), remove the service-layer checks and let boundary validation fail fast. The worst outcome: a service silently changes its validation rules while the boundary still accepts old formats, creating a window of invalid but accepted data.

Zod Keeps Contracts Explicit

Zod's main advantage isn't the validation itself — it's the type inference. A Zod schema generates a TypeScript type automatically:

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'viewer']),
  metadata: z.record(z.string(), z.unknown()).optional()
});

type User = z.infer;
// User type: { id: string, email: string, role: 'admin' | 'user' | 'viewer', metadata?: Record }

This means the schema is the single source of truth for both runtime validation and compile-time types. Change the schema, change both. No more TypeScript types that drift from actual API contracts.

What to Validate at Each Boundary

BoundaryValidateExample
API (HTTP)Structure, types, ranges, auth tokensZod schema on req.body + req.headers
ServiceBusiness rules, relationships, state transitions"Can this user edit this document?"
DatabaseConstraints, uniqueness, referential integrityUNIQUE index, FOREIGN KEY, CHECK constraint

Each layer validates different concerns. Overlapping validation is wasteful; gaps are dangerous. Map your validation to these boundaries explicitly.

Practical Rules

  • Fail fast, fail loud. Validation errors should throw immediately with clear messages. Don't collect every error in a list — the first error reveals the problem. (Exception: form validation where UX requires showing all errors at once.)
  • Never trust JSON.parse without Zod. Every time you parse untrusted JSON, wrap it in a schema. A single JSON.parse without validation is one refactoring away from crashing production.
  • Use branded types for validated data. Zod's .brand() creates nominal types so you can't accidentally mix validated and unvalidated data:
const EmailSchema = z.string().email().brand('Email');
type Email = z.infer;

function sendEmail(to: Email, body: string) {
  // `to` must be a validated email
}

The boundary validation principle is simple: define your contract once, enforce it at the edge, and trust it everywhere inside. Every drift from this principle adds maintenance cost and surface area for bugs.