DTOs: the pattern that keeps API input from contaminating business logic.
Data Transfer Objects create a boundary between what comes in over the wire and what your application actually works with.
A DTO (Data Transfer Object) is a type that represents data crossing a boundary — typically incoming API requests or outgoing API responses. The pattern solves a specific problem: the shape of data in your API does not always match the shape your business logic needs.
Why the boundary matters
Consider a user creation endpoint. The client sends:
{
"name": "Alice Smith",
"email": "alice@example.com",
"password": "hunter2"
}
But your User domain object looks like:
interface User {
id: string; // generated by the server
name: string;
email: string;
passwordHash: string; // hashed, never stored as plain text
createdAt: Date; // generated by the server
updatedAt: Date; // generated by the server
}
If you directly use the incoming request body as a User, you have a type mismatch, and you have let raw API input into your domain layer. The DTO sits between them.
Defining DTOs
// What the API accepts
interface CreateUserDTO {
name: string;
email: string;
password: string;
}
// What the API returns (never expose password hash or sensitive internals)
interface UserResponseDTO {
id: string;
name: string;
email: string;
createdAt: string; // ISO string, not Date object — JSON-friendly
}
Validation at the DTO boundary
DTOs are the right place to validate incoming data. Nothing should pass the DTO boundary unless it is correct.
Using a library like zod:
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
});
type CreateUserDTO = z.infer<typeof CreateUserSchema>;
// In the controller
app.post("/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const dto: CreateUserDTO = result.data;
const user = await userService.create(dto);
res.status(201).json(toUserResponseDTO(user));
});
After safeParse succeeds, dto is typed and validated. The service layer receives clean, typed data — not req.body with type any.
Mapping between DTO and domain
A mapping function converts domain objects to response DTOs:
function toUserResponseDTO(user: User): UserResponseDTO {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
// passwordHash is intentionally excluded
}
This is an explicit act: you decide what to expose. If you add a ssn field to User for internal use, the DTO mapper will not include it unless you add it explicitly.
The service layer receives DTOs
class UserService {
async create(dto: CreateUserDTO): Promise<User> {
const passwordHash = await bcrypt.hash(dto.password, 12);
return this.userRepo.create({
name: dto.name,
email: dto.email,
passwordHash,
});
}
}
The service never receives req.body. It receives a typed, validated DTO. If the controller changes (REST to GraphQL, for example), the service does not change.
Update DTOs use Partial
For partial updates, derive the DTO from the full one:
const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true });
type UpdateUserDTO = z.infer<typeof UpdateUserSchema>;
// { name?: string; email?: string }
The caller can update name, email, or both. Password updates are a separate endpoint because they require additional verification.
Nested DTOs
For complex resources:
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
).min(1),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2), // ISO country code
}),
});
The entire input is validated in one schema. Nested validation errors are reported with paths (items.0.quantity, etc.).
Response DTOs for lists
Don’t forget pagination:
interface PaginatedResponseDTO<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
function toPaginatedResponse<T, U>(
items: T[],
total: number,
page: number,
pageSize: number,
mapper: (item: T) => U
): PaginatedResponseDTO<U> {
return {
items: items.map(mapper),
total,
page,
pageSize,
};
}
The pattern in one sentence
DTOs create an explicit, validated, typed boundary between the outside world and your application. Everything that crosses the boundary is checked. Nothing leaks through without intention.