Chapter 8: Request Validation & DTOs: Securing Incoming Requests with Zod, BaseDto, and Route Middleware
Chapter 8: Request Validation & DTOs: Securing Incoming Requests with Zod, BaseDto, and Route Middleware
Why This Chapter Matters
In Chapter 7: Backend Utilities & Error Architecture, we built a clean, professional foundation: asyncHandler eliminated all repetitive try/catch blocks, ApiResponse standardized our JSON envelopes, and ApiError established centralized error handling.
Our controllers and services are now clean and readable. But our backend currently has a fatal security and stability flaw: it blindly trusts everything the client sends.
Look at what happens in our current createUser controller:
// features/users/user.controller.js
export const createUser = asyncHandler(async (req, res) => {
// ⚠️ DANGER: req.body is passed directly to the database service!
const user = await userService.createUser(req.body);
return ApiResponse.created(res, 'User created successfully', user);
});
Now, consider what happens if a malicious client sends this payload to POST /api/users:
{
"email": "not-an-email",
"password": "123",
"role": "superadmin",
"isSuperUser": true,
"balance": 999999
}
If your controller passes raw req.body directly to Mongoose or PostgreSQL:
- Garbage Data Corrupts Your Database: Invalid email strings, negative ages, and missing required fields permanently pollute your collections.
- Mass Assignment Security Exploit: The client just granted themselves
superadminprivileges and added $999,999 to their balance because your code failed to whitelist allowed properties! - Unhandled Exceptions Crash Your Process: When your service executes
userData.name.trim()and the client sentundefinedor a number, JavaScript throws an unhandledTypeError: Cannot read properties of undefined (reading 'trim').
In this chapter, you will build the enterprise-grade request boundary used by experienced backend teams:
BaseDto& DTO Classes: Encapsulating validation schemas with staticvalidate()methods to strip unauthorized fields and enforce strict contracts.- The Mass Assignment Defense: Using Zod's
.strip()and.strict()modes to prevent privilege escalation attacks. - Query & Parameter DTOs: Coercing and validating URL query parameters (
?page=1&limit=20) and route parameters (/:id). - The
validate()Route Middleware: A universal middleware placed on every single route that stops invalid data cold before it ever reaches your controllers or services. - Pure Service Layers: Freeing your services from defensive
if (!email)checks so they focus 100% on business logic.
Part 1: The Core Problem — Never Trust User Input
In a Node.js backend, req.body, req.query, and req.params are untrusted strings sent over the open network by strangers.
Without request validation at the boundary, your system architecture looks like this:
❌ UNPROTECTED ARCHITECTURE:
Client Payload (Malicious / Corrupt)
│
▼
Router ──────────────────────────────────────────────┐
│ (Garbage / malicious payload passes freely) │
▼ │
Controller │
│ (Tries to read properties, risks TypeError) │
▼ │
Service ─────────────────────────────────────────────┤ (Crashes or corrupts DB)
│ (Executes business logic on corrupt data) │
▼ ▼
Database [Corrupted with invalid emails, hacked roles, missing fields]
The 3 Golden Rules of Backend Data Handling
- Fail Fast at the Boundary: Reject invalid requests at the very edge of your application (the HTTP router/middleware layer). Do not let invalid data waste CPU cycles in controllers, services, or database queries.
- Whitelist, Never Blacklist: Never try to filter out "bad" fields (like
delete req.body.role). Instead, strictly define the exact fields you allow and automatically discard or reject everything else. - Services Assume Valid Data: Your service layer should NEVER have to check
if (!email) throw ApiError.badRequest(...). By the time execution reaches the service, the data is guaranteed to be valid, sanitized, and correctly typed.
Part 2: What is a DTO (Data Transfer Object)?
A Data Transfer Object (DTO) is an object that defines how data is transferred over the network between the client and your server, or between different subsystems of your application.
A DTO has two essential responsibilities:
- Shape & Types: It defines what fields exist, their data types (string, number, boolean), and validation rules (minimum length, email format, positive integer).
- Sanitization & Stripping: It strips away unwanted properties (like injected
role: "admin"fields) and coerces types (e.g. converting query string"20"to integer20).
┌────────────────────────────────────────────────────────┐
│ INCOMING HTTP REQUEST │
│ { email: "ALEX@GMAIL.COM ", role: "admin", age: "25" }│
└───────────────────────────┬────────────────────────────┘
│
▼
┌───────────────────────────┐
│ DTO VALIDATION & │
│ SANITIZATION │
└─────────────┬─────────────┘
│
┌──────────────────┴──────────────────┐
│ │
▼ (Invalid) ▼ (Valid & Sanitized)
422 Unprocessable Entity Clean DTO to Service:
"Validation failed..." {
email: "alex@gmail.com",
age: 25
// Note: "role" was STRIPPED!
}
The Mass Assignment Vulnerability
In 2012, Russian developer Egor Homakov famously hacked GitHub by exploiting a Mass Assignment vulnerability in Ruby on Rails. He sent an extra public_key field in an update request and added his own SSH key directly to the official Rails repository organization!
Here is how Mass Assignment happens in Node.js:
// ❌ DANGEROUS: Blindly spreading req.body into the database
app.post('/api/users', async (req, res) => {
// If the attacker sends: { "name": "Eve", "role": "superadmin" }
// The database saves "role": "superadmin"!
const user = await UserModel.create(req.body);
return ApiResponse.created(res, 'User created', user);
});
Here is how a DTO fixes it:
// ✅ SAFE: Only whitelisted fields defined in CreateUserDto are accepted
app.post('/api/users', validate(CreateUserDto), async (req, res) => {
// req.validatedBody contains ONLY name, email, and password.
// Any "role" or "balance" fields were automatically stripped by the DTO schema!
const user = await userService.createUser(req.validatedBody);
return ApiResponse.created(res, 'User created successfully', user);
});
Part 3: Building the BaseDto Class with Zod
In object-oriented design, a base DTO provides a common static validate() method so every child DTO inherits a consistent parsing and error-formatting interface.
We use Zod as our underlying schema engine because it is fast, lightweight, and supports automatic TypeScript type inference.
Let's build shared/dtos/base.dto.js:
// shared/dtos/base.dto.js
import { z } from 'zod';
export class BaseDto {
/**
* Child classes override this static schema with their Zod object definition.
*/
static schema = z.object({});
/**
* Validates and sanitizes data against the class schema.
* Uses safeParse() so it NEVER throws an unhandled exception.
*
* @param {Record<string, any>} data - Raw input data (req.body, req.query, or req.params)
* @returns {{ errors: Array<{ field: string, message: string }> | null, value: any }}
*/
static validate(data) {
const result = this.schema.safeParse(data);
if (!result.success) {
// Map Zod error issues into clean, frontend-friendly field errors
const errors = result.error.errors.map((err) => ({
field: err.path.join('.') || 'root',
message: err.message,
}));
return { errors, value: null };
}
return { errors: null, value: result.data };
}
}
export default BaseDto;
Why safeParse() is Critical
Notice that BaseDto.validate() uses this.schema.safeParse(data) instead of this.schema.parse(data).
If you use .parse(), Zod throws an exception. You would need another try/catch block to handle it. With .safeParse(), Zod returns an object { success: boolean, data?: any, error?: ZodError }, ensuring that invalid input is handled purely as data without runtime throwing.
Part 4: Crafting Feature DTOs
In our feature-wise folder structure (features/users/), we create a dtos/ subfolder:
features/users/
├── dtos/
│ ├── create-user.dto.js ← Validates POST /api/users
│ ├── update-user.dto.js ← Validates PATCH /api/users/:id
│ └── user-query.dto.js ← Validates GET /api/users?page=1&limit=20
├── user.controller.js
├── user.routes.js
├── user.service.js
└── user.model.js
1. CreateUserDto (Request Body Validation)
Let's create features/users/dtos/create-user.dto.js extending BaseDto:
// features/users/dtos/create-user.dto.js
import { z } from 'zod';
import { BaseDto } from '../../../shared/dtos/base.dto.js';
export class CreateUserDto extends BaseDto {
static schema = z
.object({
name: z
.string({ required_error: 'Name is required' })
.trim()
.min(2, { message: 'Name must be at least 2 characters' })
.max(50, { message: 'Name cannot exceed 50 characters' }),
email: z
.string({ required_error: 'Email is required' })
.trim()
.toLowerCase()
.email({ message: 'Invalid email address format' }),
password: z
.string({ required_error: 'Password is required' })
.min(8, { message: 'Password must be at least 8 characters long' })
.regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
.regex(/[0-9]/, { message: 'Password must contain at least one number' }),
age: z
.number({ invalid_type_error: 'Age must be a number' })
.int({ message: 'Age must be an integer' })
.min(18, { message: 'User must be at least 18 years old' })
.max(120, { message: 'Age must be valid' })
.optional(),
})
.strip(); // ⚠️ .strip() strips away unknown keys (like "role: admin")!
}
[!TIP]
.strip()vs..strict():
.strip()(Default in Zod): Silently discards any extra, unlisted fields. Excellent for public APIs where clients might send harmless extra fields..strict(): Throws a validation error if ANY unlisted field is present. Excellent for strict enterprise APIs or banking applications where unexpected fields are treated as malicious anomalies.
2. UpdateUserDto (Partial Updates)
When updating a user via PATCH /api/users/:id, the client only sends the fields they want to change. All fields should be optional:
// features/users/dtos/update-user.dto.js
import { z } from 'zod';
import { BaseDto } from '../../../shared/dtos/base.dto.js';
export class UpdateUserDto extends BaseDto {
static schema = z
.object({
name: z.string().trim().min(2).max(50).optional(),
age: z.number().int().min(18).max(120).optional(),
})
.strip();
}
3. UserQueryDto (URL Query Parameters & Type Coercion)
Query parameters in Express arrive as strings (for example, in GET /api/users?page=2&limit=50, req.query.page is "2", NOT the number 2).
We use Zod's .transform() to coerce query strings into numbers, provide safe default values, and enforce pagination boundaries:
// features/users/dtos/user-query.dto.js
import { z } from 'zod';
import { BaseDto } from '../../../shared/dtos/base.dto.js';
export class UserQueryDto extends BaseDto {
static schema = z
.object({
page: z
.string()
.optional()
.default('1')
.transform((val) => Math.max(1, parseInt(val, 10) || 1)),
limit: z
.string()
.optional()
.default('20')
.transform((val) => Math.min(100, Math.max(1, parseInt(val, 10) || 20))),
status: z.enum(['active', 'pending', 'suspended']).optional(),
search: z.string().trim().optional(),
})
.strip();
}
Part 5: The Universal validate() Route Middleware Factory
Now we bring BaseDto and Chapter 7's ApiError together with the universal validate() middleware.
How the Middleware Works
HTTP Request: POST /api/users
│
▼
Route Definition:
router.post('/', validate(CreateUserDto), userController.create)
│
┌────────────────┴────────────────┐
│ │
[CreateUserDto.validate] [CreateUserDto.validate]
returns { errors: null } returns { errors: [...] }
│ │
Attaches req.validatedBody Calls next(ApiError.unprocessable(..., errors))
Calls next() │
│ ▼
▼ Centralized Error Handler (from Chapter 7)
userController.create │
│ ▼
userService.createUser Client receives 422 with
(Guaranteed clean data!) exact field-level errors
Implementing validate.js
// shared/middleware/validate.js
import { ApiError } from '../errors/api-error.js';
/**
* Higher-order middleware factory that validates incoming requests against a DTO class.
*
* @param {typeof import('../dtos/base.dto.js').BaseDto} DtoClass - The DTO class extending BaseDto
* @param {'body' | 'query' | 'params'} [source='body'] - The request property to validate
* @returns {import('express').RequestHandler}
*/
export const validate = (DtoClass, source = 'body') => {
return (req, res, next) => {
// 1. Run DTO validation on the target request property
const { errors, value } = DtoClass.validate(req[source]);
// 2. If validation fails, reject immediately with 422 Unprocessable Entity
if (errors) {
return next(ApiError.unprocessable('Validation failed', errors));
}
// 3. Attach the sanitized, validated value to the request
const capitalizedSource = source.charAt(0).toUpperCase() + source.slice(1);
req[`validated${capitalizedSource}`] = value;
// 4. Continue to the next handler
next();
};
};
export default validate;
Part 6: Putting It All Together in Routes, Controllers, and Services
Look how clean and readable our route definition becomes when every route is guarded with validate():
1. The Route Layer (user.routes.js)
// features/users/user.routes.js
import { Router } from 'express';
import { validate } from '../../shared/middleware/validate.js';
import { CreateUserDto } from './dtos/create-user.dto.js';
import { UpdateUserDto } from './dtos/update-user.dto.js';
import { UserQueryDto } from './dtos/user-query.dto.js';
import { userController } from './user.controller.js';
const router = Router();
// GET /api/users — Validate query parameters (?page=1&limit=20&status=active)
router.get(
'/',
validate(UserQueryDto, 'query'),
userController.getUsers
);
// GET /api/users/:id — Get user by ID
router.get(
'/:id',
userController.getUserById
);
// POST /api/users — Validate body before controller or service runs
router.post(
'/',
validate(CreateUserDto, 'body'),
userController.createUser
);
// PATCH /api/users/:id — Validate partial update body
router.patch(
'/:id',
validate(UpdateUserDto, 'body'),
userController.updateUser
);
// DELETE /api/users/:id — Delete user
router.delete(
'/:id',
userController.deleteUser
);
export default router;
2. The Controller Layer (user.controller.js)
Because validate() already caught and rejected invalid data, our controller is compact, safe, and readable:
// features/users/user.controller.js
import { ApiResponse } from '../../shared/utils/api-response.js';
import { userService } from './user.service.js';
export const userController = {
createUser: async (req, res) => {
// req.validatedBody is guaranteed to be valid and stripped of unwanted fields!
const user = await userService.createUser(req.validatedBody);
return ApiResponse.created(res, 'User created successfully', user);
},
getUsers: async (req, res) => {
// req.validatedQuery contains parsed numbers and safe defaults
const { page, limit, status, search } = req.validatedQuery;
const { users, total } = await userService.getUsers({ page, limit, status, search });
return ApiResponse.paginated(res, 'Users retrieved successfully', users, {
page,
limit,
totalItems: total,
totalPages: Math.ceil(total / limit),
});
},
getUserById: async (req, res) => {
const user = await userService.getUserById(req.params.id);
return ApiResponse.ok(res, 'User retrieved successfully', user);
},
updateUser: async (req, res) => {
const updatedUser = await userService.updateUser(req.params.id, req.validatedBody);
return ApiResponse.ok(res, 'User updated successfully', updatedUser);
},
deleteUser: async (req, res) => {
await userService.deleteUser(req.params.id);
return ApiResponse.noContent(res);
},
};
3. The Service Layer Stays Pure (user.service.js)
Because our validate() middleware guaranteed that input is sanitized, our Service layer contains zero defensive if (!email) statements. It focuses purely on business logic:
// features/users/user.service.js
import { UserModel } from './user.model.js';
import { ApiError } from '../../shared/errors/api-error.js';
export class UserService {
async createUser(cleanDto) {
// 1. Business Logic Check: Does a user with this email already exist?
const existingUser = await UserModel.findOne({ email: cleanDto.email });
if (existingUser) {
throw ApiError.conflict('A user with this email address already exists');
}
// 2. Persist sanitized user
const user = await UserModel.create(cleanDto);
return user;
}
async getUserById(id) {
const user = await UserModel.findById(id).lean();
if (!user) {
throw ApiError.notFound('User not found');
}
return user;
}
async updateUser(id, updateDto) {
const user = await UserModel.findByIdAndUpdate(id, updateDto, { new: true, runValidators: true });
if (!user) {
throw ApiError.notFound('User not found');
}
return user;
}
async deleteUser(id) {
const user = await UserModel.findByIdAndDelete(id);
if (!user) {
throw ApiError.notFound('User not found');
}
}
async getUsers({ page, limit, status, search }) {
const query = {};
if (status) query.status = status;
if (search) query.name = { $regex: search, $options: 'i' };
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
UserModel.find(query).skip(skip).limit(limit).lean(),
UserModel.countDocuments(query),
]);
return { users, total };
}
}
export const userService = new UserService();
Part 7: What the Client Receives on Validation Failure
When a client sends invalid or malicious data to POST /api/users:
{
"name": "A",
"email": "invalid-email",
"password": "short"
}
Our validate() middleware rejects it immediately with HTTP 422 Unprocessable Entity:
// HTTP/1.1 422 Unprocessable Entity
{
"success": false,
"message": "Validation failed",
"errors": [
{
"field": "name",
"message": "Name must be at least 2 characters"
},
{
"field": "email",
"message": "Invalid email address format"
},
{
"field": "password",
"message": "Password must be at least 8 characters long"
}
]
}
Frontend developers can map directly over the errors array to highlight input fields in red with precise feedback:
// Frontend React / Next.js Form Handler:
if (!response.data.success && response.data.errors) {
response.data.errors.forEach(({ field, message }) => {
setFieldError(field, message);
});
}
Architecture Quick Reference
Request Validation & DTO Flow:
□ BaseDto defines static schema & static validate() using Zod safeParse()
□ Child DTOs extend BaseDto and specify .strip() to defeat Mass Assignment attacks
□ Query DTOs coerce strings to numbers with .transform() and safe fallbacks
□ Routes use validate(DtoClass, 'body' | 'query' | 'params') before controllers
□ Controllers consume req.validatedBody / req.validatedQuery (clean & typed)
□ Services remain 100% pure business logic with zero defensive field checks
□ Validation failures return HTTP 422 Unprocessable Entity with field-level errors
Summary & What Comes Next
We now have a complete, production-grade request handling pipeline across our foundational chapters:
- Chapter 6 (Building from Scratch): Created the server, Express pipeline, Mongoose models, and feature architecture.
- Chapter 7 (Utilities & Error Architecture): Eliminated boilerplate with
asyncHandler,ApiResponse,ApiError, and centralized error handling. - Chapter 8 (Request Validation & DTOs): Secured the HTTP boundary with
BaseDto, Zod schemas, and thevalidate()route middleware.
With our server's internal architecture battle-tested, we are now ready to zoom out and master the protocol that connects our server to the outside world.
In Chapter 9: HTTP Deep Dive, we will dissect the protocol from the ground up: HTTP methods, idempotent vs. non-idempotent semantics, the full landscape of HTTP status codes, essential request and response headers, and the complete HTTP request-response lifecycle.