Exceptions : lancer, laisser remonter, traiter une seule fois
L'idée en une phrase
Une exception signale qu'une opération n'a pas pu se faire. Ton rôle n'est pas de toutes les attraper : c'est de lancer la bonne, avec un message clair, et de la laisser remonter jusqu'à l'endroit qui sait la traiter. Dans une application Spring Boot, cet endroit est presque toujours un gestionnaire global.
Une image pour comprendre : l'escalade d'un problème
Dans une entreprise, quand un employé rencontre un problème qu'il ne peut pas régler, il ne le cache pas dans un tiroir : il le fait remonter à la personne qui a le pouvoir de décider. Et il transmet le dossier complet, pour qu'on comprenne ce qui s'est passé.
Avec les exceptions, c'est pareil :
- lancer une exception, c'est signaler le problème ;
- elle remonte d'appel en appel (repository → service → contrôleur) ;
- un seul endroit la traite et répond à l'utilisateur ;
- la cause d'origine voyage avec elle, comme le dossier.
Les deux fautes classiques découlent de cette image : cacher le problème (un catch vide) et perdre le dossier (oublier la cause).
Le pire réflexe : le catch vide
public String chargerModele(Path chemin) {
try {
return Files.readString(chemin);
} catch (IOException e) {
// rien
}
return null; // ❌
}
Le jour où le fichier manque, l'erreur disparaît. Le null remonte, et l'application plante trois classes plus loin sur une NullPointerException qui n'a rien à voir. La vraie cause (fichier absent, droits insuffisants) est perdue : tu peux chercher des heures.
Une erreur cachée est bien pire qu'une erreur visible.
Deux familles d'exceptions
Throwable
├── Error problème grave de la JVM : on ne l'attrape pas
└── Exception "checked" : le compilateur t'oblige à la gérer
└── RuntimeException "unchecked" : rien n'est obligatoire
| Checked | Unchecked | |
|---|---|---|
| Hérite de | Exception |
RuntimeException |
| Le compilateur t'oblige à la gérer ? | oui : try/catch ou throws |
non |
| Exemples | IOException, SQLException |
NullPointerException, IllegalArgumentException |
Spring, JPA et la plupart des bibliothèques récentes utilisent presque uniquement des exceptions unchecked. Dans ton code Spring Boot, fais de même : tes exceptions héritent de RuntimeException.
Pourquoi ? Parce que la plupart des erreurs ne peuvent pas être réglées là où elles apparaissent. Elles doivent remonter jusqu'au gestionnaire global. Une exception checked obligerait chaque méthode traversée à la déclarer (throws ...) sans rien en faire.
Lancer la bonne exception
Les exceptions de Java
Pour signaler une mauvaise utilisation de ton code, Java fournit déjà ce qu'il faut :
| Situation | Exception |
|---|---|
| un argument reçu est invalide | IllegalArgumentException |
| l'objet n'est pas dans le bon état pour cette opération | IllegalStateException |
public void annuler(Commande commande) {
if (commande.statut() == Statut.EXPEDIEE) {
throw new IllegalStateException(
"Impossible d'annuler la commande " + commande.id() + " : déjà expédiée"); // ✅
}
// ...
}
Tes exceptions métier
Pour une situation propre à ton application (« commande introuvable », « stock insuffisant »), crée une exception dédiée. Son nom dit déjà ce qui s'est passé :
public class CommandeIntrouvableException extends RuntimeException {
public CommandeIntrouvableException(Long id) {
super("Commande introuvable : " + id);
}
}
public Commande trouver(Long id) {
return commandeRepository.findById(id)
.orElseThrow(() -> new CommandeIntrouvableException(id)); // ✅
}
Astuce : un bon message répond à la question « qu'est-ce qui n'a pas marché, et sur quoi ? ». « Commande introuvable : 42 » aide à corriger ; « Erreur » n'aide personne.
Traiter au bon endroit : le gestionnaire global
Dans un contrôleur, pas de try/catch : laisse l'exception remonter. Une seule classe, annotée @RestControllerAdvice, transforme chaque type d'exception en réponse HTTP :
@RestControllerAdvice
public class GestionnaireErreurs {
private static final Logger log = LoggerFactory.getLogger(GestionnaireErreurs.class);
@ExceptionHandler(CommandeIntrouvableException.class)
public ProblemDetail introuvable(CommandeIntrouvableException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); // 404
}
@ExceptionHandler(IllegalStateException.class)
public ProblemDetail etatInvalide(IllegalStateException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); // 409
}
@ExceptionHandler(Exception.class)
public ProblemDetail inattendue(Exception e) {
log.error("Erreur inattendue", e); // ✅ la trace complète va dans les logs…
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Une erreur est survenue"); // …pas chez l'utilisateur
}
}
Le contrôleur reste simple :
@GetMapping("/commandes/{id}")
public CommandeDto afficher(@PathVariable Long id) {
return CommandeDto.depuis(commandeService.trouver(id)); // si introuvable → 404, sans try/catch
}
Note :
ProblemDetail(Spring 6 / Spring Boot 3) produit une réponse d'erreur au format standard, que le front-end sait lire. Pour les erreurs inattendues, ne renvoie jamais le message technique ou la trace au client : elles peuvent révéler des informations sur ton application.
Le piège de @Transactional et des exceptions checked
Par défaut, @Transactional annule la transaction (rollback) uniquement pour les exceptions unchecked. Pour une exception checked, la transaction est… validée :
@Transactional
public void importer(Path fichier) throws IOException {
commandeRepository.save(premiereCommande);
Files.readString(fichier); // lance une IOException (checked)
// ⚠️ la première commande est quand même enregistrée en base
}
C'est une raison de plus pour que tes exceptions héritent de RuntimeException. Si une exception checked doit traverser une méthode transactionnelle, enveloppe-la (section suivante).
Quand une exception checked t'est imposée : l'envelopper
Certaines méthodes de Java lancent des exceptions checked (lecture de fichier, par exemple). Si tu ne peux rien en faire, enveloppe-la dans une exception unchecked, en gardant la cause :
public String chargerModele(Path chemin) {
try {
return Files.readString(chemin);
} catch (IOException e) {
throw new ModeleIllisibleException("Modèle illisible : " + chemin, e); // ✅ e = la cause
}
}
public class ModeleIllisibleException extends RuntimeException {
public ModeleIllisibleException(String message, Throwable cause) {
super(message, cause); // ✅ la trace d'origine est conservée
}
}
Dans les logs, tu verras alors ton message et la cause d'origine (« fichier introuvable », « accès refusé »). C'est le dossier complet de l'escalade.
throw new ModeleIllisibleException(e.getMessage()); // ❌ la cause et sa trace sont perdues
Fermer les ressources : try-with-resources
Quand tu ouvres une ressource (un fichier, un flux), elle doit être fermée, même en cas d'erreur. Le try (...) le fait pour toi :
try (BufferedReader lecteur = Files.newBufferedReader(chemin)) { // ✅ fermé automatiquement
return lecteur.readLine();
}
En Spring Boot, la plupart des ressources (connexions à la base, par exemple) sont gérées par le framework : tu en auras surtout besoin pour les fichiers.
Les bonnes habitudes
- Jamais de
catchvide. Si tu ne sais pas quoi faire d'une exception, ne l'attrape pas, ou enveloppe-la. - Pas de
try/catchdans les contrôleurs. Laisse remonter jusqu'au@RestControllerAdvice. - Tes exceptions héritent de
RuntimeException, ont un nom explicite et un message qui dit quoi et sur quoi. - Garde toujours la cause quand tu enveloppes :
new MonException(message, e). - Ne renvoie pas
nullpour signaler une erreur. Lance une exception, ou renvoie unOptionalsi l'absence est un cas normal. - Ne lance pas
new Exception("erreur"). Le type ne dit rien et oblige tout le monde à l'attraper. - Écris l'erreur dans les logs une seule fois, là où elle est traitée. Faire
log.error(...)puis relancer à chaque couche affiche la même erreur cinq fois. - N'attrape pas
Exceptionpartout. Seul le gestionnaire global a une bonne raison de le faire.
À retenir
- Une erreur cachée (
catchvide,return null) est bien pire qu'une erreur visible. - Checked : le compilateur t'oblige à la gérer. Unchecked (
RuntimeException) : rien d'obligatoire. En Spring Boot, utilise des unchecked. - Lance une exception précise avec un message clair, laisse-la remonter, et traite-la une seule fois dans un
@RestControllerAdvice. @Transactionaln'annule pas la transaction pour une exception checked.- Quand tu enveloppes une exception, passe toujours la cause au constructeur.