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á | Gatilho | Exceção | Status padrão |
|---|---|---|---|
Objeto @RequestBody com @Valid | Resolução de argumentos | MethodArgumentNotValidException | 400 |
@RequestParam, @PathVariable, @RequestHeader (Spring 6.1+) | Validação de método nativa | HandlerMethodValidationException | 400 |
Qualquer método de uma classe anotada com @Validated (services, controllers antigos) | Proxy AOP | ConstraintViolationException | 500 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;
@Validno@RequestBodyas dispara, e@Validnos campos aninhados propaga a validação em cascata. - Desde o Spring Framework 6.1, as restrições em
@RequestParame@PathVariablesão aplicadas sem@Validatede geramHandlerMethodValidationException. - 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=truejunto com um@RestControllerAdviceque estendeResponseEntityExceptionHandlerdá a todas as falhas o mesmo formato RFC 9457.- Adicione os
errorspor campo como membro de extensão, mantenha odetaillegí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.