Validation dans Spring Boot avec Bean Validation (@Valid, @NotBlank, …)

1. Aperçu

Lors de la construction d’une API REST, vous devez toujours vérifier les données d’entrée avant de traiter la logique métier : email au bon format, nom non vide, âge positif… Bean Validation (JSR 380, implémentation Hibernate Validator) vous aide à faire cela via des annotations, au lieu d’écrire des tas de if-else manuels.

Cet article passe en revue la configuration, l’utilisation de @Valid avec les annotations courantes, la validation des objets imbriqués, l’écriture de validateurs personnalisés, et la gestion des erreurs de validation pour renvoyer une réponse conviviale au client. Après la lecture, vous serez en confiance pour appliquer la validation à tous les DTOs de votre projet Spring Boot.

2. Ajouter la dépendance

Spring Boot n’inclut pas Bean Validation par défaut, vous devez ajouter spring-boot-starter-validation :

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

Cette dépendance apporte Hibernate Validator – l’implémentation la plus courante de l’API Bean Validation. Si vous utilisez spring-boot-starter-web, beaucoup pensent que la validation est déjà incluse, mais en réalité, depuis Spring Boot 2.3, ce starter a été séparé – sans lui, @Valid n’aura aucun effet et ne signalera aucune erreur, les données incorrectes passeront quand même.

3. Valider le body de la requête avec @Valid

3.1. Déclarer une DTO avec des annotations de contrainte

public record CreateOrderRequest(
        @NotBlank(message = "Le nom du client ne peut pas être vide")
        String customerName,

        @Email(message = "Email invalide")
        @NotBlank
        String email,

        @Positive(message = "La quantité doit être supérieure à 0")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "Le prix doit être supérieur à 0")
        BigDecimal unitPrice
) {}

Chaque champ reçoit une ou plusieurs annotations de contrainte : @NotBlank n’accepte pas null/vide/seulement-espaces, @Email vérifie le format de l’email, @Positive exige un nombre positif, @DecimalMin limite la valeur minimale. Le paramètre message est le contenu de l’erreur retournée en cas de violation.

3.2. Appliquer @Valid au contrôleur

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

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // request est garanti d'être valide à ce point
        return ResponseEntity.ok("Commande créée pour " + request.customerName());
    }
}

@Valid placé avant @RequestBody indique à Spring MVC d’exécuter la validation sur l’objet qui vient d’être désérialisé à partir du JSON. Si un champ viole une contrainte, Spring lève MethodArgumentNotValidException avant que le corps de la méthode s’exécute – ce qui signifie que vous n’avez plus besoin de vérifier manuellement null ou vide dans votre logique métier.

En envoyant une requête sans customerName :

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

Par défaut (sans gestion d’exception), Spring retourne HTTP 400 avec un body assez long et difficile à lire pour le client. La section 5 gèrera cela plus proprement.

4. Les annotations couramment utilisées

Annotation Signification
@NotNull La valeur ne doit pas être null
@NotBlank String non null, non vide, ne contenant pas que des espaces
@NotEmpty Collection/String/Array non null et non vide
@Size(min, max) Longueur de String ou taille de Collection
@Min / @Max Limite pour les nombres entiers
@Positive / @Negative Nombres positifs/négatifs
@Email Format email
@Pattern(regexp) Correspondance regex
@Past / @Future Date dans le passé/futur

Exemple combinant plusieurs contraintes sur un champ :

public class Customer {

    @Size(min = 3, max = 50, message = "Le nom doit contenir entre 3 et 50 caractères")
    private String name;

    @Pattern(regexp = "^0\\d{9}$", message = "Le numéro de téléphone doit avoir 10 chiffres, commençant par 0")
    private String phone;

    @Past(message = "La date de naissance doit être dans le passé")
    private LocalDate birthDate;
}

Vous pouvez attacher plusieurs annotations sur le même champ – par exemple @NotBlank combiné avec @Size – pour bloquer à la fois le vide et limiter la longueur. Le validateur exécute toutes les contraintes et regroupe les erreurs, il ne s’arrête pas à la première erreur.

5. Gérer les erreurs de validation avec @ExceptionHandler

Au lieu de laisser Spring retourner une réponse par défaut confuse, utilisez @RestControllerAdvice pour la reformater :

@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 contient BindingResult avec une liste de FieldError – chaque erreur a un nom de champ et un message. Le code ci-dessus regroupe toutes les erreurs dans une Map<field, message> puis retourne HTTP 400.

Résultat de l’appel de la requête manquant customerName ci-dessus :

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

{
  "customerName": "Le nom du client ne peut pas être vide"
}

La réponse est maintenant concise, le client peut facilement mapper les erreurs à chaque champ du formulaire.

6. Valider les objets imbriqués et les collections

6.1. Objet imbriqué avec @Valid

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

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

Si CustomerRequest.address a seulement @NotNull sans @Valid, les contraintes à l’intérieur de AddressRequest (comme @NotBlank sur city) ne seront pas vérifiées – c’est une erreur très courante lors de l’utilisation initiale de Bean Validation. @Valid ici demande au validateur de “descendre” dans l’objet imbriqué.

6.2. Valider chaque élément d’une List

public record BulkOrderRequest(
        @NotEmpty(message = "La liste des articles ne peut pas être vide")
        @Valid
        List<@Valid OrderItem> items
) {}

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

@Valid placé juste avant List<...> (ou sur l’argument de type) indique au validateur de vérifier chaque élément de la liste, pas seulement si la liste est null ou non. Si le 2e élément de la liste a quantity = -1, l’erreur retournée précisera le champ comme items[1].quantity.

7. Écrire un validateur personnalisé

Lorsque les contraintes intégrées ne suffisent pas, vous créez votre propre annotation. Par exemple, vérifier que le code de commande commence par ORD- :

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "Le code de commande doit avoir la forme 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; // laisser @NotNull gérer ce cas, éviter les doublons d'erreurs
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...) lie l’annotation à la classe qui gère la logique. ConstraintValidator<A, T> reçoit A comme annotation, T comme type de données du champ appliqué. La méthode isValid retourne true si valide. Remarque : il est préférable que isValid retourne true quand value == null, car la validation non-null doit être confiée à @NotNull seul – pour éviter qu’un champ soit signalé avec deux erreurs et deux messages différents.

Utiliser dans la DTO :

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

8. Tester la validation avec JUnit 5 + AssertJ

Vous pouvez tester directement Validator sans démarrer le contexte Spring MVC, plus rapide que les tests d’intégration :

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() crée un Validator indépendant, sans contexte Spring. validator.validate(request) retourne Set<ConstraintViolation> – vide signifie valide. Cette approche de test convient aux tests unitaires des DTOs isolément, séparés de la logique du contrôleur.

9. Valider les variables de chemin et les paramètres de requête

@Valid sur @RequestBody est le plus courant, mais vous pouvez aussi valider les paramètres individuels en ajoutant @Validated à la classe du contrôleur :

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

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "L'ID doit être supérieur à 0") Long id) {
        return ResponseEntity.ok("Commande " + id);
    }
}

@Validated (de Spring, différent de @Valid de Bean Validation) active la validation au niveau des paramètres de méthode. Quand id = -1, Spring lève ConstraintViolationException (différente de MethodArgumentNotValidException à la section 5) – vous avez besoin d’un @ExceptionHandler séparé pour cette exception :

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

Remarque : Ces deux exceptions sont faciles à confondre – MethodArgumentNotValidException est pour @Valid @RequestBody/@ModelAttribute, tandis que ConstraintViolationException est pour @Validated sur les variables de chemin/paramètres de requête. Sans l’un des deux handlers, l’erreur tombera sur une réponse 500 par défaut qui est très mauvaise.

10. Erreurs courantes et quand envisager une alternative

  • Oublier @Valid : l’annotation de contrainte ne s’active pas automatiquement, vous devez déclarer @Valid au bon endroit (paramètre du contrôleur, champ d’objet imbriqué, argument de type de générique).
  • Manquer la dépendance starter-validation : l’annotation de contrainte est ignorée silencieusement, aucune exception n’est levée – très difficile à détecter sans test.
  • Validation de logique complexe sur plusieurs champs (par exemple “la date de fin doit être après la date de début”) : Bean Validation peut le faire via une contrainte au niveau de la classe, mais si la logique dépend des données de la base de données (par exemple “l’email n’existe pas encore”), gérez-la dans la couche service plutôt que dans le validateur – car le validateur ne devrait pas appeler le repository, cela le rendrait difficile à tester et violerait le principe de responsabilité unique.
  • Messages d’erreur multilingues : utilisez message = "{order.code.invalid}" et fichier messages.properties au lieu de coder en dur le français dans l’annotation, si le projet a besoin d’i18n.

11. Conclusion

Bean Validation vous aide à déclarer des règles de validation des données directement sur la DTO via des annotations, au lieu de disperser du code if-else. Combiné avec @Valid/@Validated et @RestControllerAdvice, vous avez un flux de validation – une signalisation d’erreur cohérente sur toute votre API. Lorsque les contraintes intégrées ne suffisent pas, les validateurs personnalisés vous permettent d’étendre de façon flexible tout en conservant un style déclaratif, lisible et facile à tester.

spring-bootvalidationbean-validationjavarest-api