Gestion des exceptions dans Spring Boot : Guide complet
Ton API fonctionne parfaitement... jusqu'à ce qu'un utilisateur envoie des données invalides, qu'un ID n'existe pas en base, ou qu'une connexion réseau échoue.
Contexte technique
Versions utilisées dans cet article :
- Spring Boot 3.2+
- Java 17+
- spring-boot-starter-validation pour la validation avec
@Valid
Note migration : Si vous utilisez Spring Boot 2.x, remplacez jakarta.validation.* par javax.validation.* dans les imports.
Pourquoi cette approche en entreprise ?
Sans gestion d'erreur centralisée, tu exposes ton entreprise à trois risques majeurs :
- Sécurité : Les stack traces révèlent la structure de votre code, les noms de vos classes, vos dépendances et leurs versions (CVE exploitables)
- Conformité légale : Le RGPD exige de ne pas exposer d'informations sensibles dans les logs ou réponses
- Coût de support : Des erreurs incompréhensibles = tickets support inutiles = perte de temps et d'argent
En production, une erreur non gérée peut coûter des milliers d'euros en incident de sécurité ou en temps de debugging.
Sans gestion d'erreur, ton API renvoie ça au client :
{
"timestamp": "2026-01-29T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException: Cannot invoke String.length() because email is null\n\tat com.springcraft.UserService.createUser(UserService.java:42)\n\tat com.springcraft.UserController.create(UserController.java:28)\n\t..."
}Problèmes :
- C'est moche et incompréhensible pour le client
- Ça expose des détails internes de ton code (faille de sécurité !)
- Le message n'aide pas le client à corriger son erreur
Solution : Une gestion d'exceptions centralisée et professionnelle.
Dans cet article, nous allons voir comment gérer les erreurs comme un pro : exceptions personnalisées, @ControllerAdvice, réponses standardisées, et validation automatique.
Flux de gestion des exceptions
Voici ce qui se passe quand une exception est levée dans ton application :
Le principe : au lieu de laisser Spring gérer les exceptions n'importe comment, tu interceptes TOUTES les exceptions dans un @ControllerAdvice et tu renvoies des réponses JSON propres et standardisées.
Sommaire
- Pourquoi gérer les exceptions
- Les types d'exceptions dans Spring Boot
- Créer des exceptions personnalisées
- Gestionnaire global avec @ControllerAdvice
- Créer des réponses d'erreur standardisées
- Validation automatique avec @Valid
- Gérer les erreurs de validation
- Bonnes pratiques et cas d'usage
- Pour aller plus loin
Pourquoi gérer les exceptions
Sans gestion d'erreur
Imaginez ce controller :
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUserById(id); // Peut lancer une exception
}
}Si l'utilisateur n'existe pas, Spring renvoie une erreur 500 avec une stack trace complète. C'est mauvais :
- Sécurité : Vous exposez la structure interne de votre code
- UX : Le client ne sait pas quoi faire de cette erreur
- Debugging : Difficile de distinguer une vraie erreur serveur d'une erreur métier
Avec une bonne gestion d'erreur
Le même endpoint, mais avec gestion d'erreur :
Requête : GET /api/users/999 (utilisateur inexistant)
Réponse (404 Not Found) :
{
"status": 404,
"message": "Utilisateur non trouvé avec l'ID : 999",
"timestamp": "2026-01-29T10:30:00"
}Avantages :
- Clair : Le client sait exactement ce qui ne va pas
- Sécurisé : Pas de stack trace exposée
- Professionnel : Format standardisé et cohérent
Les types d'exceptions dans Spring Boot
Il existe plusieurs catégories d'exceptions :
1. Exceptions métier
Des erreurs liées à votre domaine métier. Exemples :
- Utilisateur non trouvé
- Email déjà utilisé
- Solde insuffisant pour effectuer un paiement
Gestion : Vous créez vos propres exceptions (ResourceNotFoundException, ValidationException, etc.)
2. Exceptions de validation
Des erreurs de validation des données entrantes. Exemples :
- Email invalide
- Champ obligatoire manquant
- Valeur hors limite
Gestion : Spring Boot les gère automatiquement avec @Valid et @Validated
3. Exceptions techniques
Des erreurs techniques (réseau, base de données, etc.). Exemples :
- Connexion à la base échouée
- Timeout d'une API externe
- Erreur de parsing JSON
Gestion : Souvent des exceptions non-contrôlées qu'on log et renvoie en 500
Créer des exceptions personnalisées
Pour gérer les erreurs métier, créez vos propres exceptions.
Exception de base
package com.springcraft.app.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}Pourquoi hériter de RuntimeException ?
En Java, il existe deux types d'exceptions :
- Checked exceptions (extends
Exception) : Le compilateur vous oblige à les gérer avectry-catchouthrows - Unchecked exceptions (extends
RuntimeException) : Pas besoin de les déclarer
Pour Spring Boot, utilisez RuntimeException. C'est plus simple et Spring les gère automatiquement.
Autres exceptions métier
package com.springcraft.app.exception;
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}package com.springcraft.app.exception;
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}Utilisation dans le code métier
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id));
}
public User createUser(String email, String name) {
if (userRepository.existsByEmail(email)) {
throw new ConflictException("Un utilisateur existe déjà avec cet email : " + email);
}
User user = new User();
user.setEmail(email);
user.setName(name);
return userRepository.save(user);
}
}Pourquoi c'est bien ?
- Le code est lisible : on comprend immédiatement ce qui peut échouer
- Les exceptions sont nommées : on sait de quel type d'erreur il s'agit
- Le message est informatif : on sait exactement ce qui ne va pas
Gestionnaire global avec @ControllerAdvice
Maintenant, il faut dire à Spring comment transformer ces exceptions en réponses HTTP.
Sans gestionnaire, Spring renvoie une erreur 500 pour toutes les exceptions.
Avec @ControllerAdvice, vous interceptez les exceptions et renvoyez des réponses personnalisées.
Créer le gestionnaire
package com.springcraft.app.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;
@ControllerAdvice // Intercepte les exceptions de tous les controllers
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(
ResourceNotFoundException ex,
WebRequest request) {
log.error("Ressource non trouvée : {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(
ConflictException ex,
WebRequest request) {
log.warn("Conflit détecté : {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex,
WebRequest request) {
log.error("Erreur inattendue", ex);
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Une erreur interne est survenue. Veuillez réessayer plus tard.",
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}Décortiquons ce code
@ControllerAdvice
Cette annotation dit à Spring : "Cette classe gère les exceptions pour tous les controllers".
Variante : Si vous voulez gérer les exceptions uniquement pour certains controllers :
@ControllerAdvice(basePackages = "com.springcraft.app.controller.user")@ExceptionHandler(ResourceNotFoundException.class)
Cette annotation indique : "Quand une ResourceNotFoundException est lancée, appelle cette méthode".
Ordre d'exécution :
- Une exception est lancée dans un controller ou un service
- Spring cherche un
@ExceptionHandlerqui correspond - La méthode du handler est appelée
- Elle renvoie une
ResponseEntityau client
ResponseEntity<ErrorResponse>
On renvoie une réponse HTTP avec :
- Un code HTTP : 404, 409, 500, etc.
- Un body : l'objet
ErrorResponse(voir section suivante)
Gestion générique avec Exception.class
Le dernier handler attrape toutes les exceptions non gérées spécifiquement.
Pourquoi c'est important ?
- Évite d'exposer des stack traces
- Fournit toujours une réponse cohérente au client
- Permet de logger les vraies erreurs pour débugger
Note importante : En production, ne renvoyez jamais le message d'exception technique au client. Renvoyez un message générique.
Créer des réponses d'erreur standardisées
Créons un objet ErrorResponse pour standardiser toutes nos réponses d'erreur.
Version simple avec record (Java 14+)
package com.springcraft.app.exception;
import java.time.LocalDateTime;
public record ErrorResponse(
int status,
String message,
LocalDateTime timestamp
) {}Avantage des records : Code ultra-concis. Java génère automatiquement le constructeur, getters, equals, hashCode, toString.
Version avec classe (Java 11)
Si vous êtes sur Java 11 ou inférieur :
package com.springcraft.app.exception;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
public class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
}Version enrichie
Pour une API plus complète, ajoutez des champs supplémentaires :
public record ErrorResponse(
int status,
String message,
String path, // L'URL qui a causé l'erreur
LocalDateTime timestamp
) {}Puis dans le handler :
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(
ResourceNotFoundException ex,
WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
request.getDescription(false).replace("uri=", ""), // Récupère le path
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}Résultat :
{
"status": 404,
"message": "Utilisateur non trouvé avec l'ID : 999",
"path": "/api/users/999",
"timestamp": "2026-01-29T10:30:00"
}Validation automatique avec @Valid
Spring Boot peut valider automatiquement les données entrantes avec les annotations de validation.
Ajouter la dépendance
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Créer un DTO avec validation
package com.springcraft.app.model.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class CreateUserRequest {
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
private String email;
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 50, message = "Le nom doit contenir entre 2 et 50 caractères")
private String name;
@Min(value = 18, message = "Vous devez avoir au moins 18 ans")
@Max(value = 120, message = "L'âge ne peut pas dépasser 120 ans")
private Integer age;
}Annotations de validation courantes
| Annotation | Usage |
|---|---|
@NotNull | Le champ ne doit pas être null |
@NotBlank | Pour les String : pas null, pas vide, pas que des espaces |
@NotEmpty | Pour les collections : pas null et pas vide |
@Email | Valide le format email |
@Size(min=, max=) | Taille min/max (String, List, etc.) |
@Min(value) | Valeur minimale (nombre) |
@Max(value) | Valeur maximale (nombre) |
@Pattern(regexp) | Valide avec une regex |
@Past | Date dans le passé |
@Future | Date dans le futur |
Utiliser @Valid dans le controller
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
// Si la validation échoue, une exception est lancée automatiquement
UserDTO user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}L'annotation @Valid dit à Spring : "Avant d'exécuter cette méthode, valide l'objet request. Si invalide, lance une exception."
Gérer les erreurs de validation
Quand la validation échoue, Spring lance une MethodArgumentNotValidException. Il faut la gérer dans le @ControllerAdvice.
Gestionnaire simple
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
// Récupérer la première erreur de validation
String errorMessage = ex.getBindingResult()
.getAllErrors()
.get(0)
.getDefaultMessage();
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
errorMessage,
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}Résultat (si l'email est invalide) :
{
"status": 400,
"message": "Format d'email invalide",
"timestamp": "2026-01-29T10:30:00"
}Gestionnaire avancé : retourner toutes les erreurs
Pour une meilleure UX, renvoyez toutes les erreurs de validation :
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("errors", errors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}Résultat :
{
"status": 400,
"errors": {
"email": "Format d'email invalide",
"name": "Le nom est obligatoire",
"age": "Vous devez avoir au moins 18 ans"
},
"timestamp": "2026-01-29T10:30:00"
}Avantage : Le client voit toutes les erreurs d'un coup, il peut toutes les corriger en une seule fois.
Bonnes pratiques et cas d'usage
✅ Bonnes pratiques
1. Codes HTTP appropriés
Utilisez les bons codes HTTP selon le type d'erreur :
| Code | Signification | Quand l'utiliser | Impact monitoring |
|---|---|---|---|
| 400 Bad Request | Données invalides | Validation échouée | Normal, pas d'alerte |
| 401 Unauthorized | Non authentifié | Token manquant/invalide | Alerte si pic soudain (attaque?) |
| 403 Forbidden | Non autorisé | Pas les permissions | Audit : qui tente d'accéder où? |
| 404 Not Found | Ressource introuvable | ID inexistant | Normal, sauf si 50% des requêtes |
| 409 Conflict | Conflit | Email déjà utilisé | Peut indiquer un bug frontend |
| 422 Unprocessable Entity | Erreur métier | Solde insuffisant | Métrique business importante |
| 500 Internal Server Error | Erreur serveur | Exception technique | Alerte critique immédiate |
| 503 Service Unavailable | Service indisponible | Base de données down | Alerte critique + escalade |
Pourquoi c'est critique en production :
- Vos dashboards Grafana/Datadog filtrent par code HTTP
- Les SLA se basent sur le taux de 5xx (erreurs serveur)
- Les 4xx (erreurs client) ne comptent pas dans votre SLO d'uptime
Exemple de métrique SRE :
Uptime SLO = (requêtes non-5xx) / (total requêtes) >= 99.9%
Si vous renvoyez 500 au lieu de 400 pour une validation ratée, votre SLO s'effondre artificiellement.
Exemple :
// Utilisateur non trouvé : 404
throw new ResourceNotFoundException("Utilisateur non trouvé");
// Email déjà utilisé : 409
throw new ConflictException("Email déjà utilisé");
// Solde insuffisant pour payer : 422
throw new BusinessException("Solde insuffisant");2. Messages clairs et actionnables
Mauvais :
throw new ResourceNotFoundException("Error"); // Pas clairBon :
throw new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id);Impact côté support et exploitation
Une gestion d'erreurs approximative a un coût très concret en production.
Lorsqu'une API retourne un message générique (500 Internal Server Error sans contexte), le client n'a souvent qu'une option : ouvrir un ticket.
Côté support, cela implique généralement :
- Analyse du contexte
- Reproduction partielle
- Échange avec l'équipe technique
À l'inverse, une erreur métier claire et stable (code HTTP, message explicite, action attendue) permet souvent au client de corriger lui-même son appel, sans intervention humaine.
En pratique, la majorité des tickets liés aux erreurs API ne concernent pas des bugs, mais un manque d'information : paramètres manquants, format invalide, règle métier violée.
Une gestion d'exceptions propre ne réduit pas le nombre d'erreurs — elle réduit le nombre de tickets inutiles.
3. Logger les erreurs avec le bon niveau
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.warn("Ressource non trouvée : {}", ex.getMessage()); // WARN, pas ERROR
return ResponseEntity.status(404).body(new ErrorResponse(404, ex.getMessage()));
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
log.info("Tentative de duplication : {}", ex.getMessage()); // INFO, pas ERROR
return ResponseEntity.status(409).body(new ErrorResponse(409, ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Erreur inattendue", ex); // ERROR avec stack trace complète
// Mais renvoie un message générique au client
ErrorResponse error = new ErrorResponse(
500,
"Une erreur interne est survenue",
LocalDateTime.now()
);
return ResponseEntity.status(500).body(error);
}Pourquoi c'est important ?
Les niveaux de log ont un impact direct sur l'exploitation :
- ERROR : Déclenche généralement une alerte PagerDuty. En production, cela peut réveiller l'astreinte à 3h du matin.
- WARN : Visible dans les dashboards mais ne déclenche pas d'alerte immédiate.
- INFO : Utilisé pour les métriques business, sans alerte.
Si tout est en ERROR, vous créez de l'alerting fatigue : les vraies erreurs critiques se noient dans le bruit.
Règle :
- 404, 409 = comportement normal → WARN ou INFO
- 500, 503 = problème technique → ERROR
4. Ne jamais exposer les détails techniques
Mauvais :
{
"error": "java.sql.SQLException: Connection refused (Connection refused)"
}Bon :
{
"status": 503,
"message": "Le service est temporairement indisponible. Veuillez réessayer plus tard."
}Impact sécurité :
- L'attaquant sait que vous utilisez SQL → peut tenter des injections
- L'attaquant sait que la DB est down → peut lancer une attaque DDoS pour prolonger l'incident
Impact légal (RGPD) : Si l'exception contient des données personnelles (ex: "User john.doe@company.com not found"), c'est une violation RGPD.
5. Centraliser la gestion des erreurs
Impact sur la maintenance :
La centralisation avec @ControllerAdvice réduit drastiquement la surface de modification.
Sans @ControllerAdvice :
- Modification du format d'erreur = autant de fichiers à modifier que de controllers
- Risque d'oubli = comportement incohérent entre endpoints
- Tests à dupliquer partout
Avec @ControllerAdvice :
- Modification = 1 seul fichier
- Cohérence garantie par construction
- Tests centralisés
Sur un projet de taille moyenne, cela représente la différence entre toucher 1 fichier ou 50+ fichiers pour une évolution du error handling.
❌ Erreurs à éviter
1. Utiliser les checked exceptions
Mauvais :
public class ResourceNotFoundException extends Exception { ... } // CheckedVous serez obligé de déclarer throws ResourceNotFoundException partout.
Bon :
public class ResourceNotFoundException extends RuntimeException { ... } // UncheckedImpact en production : Les checked exceptions polluent les signatures de méthodes, rendent le code difficile à refactorer et ne s'intègrent pas bien avec les Lambdas et Streams Java.
2. Renvoyer toujours 200 OK avec un champ "error"
Mauvais :
// HTTP 200 OK
{
"success": false,
"error": "Utilisateur non trouvé"
}Bon :
// HTTP 404 Not Found
{
"status": 404,
"message": "Utilisateur non trouvé"
}Pourquoi c'est critique : Les proxies, load balancers et monitoring tools (Datadog, New Relic) se basent sur les codes HTTP pour détecter les erreurs. Un 200 OK avec une erreur dedans = vos alertes ne se déclenchent jamais = incidents non détectés.
Les codes HTTP existent pour ça, utilisez-les !
3. Oublier le @ControllerAdvice
Sans @ControllerAdvice, chaque controller doit gérer ses propres erreurs. C'est de la duplication.
Avec @ControllerAdvice, c'est centralisé et cohérent.
Impact maintenance : Sans centralisation, une modification du format d'erreur nécessite de toucher à 50+ controllers. Avec @ControllerAdvice, c'est un seul fichier à modifier.
4. Exposer les stack traces en production
Erreur fréquente en production :
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
// 🚨 DANGER : expose les détails internes
return ResponseEntity.status(500).body(new ErrorResponse(500, ex.getMessage()));
}Pourquoi c'est dangereux :
ex.getMessage()peut contenir des infos sensibles (chemin fichier, requête SQL, credentials)- Permet à un attaquant de cartographier votre stack technique
- Violation RGPD si des données personnelles sont dans l'exception
Solution :
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Erreur inattendue", ex); // Log complet côté serveur
// Message générique au client
ErrorResponse error = new ErrorResponse(
500,
"Une erreur interne est survenue. Veuillez réessayer plus tard."
);
return ResponseEntity.status(500).body(error);
}5. Ne pas logger les erreurs métier
Mauvais :
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
// Aucun log
return ResponseEntity.status(404).body(new ErrorResponse(404, ex.getMessage()));
}Bon :
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.warn("Ressource non trouvée : {}", ex.getMessage()); // Log pour analyse
return ResponseEntity.status(404).body(new ErrorResponse(404, ex.getMessage()));
}Pourquoi c'est important : En production, ces logs permettent de détecter :
- Des tentatives de brute-force sur les IDs (beaucoup de 404 sur
/users/{id}) - Des bugs frontend qui envoient de mauvais IDs
- Des comportements utilisateurs inattendus
Sans ces logs, difficile d'analyser les tendances d'erreur et d'identifier les axes d'amélioration.
6. Valider côté controller au lieu d'utiliser @Valid
Mauvais (validation manuelle) :
@PostMapping
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
if (request.getEmail() == null || request.getEmail().isBlank()) {
throw new ValidationException("Email obligatoire");
}
if (!request.getEmail().contains("@")) {
throw new ValidationException("Email invalide");
}
// ... 20 lignes de validation
return userService.createUser(request);
}Bon (validation déclarative) :
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.createUser(request);
}Trade-off :
- Avantage : Code 10x plus concis, validation centralisée dans le DTO, réutilisable
- Inconvénient : Validation moins flexible pour des règles métier complexes (ex : "email doit être unique")
Quand utiliser de la validation manuelle : Pour des règles métier qui nécessitent l'accès à la base de données ou à des services externes.
7. Utiliser @ResponseStatus au lieu de @ControllerAdvice
Approche limitée :
@ResponseStatus(HttpStatus.NOT_FOUND) // Fixe le code HTTP
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}Problèmes :
- Impossible de personnaliser la réponse JSON (toujours le format par défaut de Spring)
- Pas de logging centralisé
- Pas de gestion de plusieurs types d'erreurs similaires
Pourquoi @ControllerAdvice est meilleur :
- Format de réponse personnalisé
- Logging centralisé
- Gestion contextuelle (ex : réponse différente selon l'environnement)
Anti-patterns de production : cas réels
Cas 1 : L'exception silencieuse qui coûte cher
Situation : Une API de paiement qui attrape toutes les exceptions et renvoie 200 OK.
@PostMapping("/payment")
public ResponseEntity<PaymentResponse> processPayment(@RequestBody PaymentRequest request) {
try {
Payment payment = paymentService.process(request);
return ResponseEntity.ok(new PaymentResponse(true, payment.getId()));
} catch (Exception e) {
// 🚨 ERREUR CRITIQUE
return ResponseEntity.ok(new PaymentResponse(false, null));
}
}Conséquences réelles :
- Les erreurs de paiement ne sont pas loggées
- Le monitoring ne détecte rien (code 200)
- Les clients pensent que le paiement a échoué mais ne savent pas pourquoi
- Perte de ventes potentielle et tickets support inutiles
Solution :
@PostMapping("/payment")
public ResponseEntity<PaymentResponse> processPayment(@Valid @RequestBody PaymentRequest request) {
Payment payment = paymentService.process(request); // Laisse les exceptions remonter
return ResponseEntity.ok(new PaymentResponse(true, payment.getId()));
}
// Dans @ControllerAdvice
@ExceptionHandler(PaymentException.class)
public ResponseEntity<ErrorResponse> handlePaymentError(PaymentException ex) {
log.error("Erreur de paiement : {}", ex.getMessage(), ex);
ErrorResponse error = new ErrorResponse(
HttpStatus.PAYMENT_REQUIRED.value(),
"Le paiement n'a pas pu être traité. Vérifiez vos informations bancaires."
);
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(error);
}Cas 2 : L'exception qui révèle trop
Situation : Une API qui expose des détails sensibles dans les messages d'erreur.
// 🚨 DANGER
throw new RuntimeException("Erreur SQL : duplicate key value violates unique constraint \"users_email_key\"");Ce que voit l'attaquant :
- Vous utilisez PostgreSQL (constraint naming convention)
- Votre table s'appelle
users - Vous avez une colonne
emailunique - Il peut maintenant tenter des injections SQL ciblées
Solution :
// Exception métier abstraite
throw new ConflictException("Un compte existe déjà avec cet email");
// Et on log les détails côté serveur uniquement
log.error("Duplicate email violation for user registration", sqlException);Cas 3 : La validation insuffisante qui ouvre une faille
Situation : Validation uniquement côté frontend.
// Pas de @Valid
@PostMapping("/users")
public User createUser(@RequestBody CreateUserRequest request) {
return userService.createUser(request);
}Conséquence : Un attaquant peut bypasser le frontend et envoyer :
{
"email": "",
"name": "<script>alert('XSS')</script>",
"age": -1
}Impact :
- XSS si le nom est affiché sans échappement
- Données corrompues en base
- Erreurs métier en cascade
Solution : TOUJOURS valider côté backend avec @Valid, même si vous avez de la validation frontend.
Trade-offs : quand NE PAS utiliser cette approche
Alternative 1 : Problem Details for HTTP APIs (RFC 7807)
Notre approche :
{
"status": 400,
"message": "Email invalide",
"timestamp": "2026-01-29T10:30:00"
}RFC 7807 (standard) :
{
"type": "https://example.com/probs/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Email invalide",
"instance": "/api/users/123"
}Trade-off :
- Avantage RFC 7807 : Standard reconnu, support tooling (Swagger UI, Postman)
- Inconvénient : Plus verbeux, nécessite Spring Boot 3.0+ avec ProblemDetail
- Notre approche : Simple, rapide à implémenter, compatible toutes versions
Quand utiliser RFC 7807 : Si vous construisez une API publique avec beaucoup de consommateurs externes.
Alternative 2 : Exceptions métier avec état
Notre approche : Exceptions simples sans état.
Alternative : Exceptions riches avec contexte.
public class ValidationException extends RuntimeException {
private final Map<String, String> errors;
public ValidationException(Map<String, String> errors) {
super("Validation failed");
this.errors = errors;
}
public Map<String, String> getErrors() {
return errors;
}
}Trade-off :
- Avantage : Contexte riche directement dans l'exception
- Inconvénient : Exceptions plus complexes, couplage avec le format de réponse
Quand l'utiliser : Si vos exceptions transportent beaucoup de métadonnées (multi-erreurs, codes d'erreur custom).
Pour aller plus loin
Vous savez maintenant gérer les erreurs comme un pro ! Voici les prochaines étapes :
📚 Série d'articles complémentaires
- Comment structurer un projet Spring Boot - Où placer vos exceptions
- Tests dans Spring Boot : Guide complet - Tester vos gestionnaires d'erreur
- Configuration multi-environnements avec Spring Boot - Logs différents selon l'environnement
- CRUD complet avec Spring Boot - Exemple avec gestion d'erreur complète
- Spring Boot en production : Checklist - Monitoring des erreurs
🎯 Points clés à retenir
- @ControllerAdvice : Gestion centralisée des erreurs
- Exceptions personnalisées :
ResourceNotFoundException,ConflictException, etc. - Codes HTTP appropriés : 400, 404, 409, 500, etc.
- Messages clairs : Aidez le client à comprendre l'erreur
- Validation avec @Valid : Laissez Spring valider automatiquement
- Ne jamais exposer les détails techniques : Sécurité avant tout
Une bonne gestion d'erreur, c'est ce qui fait la différence entre une API amateur et une API professionnelle. 🚀
Bonne gestion d'erreurs ! ⚠️