Validação no Spring Boot com Bean Validation (@Valid, @NotBlank, …)

1. Visão geral

Ao construir uma REST API, você sempre precisa validar os dados de entrada antes de processar a lógica de negócio: email em formato correto, nome não pode estar vazio, idade deve ser positiva… Bean Validation (JSR 380, implementado por Hibernate Validator) ajuda você a fazer isso com annotations, em vez de escrever centenas de if-else manuais.

Este artigo passa por como configurar, usar @Valid junto com as annotations mais comuns, validar objetos aninhados, escrever validadores customizados e tratar erros de validação para retornar uma resposta amigável ao cliente. Depois de ler, você estará confiante em aplicar validação a todos os DTOs em seu projeto Spring Boot.

2. Adicionar dependência

Spring Boot não vem com Bean Validation por padrão, você precisa adicionar spring-boot-starter-validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Esta dependência traz Hibernate Validator – a implementação mais comum da Bean Validation API. Se você usa spring-boot-starter-web, muitos pensam que validation já vem incluída, mas na verdade desde Spring Boot 2.3, este starter foi separado – sem ele, @Valid não funciona e nenhum erro é reportado, dados inválidos passam direto.

3. Validar corpo da requisição com @Valid

3.1. Declarar DTO com constraint annotations

public record CreateOrderRequest(
        @NotBlank(message = "Nome do cliente não pode estar vazio")
        String customerName,

        @Email(message = "Email inválido")
        @NotBlank
        String email,

        @Positive(message = "Quantidade deve ser maior que 0")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "Preço deve ser maior que 0")
        BigDecimal unitPrice
) {}

Cada campo recebe uma ou mais constraint annotations: @NotBlank rejeita null/vazio/apenas-espaços, @Email valida formato de email, @Positive exige número positivo, @DecimalMin estabelece valor mínimo. O parâmetro message é o texto do erro retornado ao violar a constraint.

3.2. Aplicar @Valid no controller

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // request garantidamente está válido neste ponto
        return ResponseEntity.ok("Pedido criado para " + request.customerName());
    }
}

@Valid antes de @RequestBody avisa ao Spring MVC para executar validação no objeto que foi deserializado do JSON. Se algum field violar uma constraint, Spring lança MethodArgumentNotValidException antes do body do método executar – isso significa que você não precisa verificar null ou vazio na lógica de negócio.

Enviar uma requisição sem customerName:

POST /api/orders
{
  "email": "a@b.com",
  "quantity": 2,
  "unitPrice": 10.5
}

Por padrão (sem tratamento de exceção), Spring retorna HTTP 400 com um body longo e difícil de ler para o cliente. A seção 5 mostrará como tratar isso de forma mais elegante.

4. Annotations mais comuns

Annotation Significado
@NotNull Valor não pode ser null
@NotBlank String não null, não vazio, não apenas espaços
@NotEmpty Collection/String/Array não null e não vazio
@Size(min, max) Comprimento de String ou tamanho de Collection
@Min / @Max Limites de números inteiros
@Positive / @Negative Número positivo/negativo
@Email Formato de email válido
@Pattern(regexp) Deve corresponder a uma regex
@Past / @Future Data no passado/futuro

Exemplo combinando múltiplas constraints em um field:

public class Customer {

    @Size(min = 3, max = 50, message = "Nome deve ter de 3 a 50 caracteres")
    private String name;

    @Pattern(regexp = "^0\\d{9}$", message = "Telefone deve ter 10 dígitos, começando com 0")
    private String phone;

    @Past(message = "Data de nascimento deve ser no passado")
    private LocalDate birthDate;
}

Você pode aplicar várias annotations no mesmo field – por exemplo, @NotBlank combinado com @Size – para rejeitar vazio e limitar comprimento. O validador executa todas as constraints e agrega erros, não para na primeira falha.

5. Tratar erros de validação com @ExceptionHandler

Em vez de deixar Spring retornar a resposta padrão confusa, use @RestControllerAdvice para formatar melhor:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.badRequest().body(errors);
    }
}

MethodArgumentNotValidException contém BindingResult com uma lista de FieldError – cada erro tem nome do field e mensagem. O código acima agrega todos os erros em um Map<field, message> e retorna HTTP 400.

Resultado ao chamar novamente a requisição sem customerName:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "customerName": "Nome do cliente não pode estar vazio"
}

A resposta agora é concisa, o cliente consegue mapear erros para cada field no formulário.

6. Validar objetos aninhados e collections

6.1. Objeto aninhado com @Valid

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) {}

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

Se CustomerRequest.address tiver apenas @NotNull sem @Valid, as constraints dentro de AddressRequest (como @NotBlank em city) não serão verificadas – este é um erro muito comum com iniciantes em Bean Validation. @Valid aqui pede ao validador para “descer” no objeto aninhado.

6.2. Validar cada elemento em uma List

public record BulkOrderRequest(
        @NotEmpty(message = "Lista de items não pode estar vazia")
        @Valid
        List<@Valid OrderItem> items
) {}

public record OrderItem(
        @NotBlank String productCode,
        @Positive Integer quantity
) {}

@Valid colocado antes de List<...> (ou no type argument) avisa ao validador para verificar cada elemento da lista, não apenas se a list é null ou vazia. Se o item 2 da lista tiver quantity = -1, o erro retornado indicará items[1].quantity.

7. Escrever validador customizado

Quando constraints já existentes não são suficientes, crie uma annotation própria. Por exemplo, validar que código de pedido começa com ORD-:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "Código do pedido deve ser ORD-xxxxx";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // deixe @NotNull lidar com isso, evite erro duplicado
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...) vincula a annotation com a classe que implementa a lógica. ConstraintValidator<A, T> recebe A como a annotation e T como o tipo do field. O método isValid retorna true se válido. Nota: deixe isValid retornar true quando value == null, porque rejeitar null deve ser responsabilidade de @NotNull – assim um field não reporta dois erros diferentes.

Usar no DTO:

public record OrderLookupRequest(
        @NotBlank
        @ValidOrderCode
        String orderCode
) {}

8. Testar validação com JUnit 5 + AssertJ

Você pode testar Validator diretamente sem iniciar o contexto Spring MVC, mais rápido que testes de integração:

class CreateOrderRequestValidationTest {

    private static Validator validator;

    @BeforeAll
    static void setUp() {
        try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
            validator = factory.getValidator();
        }
    }

    @Test
    void shouldRejectBlankCustomerName() {
        CreateOrderRequest request = new CreateOrderRequest(
                "", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations)
                .extracting(v -> v.getPropertyPath().toString())
                .contains("customerName");
    }

    @Test
    void shouldPassWhenAllFieldsValid() {
        CreateOrderRequest request = new CreateOrderRequest(
                "Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations).isEmpty();
    }
}

Validation.buildDefaultValidatorFactory() cria um Validator independente, sem necessidade de contexto Spring. validator.validate(request) retorna Set<ConstraintViolation> – vazio significa válido. Esta abordagem é ideal para unit tests isolados do DTO, separado da lógica do controller.

9. Validar path variable e query param

@Valid em @RequestBody é o caso mais comum, mas você também pode validar parâmetros individuais adicionando @Validated na classe controller:

@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "ID deve ser maior que 0") Long id) {
        return ResponseEntity.ok("Pedido " + id);
    }
}

@Validated (do Spring, diferente de @Valid do Bean Validation) ativa validação em parâmetros de método. Quando id = -1, Spring lança ConstraintViolationException (diferente de MethodArgumentNotValidException da seção 5) – você precisa de um @ExceptionHandler separado para esta exceção:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
}

Atenção: Estas duas exceções são fáceis de confundir – MethodArgumentNotValidException é para @Valid @RequestBody/@ModelAttribute, enquanto ConstraintViolationException é para @Validated em path variable/query param. Sem um dos handlers, o erro cairá no response 500 padrão, muito ruim.

10. Erros comuns e quando considerar alternativas

  • Esquecer @Valid: constraint annotations não se ativam sozinhas, você deve declarar @Valid no local correto (parâmetro do controller, field de objeto aninhado, type argument de generic).
  • Faltar dependency starter-validation: constraint annotations são ignoradas silenciosamente, nenhuma exceção é lançada – muito difícil detectar sem testes.
  • Validar lógica complexa envolvendo múltiplos fields (ex: “data de término deve ser após data de início”): Bean Validation consegue fazer via class-level constraint, mas se a lógica depende de dados do banco de dados (ex: “email ainda não existe”), melhor tratar na camada de service em vez de no validador – validadores não devem chamar repository, fica difícil testar e viola princípio da responsabilidade única.
  • Mensagens de erro multi-idioma: use message = "{order.code.invalid}" com arquivo messages.properties em vez de hard-code português na annotation, se o projeto precisar de i18n.

11. Conclusão

Bean Validation permite declarar regras de validação direto no DTO via annotations, substituindo if-else espalhado pelo código. Combinando @Valid/@Validated com @RestControllerAdvice, você tem um fluxo de validação – reportagem de erros – consistente para toda a API. Quando constraints pré-definidas não são suficientes, validadores customizados permitem estender de forma flexível mantendo o estilo declarativo, fácil de ler e testar.

spring-bootvalidationbean-validationjavarest-api