Validation des DTO dans un contrôleur Spring Boot
L'idée en une phrase
Tout ce qui arrive dans un contrôleur vient de l'extérieur, donc rien n'est garanti : un champ peut manquer, être vide, trop long ou absurde. La validation vérifie ces données à l'entrée, avec des annotations sur le DTO, et renvoie une erreur 400 claire avant même que ton service ne soit appelé.
Une image pour comprendre : le guichet d'accueil
Quand tu déposes un dossier dans une administration, l'agent du guichet vérifie d'abord que le formulaire est bien rempli : les champs obligatoires sont complétés, la date est une vraie date, le numéro de téléphone a le bon format. Si quelque chose manque, il te rend le dossier tout de suite, en te disant quoi corriger.
Mais l'agent du guichet ne décide pas si tu as droit à l'aide demandée. Ça, c'est le travail du service qui instruit le dossier.
C'est exactement le partage des rôles dans une application Spring Boot :
- la validation du DTO (le guichet) vérifie la forme des données ;
- le service (l'instruction) vérifie les règles métier.
Garde cette distinction en tête : elle évite de mettre les vérifications au mauvais endroit.
Pourquoi valider côté serveur ?
« Le front-end vérifie déjà le formulaire. » C'est vrai, et c'est utile pour l'utilisateur. Mais ça ne protège pas ton API :
- n'importe qui peut appeler ton API directement, sans passer par le front-end (avec
curl, Postman, un script) ; - un autre client (une application mobile, une autre équipe) peut appeler la même API avec ses propres règles, ou sans règle ;
- le front-end peut avoir un bug.
La validation côté serveur est la seule qui compte pour la sécurité et la cohérence des données. Celle du front-end sert au confort de l'utilisateur.
La mise en place
La dépendance
La validation n'est pas incluse dans le starter web. Ajoute :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Note : sans cette dépendance, les annotations compilent mais ne font rien. Aucune erreur, aucun avertissement : les données invalides passent. Si ta validation semble ignorée, vérifie d'abord ce point.
Les annotations sur le DTO
public record CreationClientDto(
@NotBlank
@Size(max = 100)
String nom,
@NotBlank
@Email
String email,
@NotNull
@Past
LocalDate dateNaissance,
@PositiveOrZero
Integer nombreEnfants
) {}
@Valid dans le contrôleur
@PostMapping("/clients")
public ResponseEntity<ClientDto> creer(@Valid @RequestBody CreationClientDto dto) { // ✅
ClientDto cree = clientService.creer(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(cree);
}
@Valid déclenche la vérification avant l'appel de la méthode. Si une règle n'est pas respectée, la méthode n'est jamais exécutée : Spring renvoie directement une erreur 400.
⚠️ Sans @Valid, les annotations du DTO sont ignorées. C'est l'oubli le plus fréquent.
Les annotations à connaître
Présence : trois annotations à ne pas confondre
| Annotation | null |
"" |
" " |
S'applique à |
|---|---|---|---|---|
@NotNull |
❌ refusé | ✅ accepté | ✅ accepté | tout type |
@NotEmpty |
❌ refusé | ❌ refusé | ✅ accepté | texte, liste, Map |
@NotBlank |
❌ refusé | ❌ refusé | ❌ refusé | texte uniquement |
Pour un texte obligatoire, c'est presque toujours @NotBlank. Pour un nombre, une date ou un objet obligatoire, @NotNull.
Les autres annotations courantes
| Annotation | Vérifie que… | Exemple |
|---|---|---|
@Size(min, max) |
la longueur d'un texte ou d'une liste est dans l'intervalle | @Size(max = 100) |
@Min, @Max |
un nombre est dans les bornes | @Min(1) @Max(99) |
@Positive, @PositiveOrZero |
un nombre est strictement positif, ou positif ou nul | une quantité, un prix |
@Email |
le texte a la forme d'une adresse e-mail | |
@Pattern(regexp) |
le texte respecte une expression régulière | un code postal : @Pattern(regexp = "\\d{5}") |
@Past, @Future |
une date est dans le passé ou dans le futur | une date de naissance, une date de livraison |
@Digits(integer, fraction) |
un nombre a au plus tant de chiffres avant et après la virgule | un montant : @Digits(integer = 8, fraction = 2) |
Le piège : les annotations ne vérifient pas null
À part @NotNull, @NotEmpty et @NotBlank, toutes les annotations acceptent null. C'est voulu : elles vérifient la valeur si elle est présente.
@Email
String email; // ⚠️ null est accepté : le champ est facultatif
@NotBlank
@Email
String email; // ✅ obligatoire ET au bon format
Pour un champ obligatoire, combine toujours une annotation de présence avec les autres.
Astuce : utilise des types objets (
Integer,Long,BigDecimal) dans les DTO plutôt que des types simples (int,long). Unintabsent du JSON vaut0au lieu denull:@NotNullne peut plus détecter qu'il manquait.
Des messages d'erreur utiles
Chaque annotation a un message par défaut (« ne doit pas être vide »), traduit selon la langue de la requête. Pour un message plus précis :
@NotBlank(message = "Le nom est obligatoire")
@Size(max = 100, message = "Le nom ne doit pas dépasser {max} caractères")
String nom
{max} est remplacé par la valeur de l'annotation : le message reste juste si tu changes la limite.
Renvoyer une réponse 400 exploitable
Quand la validation échoue, Spring lance une MethodArgumentNotValidException et renvoie une erreur 400. Mais par défaut, la réponse ne dit pas quels champs posent problème. Pour le front-end, c'est inutilisable : il ne sait pas quel champ du formulaire mettre en rouge.
Ajoute un traitement dans ton gestionnaire global (voir la fiche sur les exceptions) :
@RestControllerAdvice
public class GestionnaireErreurs {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail validation(MethodArgumentNotValidException e) {
ProblemDetail probleme = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Certains champs sont invalides");
Map<String, String> erreurs = new LinkedHashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(erreur -> erreurs.putIfAbsent(erreur.getField(), erreur.getDefaultMessage()));
probleme.setProperty("erreurs", erreurs);
return probleme;
}
// ... les autres @ExceptionHandler
}
La réponse devient :
{
"status": 400,
"detail": "Certains champs sont invalides",
"erreurs": {
"nom": "Le nom est obligatoire",
"email": "doit être une adresse électronique syntaxiquement correcte"
}
}
Le front-end peut afficher chaque message sous le bon champ, et toutes les erreurs arrivent en une seule fois, pas une par une.
⚠️ Le piège du gestionnaire « attrape-tout ». Si ton @RestControllerAdvice contient un @ExceptionHandler(Exception.class) pour les erreurs inattendues, il attrape aussi les erreurs de validation… et les transforme en 500. Une donnée mal saisie par l'utilisateur apparaît alors comme une panne du serveur. Le traitement spécifique ci-dessus règle le problème : Spring choisit toujours le gestionnaire le plus précis.
Note : un JSON mal formé (une virgule manquante, du texte à la place d'un nombre) n'atteint même pas la validation : Spring lance une
HttpMessageNotReadableException. Si tu as un gestionnaire « attrape-tout », traite-la aussi pour renvoyer une 400.
Les objets imbriqués et les listes
@Valid ne vérifie que le premier niveau. Un objet ou une liste d'objets à l'intérieur du DTO n'est vérifié que si tu ajoutes @Valid dessus aussi :
public record CommandeDto(
@NotNull
@Valid // ✅ vérifie aussi les champs de l'adresse
AdresseDto adresseLivraison,
@NotEmpty
List<@Valid LigneCommandeDto> lignes // ✅ vérifie chaque ligne
) {}
public record AdresseDto(
@NotBlank String rue,
@NotBlank @Pattern(regexp = "\\d{5}") String codePostal,
@NotBlank String ville) {}
public record LigneCommandeDto(
@NotNull Long produitId,
@NotNull @Positive Integer quantite) {}
Sans le @Valid sur adresseLivraison, une adresse sans ville passerait sans erreur.
Dans la réponse 400, le nom du champ indique le chemin : adresseLivraison.ville, lignes[2].quantite.
Les paramètres d'adresse et de requête
Les annotations fonctionnent aussi directement sur les paramètres d'une méthode de contrôleur :
@GetMapping("/produits")
public List<ProduitDto> rechercher(
@RequestParam @Size(min = 2) String recherche,
@RequestParam(defaultValue = "20") @Max(100) int taille) {
// ...
}
Spring renvoie une 400 si recherche fait moins de 2 caractères ou si taille dépasse 100. L'exception levée est cette fois une HandlerMethodValidationException. Pense à la traiter aussi dans ton gestionnaire global si tu veux le même format de réponse.
Vérifier deux champs ensemble
Certaines règles portent sur plusieurs champs : la date de fin doit être après la date de début. Une annotation sur un seul champ ne peut pas le vérifier. La solution la plus simple est une méthode annotée @AssertTrue dans le DTO :
public record PeriodeDto(
@NotNull LocalDate debut,
@NotNull LocalDate fin) {
@AssertTrue(message = "La date de fin doit être après la date de début")
public boolean isPeriodeValide() {
if (debut == null || fin == null) {
return true; // déjà signalé par @NotNull, inutile d'ajouter une seconde erreur
}
return fin.isAfter(debut);
}
}
Le nom de la méthode doit commencer par is : l'erreur apparaît alors sous le nom periodeValide.
Validation ou règle métier ?
Revenons au guichet. Toutes les vérifications n'ont pas leur place dans le DTO :
| Vérification | Où ? | Pourquoi |
|---|---|---|
| le nom est renseigné | DTO (@NotBlank) |
c'est la forme de la donnée |
| l'e-mail a le bon format | DTO (@Email) |
idem |
| la quantité est positive | DTO (@Positive) |
idem |
| l'e-mail n'est pas déjà utilisé | service + contrainte en base | il faut consulter la base de données |
| le produit existe | service | idem |
| le stock est suffisant | service | règle métier, qui change avec les données |
| une commande expédiée ne peut plus être annulée | service | règle métier |
La règle simple : si la vérification n'a besoin que du DTO lui-même, c'est de la validation. Si elle a besoin de la base de données ou de l'état de l'application, c'est une règle métier, qui va dans le service et lance une exception métier (voir les fiches sur les exceptions et sur la concurrence).
Les bonnes habitudes
- N'oublie ni la dépendance
spring-boot-starter-validation, ni@Valid: sans eux, rien n'est vérifié, et sans erreur. @NotBlankpour un texte obligatoire,@NotNullpour le reste.- Combine une annotation de présence avec les autres pour un champ obligatoire :
@Emailseul acceptenull. - Des types objets dans les DTO (
Integer, pasint). - Renvoie la liste des champs en erreur dans la réponse 400, et vérifie qu'un gestionnaire « attrape-tout » ne la transforme pas en 500.
@Validsur les objets imbriqués et dans les listes.- Valide des DTO, jamais des entités reçues directement dans le contrôleur.
- La forme dans le DTO, les règles métier dans le service.
- Ne compte jamais sur la validation du front-end.
À retenir
- Les données reçues par un contrôleur viennent de l'extérieur : le serveur doit toujours les vérifier.
- Des annotations sur le DTO (
@NotBlank,@Size,@Email…) et@Validdans le contrôleur : si une règle échoue, la méthode n'est pas appelée et Spring renvoie une 400. - Seules
@NotNull,@NotEmptyet@NotBlankrefusentnull: combine-les avec les autres annotations. - Personnalise la réponse 400 pour indiquer chaque champ en erreur.
@Validdoit être répété sur les objets imbriqués et les éléments de liste.- Le DTO vérifie la forme ; le service vérifie les règles métier.