Spring Boot et Spring Cloud

Validation et gestion des erreurs avec Spring Boot 3 : Bean Validation et Problem Details

Intermédiaire7 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Vous saurez rejeter les entrées invalides avant qu'elles n'atteignent la logique métier et renvoyer des erreurs exploitables par les clients : appliquer des contraintes Jakarta Bean Validation aux records et aux paramètres de méthode, écrire des contraintes personnalisées, centraliser la gestion des exceptions dans un @RestControllerAdvice et produire des réponses application/problem+json standard sans exposer les détails internes.

Ce que vous allez apprendre

  • Appliquer des contraintes Jakarta Bean Validation aux DTO de requête et aux paramètres de contrôleur, en sachant quelle exception chacune produit
  • Écrire une contrainte personnalisée avec sa classe de validation et utiliser les groupes de validation
  • Activer la prise en charge native de ProblemDetail dans Spring et comprendre ce qu'elle couvre
  • Centraliser les erreurs métier et de validation dans un @RestControllerAdvice qui produit des Problem Details RFC 9457
  • Éviter d'exposer stack traces, structure des entités et messages du framework dans les réponses d'erreur

Une API se juge autant à sa façon d'échouer qu'à sa façon de réussir. Les clients doivent savoir s'ils ont envoyé quelque chose d'incorrect, si la ressource existe, si une nouvelle tentative a une chance d'aboutir et quel champ corriger. Spring Boot 3 fournit de quoi répondre à ces questions de manière cohérente : Jakarta Bean Validation pour décrire à quoi ressemble une entrée valide, des gestionnaires d'exceptions pour traduire les échecs en réponses, et le type ProblemDetail de Spring Framework 6 pour un corps d'erreur standard. Cette page les assemble en une approche unique, applicable à chaque endpoint.

Déclarer les contraintes

Ajoutez spring-boot-starter-validation, qui apporte Hibernate Validator, l'implémentation de référence de Jakarta Bean Validation 3. L'API se trouve dans jakarta.validation ; si vous voyez des imports javax.validation, vous lisez une documentation antérieure à Boot 3.

Les contraintes sont des annotations posées sur les champs, les composants de record, les paramètres et les valeurs de retour :

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) {}
}

Les contraintes natives les plus utilisées sont @NotNull, @NotBlank (non nul et contenant au moins un caractère autre qu'un espace), @NotEmpty (non nul et non vide, pour les collections et les chaînes), @Size, @Min/@Max, @Positive/@PositiveOrZero, @DecimalMin/@DecimalMax, @Digits, @Past/@Future (et leurs variantes OrPresent), @Email et @Pattern. Hibernate Validator en ajoute quelques-unes, comme @URL et @Length, au prix d'un couplage à cette implémentation.

Deux points surprennent souvent. D'abord, les contraintes ne se propagent pas en cascade sans qu'on le demande : une List<AddressRequest> n'est vérifiée que pour @NotEmpty tant que vous n'ajoutez pas @Valid. Ensuite, @NotNull sur un int primitif ne sert à rien : un primitif n'est jamais nul, si bien qu'un champ JSON absent devient silencieusement zéro. Utilisez le type enveloppe Integer avec @NotNull quand l'absence doit être une erreur.

Pour les expressions régulières de @Pattern, rappelez-vous que la valeur doit correspondre en entier ; vous pouvez essayer vos motifs sur des exemples dans le testeur de regex avant de les committer.

Déclencher la validation dans les contrôleurs

Spring MVC exécute la validation à trois endroits, chacun levant une exception différente :

Emplacement de la contrainteDéclencheurExceptionStatut par défaut
Objet @RequestBody annoté @ValidRésolution des argumentsMethodArgumentNotValidException400
@RequestParam, @PathVariable, @RequestHeader (Spring 6.1+)Validation de méthode nativeHandlerMethodValidationException400
N'importe quelle méthode d'une classe annotée @Validated (services, anciens contrôleurs)Proxy AOPConstraintViolationException500 si non gérée
@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);
    }
}

La troisième ligne mérite qu'on s'y arrête. Avant Spring Framework 6.1, les contraintes sur les paramètres simples étaient ignorées sauf si le contrôleur portait @Validated, qui l'enveloppait dans un proxy ; la ConstraintViolationException qui en résultait n'était pas gérée par Spring MVC et produisait une 500. Depuis la 6.1, le framework valide lui-même ces paramètres lorsqu'un fournisseur est présent, et la HandlerMethodValidationException est traitée comme une 400. Si vos contrôleurs portent encore @Validated, retirez-la pour que les deux mécanismes ne s'exécutent pas en même temps.

@Validated reste l'outil adapté aux services : annotez la classe, placez les contraintes sur les paramètres des méthodes, et tout appelant qui transmet des données invalides reçoit une ConstraintViolationException, que l'appel vienne de HTTP, d'un scheduler ou d'un listener de messages.

Contraintes personnalisées et groupes

Lorsqu'une règle est réutilisable et ne porte que sur la valeur (un IBAN, un code produit interne, un mot de passe robuste), écrivez une contrainte plutôt que de répéter les vérifications dans les 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
    }
}

Renvoyez true pour null afin que le caractère facultatif reste une décision distincte, prise avec @NotNull. Les validateurs sont des beans Spring lorsque le LocalValidatorFactoryBean de Boot est utilisé : ils peuvent donc injecter un repository pour vérifier, par exemple, qu'un e-mail n'est pas déjà enregistré. Gardez ces vérifications peu coûteuses, car elles s'exécutent à chaque validation de l'objet, et gérez de toute façon la situation de concurrence en base avec une contrainte d'unicité.

Les groupes permettent à un même record de porter des règles différentes selon l'opération. Déclarez des interfaces marqueurs (OnCreate, OnUpdate), rattachez-les avec @NotNull(groups = OnUpdate.class) et validez avec @Validated(OnUpdate.class) sur le paramètre du contrôleur. Les groupes sont puissants et faciles à surutiliser ; des records de requête distincts par opération sont souvent plus lisibles.

Les contraintes au niveau de la classe (annotation sur le record, validateur recevant l'objet entier) expriment des règles portant sur plusieurs champs, comme « la date de fin est postérieure à la date de début ». Les règles véritablement métier, celles qui ont besoin d'autres agrégats ou d'une transaction, ont leur place dans le service, qui lève alors une exception métier.

Des corps d'erreur standard avec ProblemDetail

La RFC 9457 (qui rend obsolète la RFC 7807) définit une structure JSON pour les erreurs que la plupart des outils d'API comprennent déjà :

{
  "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" }
  ]
}

Spring Framework 6 la modélise avec org.springframework.http.ProblemDetail, sérialisé avec le media type application/problem+json. Boot bascule vers ce format la gestion des exceptions propre au framework, avec une seule propriété :

spring.mvc.problemdetails.enabled=true

Cette propriété enregistre un ResponseEntityExceptionHandler qui couvre les exceptions levées par Spring MVC lui-même : corps illisibles, media types non pris en charge, paramètres manquants, MethodArgumentNotValidException, HandlerMethodValidationException, NoResourceFoundException et d'autres. Il ignore tout de vos exceptions métier, et sa réponse de validation ne liste aucune erreur par champ : en pratique, vous l'étendez.

Un seul @RestControllerAdvice

Centralisez la correspondance dans une seule classe pour que tous les contrôleurs échouent de la même façon :

@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;
    }
}

Il suffit de renvoyer un ProblemDetail depuis un @ExceptionHandler pour que Spring en tire le statut et fixe le content type à application/problem+json. setProperty ajoute des membres d'extension (errors ci-dessus) sérialisés à la racine du JSON, comme le prévoit la RFC. Le gestionnaire fourre-tout journalise la véritable exception avec sa stack trace et ne renvoie qu'une référence que l'appelant pourra communiquer au support ; un identifiant de trace ou de corrélation issu de votre dispositif de traçage est idéal.

Les exceptions métier peuvent aussi étendre ErrorResponseException de Spring, qui porte un ProblemDetail et ne nécessite aucun gestionnaire. C'est pratique pour les petits services ; la classe advice passe mieux à l'échelle dès que vous voulez faire varier la réponse selon le type d'exception ou ajouter de la journalisation.

L'ordre compte lorsque plusieurs gestionnaires peuvent correspondre : Spring choisit le type d'exception le plus proche, si bien que CustomerNotFoundException l'emporte sur Exception. S'il existe deux classes advice, @Order décide laquelle est consultée en premier.

Le mécanisme de repli /error de Boot (le BasicErrorController) prend toujours en charge ce qui échappe à MVC, par exemple les échecs survenus dans un filtre. Sa sortie est pilotée par server.error.include-message, include-binding-errors, include-stacktrace et include-exception, qui doivent tous rester à leur valeur par défaut never en production.

Messages et internationalisation

Les messages des contraintes sont résolus via une MessageSource. Placez-les dans ValidationMessages.properties (la convention Bean Validation, à la racine du classpath) ou, depuis Boot 2.6, dans le messages.properties de l'application, puisque Boot branche sa MessageSource sur le validateur :

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

Référencez-le ensuite avec @Email(message = "{customer.email.invalid}"). Les fichiers propres à une locale (messages_fr.properties) sont choisis d'après l'en-tête Accept-Language de la requête. Traduire ou non les messages d'erreur d'une API relève d'une décision produit ; beaucoup d'API renvoient des codes lisibles par une machine et laissent le client se charger de la localisation.

Erreurs courantes

Oublier @Valid sur le corps. Les contraintes sont déclarées, le starter est présent, et il ne se passe rien. La validation d'un @RequestBody s'active paramètre par paramètre.

Valider trop tard. Les contraintes posées sur les entités (@NotBlank sur un champ JPA) s'exécutent au flush, à l'intérieur de la transaction, et remontent sous forme de TransactionSystemException enveloppant une ConstraintViolationException. Validez à la frontière de l'API, sur les records de requête, et considérez les contraintes d'entité comme une seconde ligne de défense.

Recopier ex.getMessage() dans la réponse. Les messages de DataIntegrityViolationException contiennent du SQL et des noms de contraintes ; ceux de HttpMessageNotReadableException contiennent des noms de classes et des détails internes de Jackson. Rédigez le détail vous-même.

Renvoyer une structure d'erreur différente par contrôleur. Deux gestionnaires, l'un produisant { "error": ... } et l'autre { "message": ... }, obligent chaque client à traiter des cas particuliers. Une classe advice, une structure.

Lever des ResponseStatusException partout. C'est acceptable pour une 404 rapide, mais son message devient la reason de la réponse, ses corps ne sont pas cohérents avec vos Problem Details, et elle dissémine la connaissance de HTTP dans les services. Levez des exceptions métier et faites la correspondance une seule fois.

Activer include-stacktrace=always « juste pour la préprod ». La configuration de préproduction a une fâcheuse tendance à devenir celle de production. Journalisez les traces ; ne les renvoyez jamais.

Points clés à retenir

  • Décrivez ce qui est valide avec des contraintes Jakarta Bean Validation sur les records de requête ; @Valid sur le @RequestBody les déclenche, @Valid sur les champs imbriqués propage la validation en cascade.
  • Depuis Spring Framework 6.1, les contraintes sur @RequestParam et @PathVariable sont appliquées sans @Validated et produisent une HandlerMethodValidationException.
  • Les contraintes personnalisées couvrent les règles de format réutilisables ; les règles métier qui ont besoin d'autres données relèvent des services, sous forme d'exceptions métier.
  • spring.mvc.problemdetails.enabled=true associé à un @RestControllerAdvice qui étend ResponseEntityExceptionHandler donne à chaque échec la même structure RFC 9457.
  • Ajoutez les errors par champ comme membre d'extension, gardez un detail lisible par un humain, et ne recopiez jamais de message d'exception brut ni de stack trace dans une réponse.
  • Journalisez les exceptions inattendues côté serveur avec un identifiant de corrélation et renvoyez cet identifiant au client.

Sources et lectures complémentaires

Questions fréquentes

Quelle différence entre @Valid et @Validated ?
@Valid est l'annotation de Jakarta Bean Validation : sur un paramètre de contrôleur, elle valide l'objet et déclenche la validation en cascade des objets imbriqués marqués @Valid. @Validated est l'annotation de Spring : elle accepte des groupes de validation et, posée sur une classe, active la validation au niveau méthode des paramètres et des valeurs de retour via un proxy. Utilisez @Valid sur les corps de requête et @Validated lorsque vous avez besoin de groupes ou voulez valider les arguments des méthodes d'un service.
Pourquoi mes contraintes sur @RequestParam ou @PathVariable n'ont-elles aucun effet ?
Avant Spring Framework 6.1, les contraintes sur les paramètres simples n'étaient appliquées que si la classe du contrôleur portait @Validated, ce qui créait un proxy et levait une ConstraintViolationException. Depuis la 6.1, Spring MVC les valide nativement dès qu'un fournisseur Bean Validation est présent, et lève à la place une HandlerMethodValidationException. Vérifiez la version du framework et assurez-vous que spring-boot-starter-validation est bien dans le classpath.
Faut-il renvoyer 400 ou 422 pour un échec de validation ?
Les deux se défendent. 400 Bad Request est ce que la gestion par défaut de Spring utilise pour MethodArgumentNotValidException, et tous les clients le comprennent. 422 Unprocessable Content est plus précis quand le JSON est bien formé mais enfreint des règles métier. Choisissez l'un des deux, documentez-le dans l'API et appliquez-le de façon cohérente ; ne mélangez pas les deux pour le même type d'échec.
Comment empêcher Spring Boot d'exposer les messages d'exception et les stack traces ?
La réponse d'erreur par défaut de Boot omet déjà le message et la trace, sauf si server.error.include-message ou server.error.include-stacktrace valent always. Laissez-les à never en production, et faites en sorte que votre @RestControllerAdvice associe chaque exception à un texte de détail rédigé à la main plutôt que de recopier exception.getMessage(), qui, pour les erreurs de persistance et du framework, contient souvent du SQL, des noms de classes ou des chemins de fichiers.