Voltar para Artigos
Arquitetura8 min de leitura

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.

12 de agosto de 2026

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.body diretamente 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.

CreateUserInputDTO.ts
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.

UsersController.ts
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'.

CreateUserDTO.ts
// 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';
}
IUsersRepository.ts
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.

UserResponseDTO.ts
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.