DTOs Explained: Why Data Transfer Objects Matter in API Design
If you have ever passed a raw database entity directly to an API response, you have probably leaked internal fields, sent more data than needed, or broken a client when the schema changed. Data Transfer Objects (DTOs) solve this by creating explicit shapes for data crossing boundaries. This guide covers what DTOs are, why they matter, and how to structure them.
What is a DTO?
A Data Transfer Object is a plain object that defines the shape of data sent between layers of your application. It has no business logic -- it is purely a container for data with a defined structure.
The term comes from Martin Fowler's enterprise patterns, but the concept is simple: instead of passing your database model directly to the API response, you define a separate object that describes exactly what the client receives.
// Database entity (internal)
interface User {
id: number;
email: string;
passwordHash: string;
role: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
internalNotes: string;
}
// Response DTO (external)
interface UserResponseDto {
id: number;
email: string;
role: string;
createdAt: string;
}
Notice what the DTO excludes: passwordHash, deletedAt, internalNotes, and updatedAt. These are internal details that clients should never see.
Why DTOs matter
Security
Without DTOs, it is dangerously easy to leak sensitive data. A single return user in an Express handler could expose password hashes, internal IDs, or admin flags. DTOs make the exposed surface explicit.
Stability
Your database schema evolves. Adding an internal column should not break API clients. DTOs create a stable contract: the database can change freely as long as you maintain the DTO mapping.
Validation
Input DTOs define exactly what the API accepts. Anything outside the DTO shape is rejected. This prevents mass assignment vulnerabilities where a client sends {"role": "admin"} and your ORM happily applies it.
Documentation
DTOs serve as self-documenting API contracts. A CreateUserDto tells every developer exactly what fields are required to create a user, with their types and constraints.
The three DTO variants
Most resources need three DTO variants: one for creation, one for updates, and one for responses.
CreateDto
Defines the required fields for creating a new resource. Excludes auto-generated fields like id, createdAt, and computed values.
interface CreateUserDto {
email: string;
password: string; // Plain text, will be hashed by the service
fullName: string;
role?: string; // Optional, defaults to 'user'
}
UpdateDto
All fields are optional because partial updates are the norm. The client sends only the fields they want to change.
interface UpdateUserDto {
email?: string;
fullName?: string;
role?: string;
}
In TypeScript, you can derive this from CreateDto:
type UpdateUserDto = Partial<Omit<CreateUserDto, 'password'>>;
ResponseDto
Defines what the API returns. Excludes sensitive fields, includes computed fields, and formats data for clients (e.g., dates as ISO strings).
interface UserResponseDto {
id: number;
email: string;
fullName: string;
role: string;
createdAt: string; // ISO 8601 string, not Date object
orderCount: number; // Computed field
}
Adding validation
DTOs become even more powerful with runtime validation. In TypeScript, Zod is a popular choice that handles both type inference and validation:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
fullName: z.string().min(1, 'Name is required').max(100),
role: z.enum(['user', 'admin', 'editor']).default('user'),
});
type CreateUserDto = z.infer<typeof CreateUserSchema>;
// Usage in an Express handler
app.post('/users', (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
const dto: CreateUserDto = result.data;
// dto is fully typed and validated
});
The schema and the TypeScript type are derived from the same source, so they can never drift apart.
NestJS class-validator approach
In NestJS, DTOs are classes decorated with validation rules:
import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
fullName: string;
@IsOptional()
@IsEnum(['user', 'admin', 'editor'])
role?: string;
}
NestJS automatically validates incoming requests against these decorators and returns structured error responses for invalid input.
Mapping between layers
The translation between entities and DTOs should live in a dedicated mapper function or class:
function toUserResponse(user: User): UserResponseDto {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
createdAt: user.createdAt.toISOString(),
orderCount: user.orders?.length ?? 0,
};
}
function fromCreateDto(dto: CreateUserDto): Partial<User> {
return {
email: dto.email,
passwordHash: hashPassword(dto.password),
fullName: dto.fullName,
role: dto.role ?? 'user',
};
}
Keeping mappers as pure functions makes them easy to test and reuse.
Common mistakes
Reusing one DTO for everything. A single UserDto used for creation, updates, and responses forces awkward optionality and exposes too many fields. Create separate variants.
Putting business logic in DTOs. DTOs are data containers. Validation (field format, required fields) belongs in DTOs. Business rules (can this user be promoted) belong in services.
Skipping DTOs for simple APIs. Even a small API benefits from explicit input/output shapes. It takes minutes to define and saves hours of debugging leaked fields.
Not versioning response DTOs. When you need to change the response shape, create a v2 DTO rather than modifying the existing one. This gives clients time to migrate.
DTO patterns in different frameworks
| Framework | DTO Style | Validation |
|---|---|---|
| Express + Zod | Zod schemas with inferred types | safeParse() in middleware |
| NestJS | Classes with decorators | class-validator + ValidationPipe |
| FastAPI (Python) | Pydantic models | Built-in |
| Spring Boot (Java) | Record classes with annotations | jakarta.validation |
| Go | Structs with json tags | go-playground/validator |
Summary
DTOs are a simple pattern with outsized impact. They protect your API from leaking internal data, validate input at the boundary, and create a stable contract between your backend and clients. The small upfront cost of defining separate create, update, and response DTOs pays for itself in security, clarity, and maintainability.
Try our DTO Generator to generate typed DTOs from JSON instantly -- right in your browser, no upload required.