Contratos Blindados: O Padrão DTO em APIs Modernas
DTOs como contratos entre camadas: Input DTOs (de entrada), Output DTOs (para o cliente) e Domain DTOs (entre use cases e repositórios). Validação com Zod no boundary e mapeamento com métodos estáticos.
Quando req.body percorre toda a aplicação de camada em camada, você perde controle total sobre o que está trafegando. O controller não sabe o que o service espera. O service não sabe o que o repositório aceita. E quando a API retorna, o usuário pode receber campos que nunca deveriam ser expostos — como passwordHash ou internalNotes.
O DTO (Data Transfer Object) resolve isso com contratos explícitos entre fronteiras: cada cruzamento de camada usa um DTO específico com os campos exatos necessários — nem mais, nem menos.
Os Três Tipos de DTO
- Input DTO — dados que chegam do exterior (req.body, req.query). Validado com Zod antes de entrar na lógica de negócio. Nunca use
req.bodydiretamente no Use Case. - Domain DTO — dados que trafegam entre Use Cases, Repositórios e Services. Interfaces TypeScript puras, sem framework, sem decorators.
- Output DTO — dados enviados para o cliente. Mapeamento explícito que garante que campos internos (hash de senha, dados de auditoria) nunca vazem na resposta.
Input DTO: Validação na Fronteira
O Input DTO com Zod é declarado uma vez e serve duas funções: validação em runtime (rejeitando payloads inválidos com mensagens de erro descritivas) e tipagem estática (permitindo que o Use Case receba os dados com segurança de tipo completa). O z.infer<typeof CreateUserInputSchema> elimina a duplicação — você não escreve uma interface TypeScript separada do schema de validação.
import { z } from 'zod';
// Schema Zod = validação + inferência de tipo
export const CreateUserInputSchema = z.object({
name: z.string().min(2, 'Nome muito curto').max(100),
email: z.string().email('E-mail inválido').toLowerCase(),
password: z
.string()
.min(8, 'Senha deve ter ao menos 8 caracteres')
.regex(/[A-Z]/, 'Deve conter ao menos uma letra maiúscula')
.regex(/[0-9]/, 'Deve conter ao menos um número'),
role: z.enum(['USER', 'MANAGER', 'ADMIN']).default('USER'),
});
// Tipo inferido do schema — sem duplicação
export type CreateUserInputDTO = z.infer<typeof CreateUserInputSchema>;O Controller é o único lugar onde o req.body é acessado e validado. Após o parse(), o resultado é um objeto fortemente tipado que o Use Case pode consumir com segurança. Se o payload for inválido, o Zod lança um erro antes mesmo de entrar no Use Case — o error handler global captura e formata a resposta 422.
import type { Request, Response } from 'express';
import { CreateUserInputSchema } from '../../modules/users/dtos/CreateUserInputDTO';
import { CreateUserUseCase } from '../../modules/users/usecases/CreateUserUseCase';
export class UsersController {
async create(req: Request, res: Response): Promise<void> {
// Validação acontece na FRONTEIRA (controller) — antes de entrar no Use Case
// Se inválido, lança ZodError → tratado pelo global error handler
const dto = CreateUserInputSchema.parse(req.body);
// Use Case recebe apenas o DTO tipado — nunca req.body
const useCase = container.resolve(CreateUserUseCase);
const result = await useCase.execute(dto);
res.status(201).json(result);
}
}Domain DTO: Contratos entre Camadas Internas
Os DTOs de domínio são interfaces TypeScript puras, sem dependências externas. Eles definem os contratos entre o Use Case e o Repositório. Note que o CreateUserDTO de domínio já contém o passwordHash — isso porque é responsabilidade do Use Case fazer o hashing da senha antes de enviar ao repositório. O repositório não deve conhecer regras de negócio como 'qual algoritmo de hash usar'.
// DTOs de domínio são interfaces puras — zero dependência de framework
// O Use Case conhece este DTO, não o CreateUserInputDTO (que tem deps do Zod)
export interface CreateUserDTO {
name: string;
email: string;
passwordHash: string; // Já hasheado pelo Use Case — repositório não faz hash
role: 'USER' | 'MANAGER' | 'ADMIN';
}
export interface FindUsersDTO {
page: number;
pageSize: number;
search?: string;
role?: 'USER' | 'MANAGER' | 'ADMIN';
sortBy?: 'name' | 'email' | 'createdAt';
sortOrder?: 'ASC' | 'DESC';
}import type { User } from '../entities/User';
import type { CreateUserDTO, FindUsersDTO } from '../dtos/domain/CreateUserDTO';
export interface PaginatedResult<T> {
data: Array<T>;
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface IUsersRepository {
// Tipagem explícita: o repositório exige EXATAMENTE CreateUserDTO
// O TypeScript falha em compile-time se faltar algum campo
create(data: CreateUserDTO): Promise<User>;
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findMany(params: FindUsersDTO): Promise<PaginatedResult<User>>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}Output DTO: Nunca Vaze Dados Internos
O Output DTO é a última fronteira de segurança antes dos dados saírem da API. A função toUserResponseDTO faz o mapeamento explícito: apenas os campos listados entram na resposta. Campos como passwordHash, refreshTokens, deletedAt e internalNotes simplesmente não existem no Output DTO. O TypeScript garante que não há como 'esquecer' de excluir um campo sensível — se não está no Output DTO, não vai na resposta.
import type { User } from '../entities/User';
// DTO de resposta: apenas campos seguros para o cliente
export interface UserResponseDTO {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
avatar: string | null;
createdAt: string; // ISO 8601 — Date serializado como string
}
// Mapeamento explícito da entidade para o DTO
// Garante que passwordHash, deletedAt, internalNotes nunca sejam expostos
export function toUserResponseDTO(user: User): UserResponseDTO {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
emailVerified: user.emailVerified,
avatar: user.avatarUrl ?? null,
createdAt: user.createdAt.toISOString(),
// passwordHash: NUNCA incluído
// deletedAt: NUNCA incluído
// refreshToken: NUNCA incluído
};
}
// Para listas:
export function toUserListResponseDTO(users: Array<User>): Array<UserResponseDTO> {
return users.map(toUserResponseDTO);
}Nunca retorne a entidade diretamente na resposta HTTP. A entidade User tem campos como passwordHash, refreshTokens e deletedAt que jamais devem chegar ao cliente. Use sempre um Output DTO com mapeamento explícito — mesmo que pareça verboso, essa explicitidade é a única proteção contra vazamento de dados.
Conclusão
DTOs transformam fronteiras de camadas de implícitas em explícitas. O Input DTO com Zod valida e normaliza na entrada. O Domain DTO garante que o Use Case receba exatamente o que precisa. O Output DTO garante que o cliente receba apenas o que deve ver. Quando uma refatoração exige um novo campo obrigatório, o TypeScript aponta todos os lugares que precisam ser atualizados — refactoring com segurança total.