Overview
Validation boundaries define where and how input is checked. Untrusted data (HTTP requests, files, messages) is validated at the system edge for shape and format; domain objects enforce business invariants in their constructors and methods; and the database enforces final constraints. Each layer checks what it is responsible for.
The 'parse, don't validate' principle says convert raw input into typed, valid objects once at the boundary (an Email value object, a Quantity greater than zero). After that, the rest of the code can trust its inputs instead of re-checking everywhere. Validation is also a security control: unvalidated input causes injection and logic flaws.
Passports are checked at the border (edge validation), boarding passes at the gate (business rule), and seat numbers on the plane (final constraint). Each checkpoint verifies what it is responsible for, and once through security, passengers are not re-scanned at every shop.
When to use it
- Accepting any external input.
- Designing constructors of domain objects.
- Preventing duplicated or missing checks across layers.
Where it shows up in interviews
Recognize it when: API or CLI accepting user input.
- Design a URL shortener API
- Design a parking lot entry
Recognize it when: objects must never be invalid.
- Design a bank account
- Design an order
Where it is used in real software
Schema validation libraries check request shape at the edge (z.object, @NotNull, @Size).
Allow-list validation at trust boundaries is a core defense against injection.
NOT NULL, UNIQUE, CHECK, and foreign keys are the last line of defense.
Key terms
- Trust boundary
- Where data enters from an untrusted source.
- Syntactic validation
- Format and type: is this an email-shaped string?
- Semantic validation
- Business meaning: is this email already registered?
- Parse, don't validate
- Convert input into types that guarantee validity.
- Invariant
- Rule an object always satisfies.
How it works, step by step
- 1Validate shape at the edge
Types, required fields, lengths, formats (allow-lists).
- 2Parse into value objects
Email, Money, Quantity.
- 3Enforce invariants in domain objects
Constructors and methods reject invalid states.
- 4Check business rules in services
Uniqueness, availability, permissions.
- 5Back up with database constraints
Unique indexes, checks.
Where each check belongs
POST /accounts/{id}/withdraw { amount }
| Check | Layer | Example |
|---|---|---|
| amount is a number, required | Edge schema | Zod / Bean Validation |
| amount > 0, max 2 decimals | Value object | Money.of(amount) |
| balance >= amount | Domain entity | account.withdraw(money) |
| caller owns the account | Application service | Authorization check |
| balance never negative | Database | CHECK (balance >= 0) |
NOWCheck: amount is a number, required | Layer: Edge schema | Example: Zod / Bean Validation
Each layer validates what it owns, so no check is missing or duplicated inconsistently.
Implementation
import { z } from "zod"; // Edge: shape and formatconst WithdrawBody = z.object({ amount: z.number().positive().max(10_000) }).strict(); // Value object: parsed once, always validclass Money { private constructor(readonly cents: number) {} static fromDecimal(amount: number) { const cents = Math.round(amount * 100); if (Math.abs(amount * 100 - cents) > 1e-6) throw new Error("At most 2 decimal places"); return new Money(cents); }} // Domain: invariantsclass Account { constructor(readonly id: string, readonly ownerId: string, private balanceCents: number) {} withdraw(m: Money) { if (m.cents > this.balanceCents) throw new Error("Insufficient funds"); this.balanceCents -= m.cents; }} app.post("/accounts/:id/withdraw", async (req, res) => { const parsed = WithdrawBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ errors: parsed.error.flatten() }); const account = await accounts.get(req.params.id); if (account.ownerId !== req.user.id) return res.status(403).end(); // authorization account.withdraw(Money.fromDecimal(parsed.data.amount)); await accounts.save(account); res.status(204).end();});Complexity and performance
Once per request.
Types carry guarantees.
Trade-offs
Checking in several layers catches more but can drift; give each layer distinct responsibilities.
APIs for forms should return all field errors; domain objects usually fail fast.
Variants and related techniques
OpenAPI or JSON Schema validates requests automatically.
Factory functions returning a result for valid or invalid input.
Common mistakes
- Validating only in the frontend.
Fix: Clients can be bypassed; always validate on the server.
- Deny-list validation.
Fix: Use allow-lists for formats and characters.
- Primitive strings flowing into the domain.
Fix: Parse into value objects at the boundary.
Interview questions
Where should validation happen?
At trust boundaries for shape and format, in value objects and entities for invariants, in services for context-dependent business rules and authorization, and in the database for final constraints.
What does 'parse, don't validate' mean?
Instead of checking a string and passing it on as a string, convert it into a type that can only hold valid values, so downstream code never needs to re-check.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| Validate a booking request end to end | Easy | Layered checks. |
| Design value objects for Quantity, Money, Email | Medium | Always-valid types. |