Em quase todo projeto Spring Boot aparece a mesma dúvida: “posso devolver a minha entidade direto no controller?”. Pode — mas cedo ou tarde isso cobra o seu preço. Para entender por quê, precisamos separar três conceitos que costumam se misturar: DTO, modelo de domínio e entidade.
Entidade: o espelho da tabela
A entidade representa como o dado é guardado. Com JPA, ela é anotada com @Entity e cada campo corresponde a uma coluna.
@Entity
@Table(name = "clientes")
public class ClienteEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String nome;
private String email;
private String senhaHash;
private LocalDateTime criadoEm;
// getters e setters
}Ela conhece detalhes de infraestrutura: nomes de tabela, geração de ID, relacionamentos lazy. Isso é ótimo para o banco e péssimo para o resto da aplicação.
Domínio: as regras do negócio
O modelo de domínio representa o conceito do negócio e as regras que valem para ele, sem saber nada de banco ou de HTTP.
public class Cliente {
private final Long id;
private String nome;
private Email email;
public void alterarEmail(Email novo) {
if (novo.equals(email)) {
throw new RegraDeNegocioException("O e-mail é o mesmo");
}
this.email = novo;
}
}Em projetos pequenos é comum a entidade fazer também o papel de domínio — e tudo bem. Separar os dois compensa quando as regras crescem ou quando o modelo do banco e o modelo do negócio começam a divergir.
DTO: o contrato com quem está de fora
O Data Transfer Object define o formato dos dados que entram e saem da sua API. Em Java moderno, um record é perfeito para isso:
public record CriarClienteRequest(
@NotBlank String nome,
@Email String email,
@Size(min = 8) String senha) {}
public record ClienteResponse(Long id, String nome, String email) {}Repare que o ClienteResponse não tem senhaHash. Se o controller devolvesse a entidade, esse campo iria parar no JSON. Esse é o primeiro grande motivo para usar DTOs.
Por que separar?
- Segurança: você escolhe exatamente o que expõe e o que aceita (nada de alguém enviar
"admin": truenum cadastro). - Estabilidade da API: mudar uma coluna no banco não quebra os clientes da API.
- Sem surpresas do JPA: serializar entidades com relacionamentos lazy gera
LazyInitializationExceptionou loops infinitos no JSON. - Validação no lugar certo: as anotações de entrada ficam no DTO de request, não espalhadas pela entidade.
O fluxo completo
Request (DTO) ─▶ Controller ─▶ Service (domínio) ─▶ Repository (entidade) ─▶ Banco
Response (DTO) ◀─ Controller ◀─ Service ◀─ Repository ◀─────────────────── BancoA conversão pode ser feita à mão, num mapper simples:
@Component
public class ClienteMapper {
public ClienteResponse toResponse(ClienteEntity e) {
return new ClienteResponse(e.getId(), e.getNome(), e.getEmail());
}
}
@PostMapping
public ResponseEntity<ClienteResponse> criar(@RequestBody @Valid CriarClienteRequest req) {
ClienteEntity salvo = service.criar(req);
return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toResponse(salvo));
}Para projetos maiores, bibliotecas como o MapStruct geram esses mapeamentos em tempo de compilação.