📚 Módulo 03: DTOs, Mappers e Tratamento Global de Erros

✅ Pré-Requisitos deste Módulo

Confirme antes de começar:

./mvnw clean compile   # deve retornar BUILD SUCCESS

Você deve ter concluído o Módulo 02 (repositórios criados, DataSeeder funcionando e application.properties configurado).

Em Engenharia de Software, as Boas Práticas de Design de API exigem que as Entidades JPA (que representam a estrutura física do banco) nunca sejam expostas diretamente para o cliente externo da API. Neste módulo, aprenderemos a criar uma camada robusta de desacoplamento utilizando o padrão DTO (Data Transfer Object), conversores manuais (Mappers) e um manipulador globalizado para capturar e formatar exceções automaticamente.


🛡️ 1. O Padrão DTO (Data Transfer Object)

🧠 Por que utilizar DTOs?

  1. Segurança (Mass Assignment Protection): Evita que o usuário envie campos maliciosos na requisição (por exemplo, injetar um ID inexistente ou forçar privilégios).
  2. Evitar Loops de Serialização: Como os relacionamentos no JPA são bidirecionais (ex: Pedido tem ItemPedido que por sua vez referencia Pedido), tentar retornar a entidade bruta em JSON causará um estouro de memória (StackOverflowError) por recursão infinita.
  3. Desacoplamento de Contrato: Permite que o banco de dados seja alterado (renomear colunas) sem que o contrato externo da API mude e quebre o aplicativo frontend Angular.

Criaremos nossos DTOs no pacote br.com.tecloja.api.dto.

1. DTOs de Produto

package br.com.tecloja.api.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

// DTO para cadastrar/atualizar produtos (Dados de Entrada)
public record ProdutoFormDTO(
    @NotBlank(message = "O nome do produto é obrigatório")
    String nome,

    @Size(max = 500, message = "A descrição não pode exceder 500 caracteres")
    String descricao,

    @NotNull(message = "O preço é obrigatório")
    @DecimalMin(value = "0.01", message = "O preço deve ser maior que zero")
    BigDecimal preco,

    @Min(value = 0, message = "O estoque não pode ser negativo")
    int estoque,

    @NotNull(message = "A categoria é obrigatória")
    Long categoriaId
) {}
package br.com.tecloja.api.dto;

import java.math.BigDecimal;

// DTO para retornar produtos (Dados de Saída)
public record ProdutoDTO(
    Long id,
    String nome,
    String descricao,
    BigDecimal preco,
    int estoque,
    boolean ativo,
    Long categoriaId,
    String categoriaNome
) {}

2. DTOs de Venda / Pedido

package br.com.tecloja.api.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

// Item do carrinho recebido no formulário de checkout
public record ItemPedidoFormDTO(
    @NotNull(message = "O ID do produto é obrigatório")
    Long produtoId,

    @Min(value = 1, message = "A quantidade mínima por produto é 1")
    int quantidade
) {}
package br.com.tecloja.api.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

// Formulário de finalização de compra (Checkout)
public record PedidoFormDTO(
    @NotNull(message = "O ID do cliente é obrigatório")
    Long clienteId,

    @NotEmpty(message = "O pedido precisa conter pelo menos um item")
    @Valid
    List<ItemPedidoFormDTO> itens
) {}
package br.com.tecloja.api.dto;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

// Retorno completo do pedido faturado
public record PedidoDTO(
    Long id,
    LocalDateTime dataPedido,
    String status,
    Long clienteId,
    String clienteNome,
    List<ItemPedidoDTO> itens,
    BigDecimal valorTotal
) {}
package br.com.tecloja.api.dto;

import java.math.BigDecimal;

public record ItemPedidoDTO(
    Long id,
    Long produtoId,
    String produtoNome,
    int quantidade,
    BigDecimal precoUnitario,
    BigDecimal subtotal
) {}

3. DTOs de Categoria

Utilizado pelo CategoriaController para expor a lista de categorias sem expor a entidade JPA diretamente.

package br.com.tecloja.api.dto;

// DTO de retorno para listagem de categorias (Dados de Saída)
public record CategoriaDTO(
    Long id,
    String nome
) {}

[!NOTE] Por que criamos um DTO separado para Categoria se ela tem apenas dois campos?

Mesmo para entidades simples, nunca exponha a entidade JPA bruta. Se no futuro adicionarmos uma lista de produtos dentro de Categoria, serializar a entidade diretamente causaria um loop infinito (StackOverflowError). O CategoriaDTO garante que o contrato externo da API nunca mude por conta de mudanças internas na entidade. Esse arquivo é obrigatório: o CategoriaController do Módulo 05 o usa diretamente.


🔄 2. Conversores Manuais (Mappers)

Embora existam bibliotecas automáticas (como MapStruct), a Engenharia de Software preconiza que, para fins didáticos, Mappers Manuais são superiores: eles são 100% seguros quanto ao tipo (Type-Safe), fáceis de depurar (debug), não dependem de processamento na compilação e tornam o fluxo explícito para o aluno.

flowchart LR
    A[Spring Data JPA] -->|Model / Entity| B(Produto)
    B -->|ProdutoMapper.toDTO| C(ProdutoDTO)
    C -->|Controller REST| D{JSON de Retorno}
    
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#bfb,stroke:#333,stroke-width:2px

Crie no pacote br.com.tecloja.api.mapper:

ProdutoMapper.java

package br.com.tecloja.api.mapper;

import br.com.tecloja.api.dto.ProdutoDTO;
import br.com.tecloja.api.dto.ProdutoFormDTO;
import br.com.tecloja.api.model.Categoria;
import br.com.tecloja.api.model.Produto;

public class ProdutoMapper {

    public static ProdutoDTO toDTO(Produto produto) {
        if (produto == null) return null;
        return new ProdutoDTO(
            produto.getId(),
            produto.getNome(),
            produto.getDescricao(),
            produto.getPreco(),
            produto.getEstoque(),
            produto.isAtivo(),
            produto.getCategoria().getId(),
            produto.getCategoria().getNome()
        );
    }

    public static Produto toEntity(ProdutoFormDTO form, Categoria categoria) {
        if (form == null) return null;
        Produto produto = new Produto();
        produto.setNome(form.nome());
        produto.setDescricao(form.descricao());
        produto.setPreco(form.preco());
        produto.setEstoque(form.estoque());
        produto.setCategoria(categoria);
        return produto;
    }
}

PedidoMapper.java

package br.com.tecloja.api.mapper;

import br.com.tecloja.api.dto.ItemPedidoDTO;
import br.com.tecloja.api.dto.PedidoDTO;
import br.com.tecloja.api.model.Pedido;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

public class PedidoMapper {

    public static PedidoDTO toDTO(Pedido pedido) {
        if (pedido == null) return null;

        List<ItemPedidoDTO> itensDTO = pedido.getItens().stream()
            .map(item -> new ItemPedidoDTO(
                item.getId(),
                item.getProduto().getId(),
                item.getProduto().getNome(),
                item.getQuantidade(),
                item.getPrecoUnitario(),
                item.getPrecoUnitario().multiply(BigDecimal.valueOf(item.getQuantidade()))
            ))
            .collect(Collectors.toList());

        BigDecimal valorTotal = itensDTO.stream()
            .map(ItemPedidoDTO::subtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        return new PedidoDTO(
            pedido.getId(),
            pedido.getDataPedido(),
            pedido.getStatus(),
            pedido.getCliente().getId(),
            pedido.getCliente().getNome(),
            itensDTO,
            valorTotal
        );
    }
}

🚨 3. Tratamento Global de Exceções

Uma API profissional nunca expõe um “stack trace” de erro Java bruto ao cliente. Erros de sistema devem ser formatados em JSON padronizados, com códigos de status HTTP apropriados.

Crie no pacote br.com.tecloja.api.exception:

1. Exceções Customizadas

package br.com.tecloja.api.exception;

// Erros 404 (Recurso Não Encontrado)
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package br.com.tecloja.api.exception;

// Erros 400 por violações de lógica de negócios (como estoque insuficiente)
public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

2. O Padrão de Erros RFC 7807 (Problem Details)

[!NOTE] Padrão de Mercado — Por que usamos RFC 7807 (Problem Details)?

Em sistemas corporativos modernos, a padronização das respostas de erro é fundamental para garantir a integridade e facilidade de leitura entre o Backend e múltiplos clientes (Web, Mobile e integrações de terceiros).

A RFC 7807 é um padrão internacional da IETF que especifica um formato JSON uniforme para reportar erros em APIs HTTP. A partir do Spring Boot 3 / Spring 6, a classe ProblemDetail foi introduzida como recurso nativo do framework, eliminando a necessidade de criarmos DTOs proprietários para erros, reduzindo o boilerplate e seguindo o padrão máximo do mercado de APIs RESTful.


3. O Handler de Exceções Global (GlobalExceptionHandler.java)

Anotado com @ControllerAdvice, esta classe interceptará todas as falhas no ecossistema da API e as formatará de maneira padronizada segundo a RFC 7807 utilizando a classe ProblemDetail nativa do Spring.

package br.com.tecloja.api.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    // Captura Erros 404 (Recurso Não Encontrado)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problemDetail.setTitle("Recurso Não Encontrado");
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        problemDetail.setProperty("path", request.getRequestURI());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
    }

    // Captura Violações de Regra de Negócio (400 Bad Request)
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handleBusiness(BusinessException ex, HttpServletRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
        problemDetail.setTitle("Violação de Regra de Negócio");
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        problemDetail.setProperty("path", request.getRequestURI());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
    }

    // Captura falha de autenticação com credenciais inválidas (401 Unauthorized)
    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ProblemDetail> handleBadCredentials(BadCredentialsException ex, HttpServletRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Usuário ou senha inválidos.");
        problemDetail.setTitle("Falha de Autenticação");
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        problemDetail.setProperty("path", request.getRequestURI());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(problemDetail);
    }

    // Captura Erros de Validação do Bean Validation (ex: campos nulos ou em branco)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
        Map<String, String> validationErrors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            validationErrors.put(fieldName, errorMessage);
        });

        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Alguns campos inseridos são inválidos.");
        problemDetail.setTitle("Falha de Validação");
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        problemDetail.setProperty("path", request.getRequestURI());
        problemDetail.setProperty("validationErrors", validationErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
    }

    // Captura qualquer outro erro genérico interno (500)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleGeneric(Exception ex, HttpServletRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "Ocorreu um erro interno no servidor: " + ex.getMessage()
        );
        problemDetail.setTitle("Erro Interno do Servidor");
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        problemDetail.setProperty("path", request.getRequestURI());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail);
    }
}

🤔 Por que record em vez de class para os DTOs?

Os records do Java 17 são imutáveis por design: todos os campos são final e gerados automaticamente com construtor canônico, equals, hashCode e toString. Para DTOs de transferência de dados — que só carregam informação sem comportamento — isso é perfeito: menos código, sem risco de mutação acidental e leitura mais clara. Use class quando precisar de herança ou mutabilidade.

🔍 Checkpoint

Após criar todos os DTOs e Mappers, compile novamente:

./mvnw clean compile

Resultado esperado: BUILD SUCCESS. Se ProdutoMapper.toDTO gerar erro, verifique se produto.getCategoria() pode retornar null — o mapper acessa produto.getCategoria().getId() diretamente, o que lança NullPointerException se a relação LAZY não estiver carregada. Isso é resolvido no Módulo 04 com @Transactional.

⚠️ Erros Comuns

Sintoma Causa Solução
NullPointerException em ProdutoMapper.toDTO Categoria LAZY não carregada O Mapper só funciona corretamente dentro de uma transação JPA ativa (será garantido pelo @Transactional no serviço)
@NotBlank não reconhecida Dependência de validação ausente Confirme spring-boot-starter-validation no pom.xml
records causando cannot find symbol Java < 17 no compilador Confirme <java.version>17</java.version> no pom.xml e que o JAVA_HOME aponta para JDK 17
Handler não captura exceção Classe sem @ControllerAdvice Confirme a anotação no GlobalExceptionHandler

🏁 Conclusão e Próximos Passos

Agora, nossa API possui uma casca externa extremamente profissional e segura. As informações enviadas pelo usuário serão rigorosamente validadas (@Valid), as entidades estarão protegidas por DTOs e qualquer erro de lógica ou banco gerará um JSON limpo, facilitando muito o tratamento visual desses dados no Angular.

No Módulo 04, programaremos a Camada de Serviço (Regras de Negócio). Implementaremos a lógica de faturamento de pedidos, contendo um controle transacional robusto para reduzir o estoque físico dos eletrônicos e disparar alertas caso o estoque acabe.


Voltar para o Sumário