Exceptions : lancer, laisser remonter, traiter une seule fois

Publié le · Mis à jour le

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

  1. Jamais de catch vide. Si tu ne sais pas quoi faire d'une exception, ne l'attrape pas, ou enveloppe-la.
  2. Pas de try/catch dans les contrôleurs. Laisse remonter jusqu'au @RestControllerAdvice.
  3. Tes exceptions héritent de RuntimeException, ont un nom explicite et un message qui dit quoi et sur quoi.
  4. Garde toujours la cause quand tu enveloppes : new MonException(message, e).
  5. Ne renvoie pas null pour signaler une erreur. Lance une exception, ou renvoie un Optional si l'absence est un cas normal.
  6. Ne lance pas new Exception("erreur"). Le type ne dit rien et oblige tout le monde à l'attraper.
  7. É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.
  8. N'attrape pas Exception partout. Seul le gestionnaire global a une bonne raison de le faire.

À retenir

  • Une erreur cachée (catch vide, 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.
  • @Transactional n'annule pas la transaction pour une exception checked.
  • Quand tu enveloppes une exception, passe toujours la cause au constructeur.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note