No Spring, DTO não é Entity

Esse erro parece economia de código no começo.

A entidade já existe, o controller já precisa receber e devolver dados, e usar a mesma classe em todo lugar dá a sensação de que o fluxo ficou mais direto.

O problema é que a classe que representa persistência e a classe que representa contrato não carregam a mesma responsabilidade.

Quando DTO e Entity viram a mesma coisa, o sistema até funciona, mas a fronteira da aplicação começa a desaparecer.


Quando a mesma classe vira tudo

Esse é o desenho que parece prático:

@Entity
public class Pedido {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String cliente;
    private BigDecimal total;
    private String status;
    private LocalDateTime criadoEm;
}

@RestController
@RequestMapping("/pedidos")
public class PedidoController {

    private final PedidoRepository pedidoRepository;

    public PedidoController(PedidoRepository pedidoRepository) {
        this.pedidoRepository = pedidoRepository;
    }

    @PostMapping
    public Pedido criar(@RequestBody Pedido pedido) {
        return pedidoRepository.save(pedido);
    }
}

Tudo parece simples.

Mas agora a mesma classe está tentando ser tabela, objeto gerenciado pelo JPA, payload de entrada e payload de saída ao mesmo tempo.

É nesse ponto que o atalho começa a cobrar juros.


O que a Entity carrega que o DTO não deveria carregar

Uma Entity existe para o modelo de persistência.

Ela carrega identidade, relacionamento, regra de mapeamento, detalhes de ORM, ciclo de vida e, em muitos casos, campos que não deveriam sair pela API nem entrar diretamente pela requisição.

Um DTO existe para a fronteira da aplicação.

Ele representa o que a API aceita ou devolve naquele caso de uso.

Misturar os dois cria alguns problemas bem comuns:

  • o contrato HTTP passa a depender da estrutura do banco e do JPA;
  • campos internos, IDs, status e atributos de auditoria podem vazar sem necessidade;
  • mudanças de persistência passam a quebrar payloads externos;
  • relacionamentos e carregamento lazy começam a aparecer onde deveriam existir só dados de resposta;
  • a entrada HTTP fica permissiva demais, aceitando mais do que deveria.

O ponto central é simples: a Entity responde à persistência. O DTO responde à comunicação.


Quando as anotações começam a brigar entre si

Tem outro sintoma bem comum em projeto real: a mesma classe começa a acumular anotações demais porque está tentando atender preocupações diferentes ao mesmo tempo.

De um lado, entram anotações de validação como @NotBlank, @NotNull e @Size.

Do outro, aparecem anotações do JPA como @Column, @ManyToOne, @JoinColumn e regras de carregamento.

E logo depois entram anotações do Jackson como @JsonIgnore, @JsonProperty, @JsonFormat ou algum remendo para evitar loop de serialização.

@Entity
public class Pedido {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Size(max = 120)
    @Column(nullable = false, length = 120)
    @JsonProperty("cliente_nome")
    private String cliente;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "usuario_id")
    @JsonIgnore
    private Usuario usuario;
}

Nesse ponto, a classe deixa de ter um papel claro.

Ela está tentando dizer ao mesmo tempo como valida entrada HTTP, como persiste no banco e como serializa JSON.

Quando isso acontece, uma mudança pensada para uma dessas camadas começa a empurrar efeito colateral para as outras.

Você ajusta o JSON e mexe, sem querer, no modelo persistido. Endurece uma regra de validação de entrada e impacta um fluxo interno de carga. Resolve um detalhe de serialização e passa a esconder dado relevante do domínio.

Esse tipo de atrito é um ótimo sinal de que a classe já está segurando responsabilidades demais.


Separar os papéis deixa o desenho honesto

Quando cada classe assume sua responsabilidade, o código fica mais explícito:

@Entity
public class Pedido {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String cliente;
    private BigDecimal total;
    private String status;
    private LocalDateTime criadoEm;

    protected Pedido() {
    }

    public Pedido(String cliente, BigDecimal total) {
        this.cliente = cliente;
        this.total = total;
        this.status = "ABERTO";
        this.criadoEm = LocalDateTime.now();
    }

    public Long getId() {
        return id;
    }

    public String getCliente() {
        return cliente;
    }

    public BigDecimal getTotal() {
        return total;
    }

    public String getStatus() {
        return status;
    }
}

public record CriarPedidoRequest(String cliente, BigDecimal total) {
}

public record PedidoResponse(Long id, String cliente, BigDecimal total, String status) {

    public static PedidoResponse from(Pedido pedido) {
        return new PedidoResponse(
            pedido.getId(),
            pedido.getCliente(),
            pedido.getTotal(),
            pedido.getStatus()
        );
    }
}

@RestController
@RequestMapping("/pedidos")
public class PedidoController {

    private final PedidoRepository pedidoRepository;

    public PedidoController(PedidoRepository pedidoRepository) {
        this.pedidoRepository = pedidoRepository;
    }

    @PostMapping
    public PedidoResponse criar(@RequestBody CriarPedidoRequest request) {
        Pedido pedido = new Pedido(request.cliente(), request.total());
        Pedido salvo = pedidoRepository.save(pedido);
        return PedidoResponse.from(salvo);
    }
}

Agora a API recebe o que faz sentido receber.

A resposta devolve o que faz sentido expor.

E a entidade continua livre para representar o modelo persistido sem virar contrato público por acidente.


O erro não é técnico. É de fronteira

Muita gente trata esse tema como preciosismo de arquitetura.

Não é.

Quando uma mudança no banco exige cuidado imediato na API, ou quando a API aceita campos que nunca deveria aceitar, o sistema está mostrando que as fronteiras ficaram confusas.

Isso conversa com o post sobre @Service: o Spring gerencia classes, injeta dependências e deixa o fluxo de pé, mas não decide sozinho onde cada responsabilidade deveria morar.

Separar DTO de Entity não é burocracia.

É impedir que persistência vire contrato e que contrato comece a mandar no desenho do domínio.


O ponto que vale fixar

DTO não existe para salvar no banco.

Entity não existe para virar payload da API.

Quando a mesma classe tenta cumprir os dois papéis, a aplicação perde clareza exatamente no lugar onde mais precisa de fronteira.

Se o seu contrato externo muda porque seu mapeamento interno mudou, você não simplificou o sistema.

Você só misturou responsabilidades cedo demais.