Gestion des exceptions dans Spring Boot : Guide complet

21 min readPar SpringCraft

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 :

  1. 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)
  2. Conformité légale : Le RGPD exige de ne pas exposer d'informations sensibles dans les logs ou réponses
  3. 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

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 :

  1. Sécurité : Vous exposez la structure interne de votre code
  2. UX : Le client ne sait pas quoi faire de cette erreur
  3. 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 avec try-catch ou throws
  • 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 :

  1. Une exception est lancée dans un controller ou un service
  2. Spring cherche un @ExceptionHandler qui correspond
  3. La méthode du handler est appelée
  4. Elle renvoie une ResponseEntity au 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

AnnotationUsage
@NotNullLe champ ne doit pas être null
@NotBlankPour les String : pas null, pas vide, pas que des espaces
@NotEmptyPour les collections : pas null et pas vide
@EmailValide 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
@PastDate dans le passé
@FutureDate 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 :

CodeSignificationQuand l'utiliserImpact monitoring
400 Bad RequestDonnées invalidesValidation échouéeNormal, pas d'alerte
401 UnauthorizedNon authentifiéToken manquant/invalideAlerte si pic soudain (attaque?)
403 ForbiddenNon autoriséPas les permissionsAudit : qui tente d'accéder où?
404 Not FoundRessource introuvableID inexistantNormal, sauf si 50% des requêtes
409 ConflictConflitEmail déjà utiliséPeut indiquer un bug frontend
422 Unprocessable EntityErreur métierSolde insuffisantMétrique business importante
500 Internal Server ErrorErreur serveurException techniqueAlerte critique immédiate
503 Service UnavailableService indisponibleBase de données downAlerte 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 clair

Bon :

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 { ... }  // Checked

Vous serez obligé de déclarer throws ResourceNotFoundException partout.

Bon :

public class ResourceNotFoundException extends RuntimeException { ... }  // Unchecked

Impact 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 email unique
  • 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

  1. Comment structurer un projet Spring Boot - Où placer vos exceptions
  2. Tests dans Spring Boot : Guide complet - Tester vos gestionnaires d'erreur
  3. Configuration multi-environnements avec Spring Boot - Logs différents selon l'environnement
  4. CRUD complet avec Spring Boot - Exemple avec gestion d'erreur complète
  5. Spring Boot en production : Checklist - Monitoring des erreurs

🎯 Points clés à retenir

  1. @ControllerAdvice : Gestion centralisée des erreurs
  2. Exceptions personnalisées : ResourceNotFoundException, ConflictException, etc.
  3. Codes HTTP appropriés : 400, 404, 409, 500, etc.
  4. Messages clairs : Aidez le client à comprendre l'erreur
  5. Validation avec @Valid : Laissez Spring valider automatiquement
  6. 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 ! ⚠️