Spring Boot e Spring Cloud

Validação e tratamento de erros no Spring Boot 3: Bean Validation e Problem Details

Intermediário7 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Você vai aprender a rejeitar entradas inválidas antes que cheguem à lógica de negócio e a devolver erros com os quais o cliente consegue agir: aplicar restrições do Jakarta Bean Validation a records e parâmetros de método, escrever restrições personalizadas, centralizar o tratamento de exceções em um @RestControllerAdvice e emitir respostas application/problem+json padronizadas sem vazar detalhes internos.

O que você vai aprender

  • Aplicar restrições do Jakarta Bean Validation aos DTOs de requisição e aos parâmetros do controller, sabendo qual exceção cada uma gera
  • Escrever uma restrição personalizada com sua classe validadora e usar grupos de validação
  • Habilitar o suporte nativo a ProblemDetail do Spring e entender o que ele cobre
  • Centralizar erros de domínio e de validação em um @RestControllerAdvice que gere Problem Details RFC 9457
  • Evitar vazar stack traces, a estrutura das entidades e mensagens do framework nas respostas de erro

Uma API é julgada tanto pela forma como falha quanto pela forma como funciona. O cliente precisa saber se enviou algo errado, se o recurso existe, se tentar de novo vai adiantar e qual campo corrigir. O Spring Boot 3 oferece as peças para responder a essas perguntas de forma consistente: Jakarta Bean Validation para declarar como é uma entrada válida, exception handlers para transformar falhas em respostas e o tipo ProblemDetail do Spring Framework 6 para um corpo de erro padronizado. Esta página junta tudo isso em uma única abordagem, que você pode aplicar a todos os endpoints.

Declarando restrições

Adicione spring-boot-starter-validation, que traz o Hibernate Validator, a implementação de referência do Jakarta Bean Validation 3. A API fica em jakarta.validation; se você encontrar imports de javax.validation, está lendo material anterior ao Boot 3.

As restrições são anotações em campos, componentes de record, parâmetros e valores de retorno:

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;

public record CreateCustomerRequest(
        @NotBlank @Size(max = 100) String name,
        @NotBlank @Email String email,
        @Pattern(regexp = "^\\+?[0-9 ]{6,20}$", message = "must be a phone number with digits and spaces")
        String phone,
        @NotNull @Past LocalDate dateOfBirth,
        @Valid @NotEmpty List<AddressRequest> addresses) {   // @Valid cascades into each element

    public record AddressRequest(
            @NotBlank String street,
            @NotBlank String city,
            @NotBlank @Size(min = 2, max = 2) String countryCode) {}
}

As restrições nativas mais usadas são @NotNull, @NotBlank (não nulo e com pelo menos um caractere que não seja espaço em branco), @NotEmpty (não nulo e não vazio, para coleções e strings), @Size, @Min/@Max, @Positive/@PositiveOrZero, @DecimalMin/@DecimalMax, @Digits, @Past/@Future (e suas variantes OrPresent), @Email e @Pattern. O Hibernate Validator acrescenta algumas, como @URL e @Length, ao custo de amarrar você a essa implementação.

Duas coisas costumam pegar as pessoas de surpresa. Primeiro, as restrições não se propagam em cascata a menos que você peça: de uma List<AddressRequest> só se verifica o @NotEmpty até você adicionar @Valid. Segundo, @NotNull em um int primitivo não faz sentido; primitivos nunca são nulos, então um campo JSON ausente vira zero silenciosamente. Use o tipo wrapper Integer com @NotNull quando a ausência precisar ser um erro.

Nas expressões regulares do @Pattern, lembre-se de que o valor inteiro precisa casar com o padrão; você pode testar os padrões com entradas de exemplo no testador de regex antes de fazer o commit.

Disparando a validação nos controllers

O Spring MVC executa a validação em três pontos, e cada um lança uma exceção diferente:

Onde a restrição estáGatilhoExceçãoStatus padrão
Objeto @RequestBody com @ValidResolução de argumentosMethodArgumentNotValidException400
@RequestParam, @PathVariable, @RequestHeader (Spring 6.1+)Validação de método nativaHandlerMethodValidationException400
Qualquer método de uma classe anotada com @Validated (services, controllers antigos)Proxy AOPConstraintViolationException500 se não for tratada
@RestController
@RequestMapping("/api/customers")
class CustomerController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    CustomerResponse create(@Valid @RequestBody CreateCustomerRequest request) {
        return CustomerResponse.from(customers.register(request));
    }

    @GetMapping
    Page<CustomerResponse> search(@RequestParam @Size(min = 2) String q,
                                  @RequestParam(defaultValue = "20") @Max(100) int size) {
        return customers.search(q, size).map(CustomerResponse::from);
    }
}

Vale a pena entender a terceira linha. Antes do Spring Framework 6.1, as restrições em parâmetros simples eram ignoradas, a menos que o controller tivesse @Validated, que o envolvia em um proxy; a ConstraintViolationException resultante não era tratada pelo Spring MVC e virava um 500. Desde a 6.1, o próprio framework valida esses parâmetros quando há um provedor disponível, e a HandlerMethodValidationException é tratada como 400. Se você ainda anota controllers com @Validated, remova a anotação para que os dois mecanismos não rodem ao mesmo tempo.

@Validated continua sendo a ferramenta certa para services: anote a classe, coloque as restrições nos parâmetros dos métodos, e quem passar dados inválidos recebe uma ConstraintViolationException, venha a chamada de HTTP, de um scheduler ou de um listener de mensagens.

Restrições personalizadas e grupos

Quando uma regra é reutilizável e diz respeito apenas ao valor (um IBAN, um código de produto interno, uma senha forte), escreva uma restrição em vez de repetir verificações nos services:

@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface Sku {
    String message() default "must be a SKU such as ABC-12345";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class SkuValidator implements ConstraintValidator<Sku, String> {
    private static final Pattern FORMAT = Pattern.compile("[A-Z]{3}-\\d{5}");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || FORMAT.matcher(value).matches();   // null is @NotNull's job
    }
}

Retorne true para null, para que a obrigatoriedade continue sendo uma decisão separada, tomada com @NotNull. Os validadores são beans do Spring quando o LocalValidatorFactoryBean do Boot está em uso, então podem injetar um repository para verificar, por exemplo, se um e-mail já está cadastrado. Mantenha essas verificações baratas, porque elas rodam em toda validação do objeto, e, de qualquer forma, trate a condição de corrida no banco com uma constraint de unicidade.

Os grupos permitem que um mesmo record tenha regras diferentes para operações diferentes. Declare interfaces marcadoras (OnCreate, OnUpdate), associe-as com @NotNull(groups = OnUpdate.class) e valide com @Validated(OnUpdate.class) no parâmetro do controller. Grupos são poderosos e fáceis de usar em excesso; records de requisição separados por operação costumam ser mais claros.

Restrições em nível de classe (anotação no record, validador que recebe o objeto inteiro) expressam regras que envolvem vários campos, como "a data de término é posterior à de início". Regras genuinamente de negócio, as que precisam de outros agregados ou de uma transação, ficam no service, que lança uma exceção de domínio.

Corpos de erro padronizados com ProblemDetail

A RFC 9457 (que torna obsoleta a RFC 7807) define um formato JSON para erros que a maioria das ferramentas de API já entende:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 400,
  "detail": "2 fields are invalid",
  "instance": "/api/customers",
  "errors": [
    { "field": "email", "message": "must be a well-formed email address" },
    { "field": "addresses[0].countryCode", "message": "size must be between 2 and 2" }
  ]
}

O Spring Framework 6 modela isso como org.springframework.http.ProblemDetail, escrito com o media type application/problem+json. O Boot passa o tratamento de exceções do próprio framework para esse formato com uma única propriedade:

spring.mvc.problemdetails.enabled=true

Isso registra um ResponseEntityExceptionHandler que cobre as exceções lançadas pelo próprio Spring MVC: corpos ilegíveis, media types não suportados, parâmetros ausentes, MethodArgumentNotValidException, HandlerMethodValidationException, NoResourceFoundException e outras. Ele não conhece suas exceções de domínio, e a resposta de validação dele não lista os erros por campo; na prática, você o estende.

Um único @RestControllerAdvice

Centralize o mapeamento em uma única classe para que todos os controllers falhem do mesmo jeito:

@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final URI VALIDATION_TYPE = URI.create("https://api.example.com/problems/validation-error");

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> Map.of("field", fe.getField(),
                                  "message", Objects.requireNonNullElse(fe.getDefaultMessage(), "invalid")))
                .toList();
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status,
                errors.size() + " field(s) are invalid");
        problem.setType(VALIDATION_TYPE);
        problem.setTitle("Request validation failed");
        problem.setProperty("errors", errors);
        return handleExceptionInternal(ex, problem, headers, status, request);
    }

    @ExceptionHandler(CustomerNotFoundException.class)
    ProblemDetail notFound(CustomerNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
                "No customer with id " + ex.customerId());
        problem.setTitle("Customer not found");
        return problem;
    }

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    ProblemDetail conflict(ObjectOptimisticLockingFailureException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The resource was modified by another request; reload and retry");
        problem.setTitle("Concurrent modification");
        return problem;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception for {} {}", request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Unexpected error");
        problem.setDetail("The request could not be processed. Reference: " + MDC.get("traceId"));
        return problem;
    }
}

Basta retornar um ProblemDetail de um @ExceptionHandler para que o Spring use o status dele e defina o content type como application/problem+json. setProperty adiciona membros de extensão (errors, acima) que são serializados no nível raiz do JSON, como a RFC prevê. O handler genérico registra no log a exceção real com o stack trace e devolve apenas uma referência que o cliente pode informar ao suporte; o ideal é um trace id ou correlation id vindo da sua infraestrutura de tracing.

As exceções de domínio também podem estender ErrorResponseException do Spring, que carrega um ProblemDetail e dispensa qualquer handler. É prático em services pequenos; a classe advice escala melhor assim que você quer variar a resposta conforme o tipo de exceção ou adicionar logging.

A ordem importa quando mais de um handler pode se aplicar: o Spring escolhe o tipo de exceção mais próximo, então CustomerNotFoundException ganha de Exception. Se houver duas classes advice, @Order decide qual é consultada primeiro.

O fallback /error do Boot (o BasicErrorController) continua tratando tudo o que escapa do MVC, como falhas dentro de um filtro. A saída dele é controlada por server.error.include-message, include-binding-errors, include-stacktrace e include-exception, e todas devem ficar no padrão never em produção.

Mensagens e internacionalização

As mensagens das restrições são resolvidas por meio de um MessageSource. Coloque-as em ValidationMessages.properties (a convenção do Bean Validation, na raiz do classpath) ou, a partir do Boot 2.6, no messages.properties da aplicação, porque o Boot conecta o MessageSource dele ao validador:

customer.email.invalid=Enter a valid email address such as [email protected]

Depois, referencie a mensagem com @Email(message = "{customer.email.invalid}"). Os arquivos específicos de cada locale (messages_fr.properties) são escolhidos pelo cabeçalho Accept-Language da requisição. Traduzir ou não as mensagens de erro de uma API é uma decisão de produto; muitas APIs retornam códigos legíveis por máquina e deixam a localização a cargo do cliente.

Erros comuns

Esquecer o @Valid no corpo. As restrições estão declaradas, o starter está lá, e nada acontece. A validação de um @RequestBody precisa ser ativada parâmetro a parâmetro.

Validar tarde demais. Restrições nas entidades (@NotBlank em um campo JPA) rodam no flush, dentro da transação, e aparecem como uma TransactionSystemException envolvendo uma ConstraintViolationException. Valide na fronteira da API, nos records de requisição, e trate as restrições de entidade como uma segunda linha de defesa.

Copiar ex.getMessage() para a resposta. As mensagens de DataIntegrityViolationException contêm SQL e nomes de constraints; as de HttpMessageNotReadableException contêm nomes de classes e detalhes internos do Jackson. Escreva o detalhe você mesmo.

Retornar um formato de erro diferente em cada controller. Dois handlers, um produzindo { "error": ... } e outro { "message": ... }, obrigam todo cliente a tratar casos especiais. Uma classe advice, um formato.

Lançar ResponseStatusException em todo lugar. Serve para um 404 rápido, mas a mensagem dela vira o reason da resposta, os corpos não batem com seus Problem Details e ela espalha conhecimento de HTTP pelos services. Lance exceções de domínio e faça o mapeamento uma única vez.

Ativar include-stacktrace=always "só em homologação". A configuração de homologação tem o hábito de virar configuração de produção. Registre os traces no log; nunca os devolva.

Principais pontos

  • Declare o que é válido com restrições do Jakarta Bean Validation nos records de requisição; @Valid no @RequestBody as dispara, e @Valid nos campos aninhados propaga a validação em cascata.
  • Desde o Spring Framework 6.1, as restrições em @RequestParam e @PathVariable são aplicadas sem @Validated e geram HandlerMethodValidationException.
  • Restrições personalizadas cuidam das regras de formato reutilizáveis; regras de negócio que precisam de outros dados ficam nos services, como exceções de domínio.
  • spring.mvc.problemdetails.enabled=true junto com um @RestControllerAdvice que estende ResponseEntityExceptionHandler dá a todas as falhas o mesmo formato RFC 9457.
  • Adicione os errors por campo como membro de extensão, mantenha o detail legível para pessoas e nunca copie mensagens brutas de exceção nem stack traces para uma resposta.
  • Registre as exceções inesperadas no servidor com um correlation id e devolva esse id ao cliente.

Fontes e leituras complementares

Perguntas frequentes

Qual a diferença entre @Valid e @Validated?
@Valid é a anotação do Jakarta Bean Validation: em um parâmetro de controller, ela valida o objeto e propaga a validação em cascata para os objetos aninhados marcados com @Valid. @Validated é a anotação do Spring: aceita grupos de validação e, quando colocada em uma classe, habilita por meio de um proxy a validação em nível de método dos parâmetros e valores de retorno. Use @Valid nos corpos de requisição e @Validated quando precisar de grupos ou quiser validar os argumentos dos métodos de um service.
Por que as restrições em @RequestParam ou @PathVariable não fazem nada?
Antes do Spring Framework 6.1, as restrições em parâmetros simples só eram aplicadas se a classe do controller estivesse anotada com @Validated, o que criava um proxy e lançava ConstraintViolationException. Desde a 6.1, o Spring MVC as valida nativamente sempre que há um provedor de Bean Validation, e lança HandlerMethodValidationException no lugar. Confira a versão do framework e garanta que spring-boot-starter-validation esteja no classpath.
Devo retornar 400 ou 422 em falhas de validação?
Os dois são defensáveis. 400 Bad Request é o que o tratamento padrão do Spring usa para MethodArgumentNotValidException e é entendido por qualquer cliente. 422 Unprocessable Content é mais preciso quando o JSON está bem formado, mas viola regras de negócio. Escolha um, documente na API e aplique de forma consistente; não misture os dois para o mesmo tipo de falha.
Como impedir que o Spring Boot exponha mensagens de exceção e stack traces?
A resposta de erro padrão do Boot já omite a mensagem e o trace, a menos que server.error.include-message ou server.error.include-stacktrace estejam como always. Mantenha-os em never em produção e faça seu @RestControllerAdvice mapear cada exceção para um texto de detalhe escrito à mão, em vez de copiar exception.getMessage(), que nos erros de persistência e do framework costuma conter SQL, nomes de classes ou caminhos de arquivos.