Exceptions personnalisées en Java
Définissez vos propres classes d'exceptions en Java en étendant Exception ou RuntimeException pour gérer les erreurs spécifiques à votre domaine.
Les exceptions intégrées couvrent la plupart des échecs généraux, mais elles ne connaissent rien de votre domaine. Quand quelque chose échoue d'une façon spécifique à votre code — "utilisateur introuvable", "coupon invalide", "configuration désynchronisée" — la bonne approche est généralement de définir votre propre type d'exception. Les exceptions personnalisées coûtent peu à écrire, rendent les traces de pile explicites et permettent aux appelants de gérer précisément l'échec qui les concerne.
La forme minimale
Une exception personnalisée est une classe qui étend Exception (ou l'une de ses sous-classes). La version minimale utile :
public class UserNotFoundException extends Exception {
public UserNotFoundException(String message) {
super(message);
}
}C'est une exception personnalisée, vérifiée et complète. Vous pouvez écrire throw new UserNotFoundException("id=42") depuis n'importe où, et les appelants peuvent utiliser catch (UserNotFoundException e).
Vérifiée ou non vérifiée ?
La décision la plus importante lors de la définition d'une classe d'exception : que faut-il étendre ?
extends Exception→ vérifiée. Le compilateur oblige les appelants à la gérer ou à la déclarer.extends RuntimeException→ non vérifiée. Les appelants peuvent la gérer, mais n'y sont pas obligés.
La même logique que pour les exceptions vérifiées et non vérifiées s'applique : étendez Exception quand les appelants peuvent raisonnablement se remettre de l'erreur et que vous voulez les forcer à y réfléchir ; étendez RuntimeException quand l'échec représente un bug ou une condition dont aucun appelant ne peut se remettre.
Pour les exceptions de domaine dans le code Java moderne, RuntimeException est le choix le plus courant — en partie parce que les exceptions vérifiées ne se composent pas bien avec les flux et les lambdas, et en partie parce que la plupart des échecs de domaine remontent de toute façon vers un seul gestionnaire de haut niveau. Commencez par RuntimeException sauf si vous avez une raison spécifique de forcer la gestion.
Les quatre constructeurs
Par convention, une classe d'exception fournit les mêmes quatre constructeurs que les classes intégrées :
public class ConfigLoadException extends RuntimeException {
public ConfigLoadException() {
super();
}
public ConfigLoadException(String message) {
super(message);
}
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
public ConfigLoadException(Throwable cause) {
super(cause);
}
}Pourquoi les quatre :
- Sans argument — pour les outils et frameworks qui utilisent la réflexion sur la classe.
- Message seul — le cas courant dans votre propre code.
- Message + cause — pour encapsuler une exception de bas niveau. Le plus important à inclure.
- Cause seule — quand le message de la cause est déjà suffisamment descriptif.
Vous n'avez pas besoin de saisir les quatre à chaque fois — les IDE les génèrent en une touche — mais omettre les formes portant la cause est une vraie perte. Sans elles, vous ne pouvez pas préserver l'exception sous-jacente lors de l'encapsulation.
Transporter un état utile
Les chaînes de caractères conviennent, mais des champs personnalisés sont préférables. Si l'appelant pourrait vouloir savoir quel utilisateur est introuvable, exposez-le :
public class UserNotFoundException extends RuntimeException {
private final String userId;
public UserNotFoundException(String userId) {
super("user not found: " + userId);
this.userId = userId;
}
public String getUserId() { return userId; }
}Maintenant un bloc catch peut agir sur l'échec plutôt que de simplement analyser le message :
catch (UserNotFoundException e) {
metrics.recordMissingUser(e.getUserId());
return Response.notFound();
}Gardez les champs immuables (final) et le constructeur minimal. Les exceptions sont construites sur le chemin d'échec — elles doivent être rapides et ne jamais lever elles-mêmes d'exception.
Encapsuler avec une cause
La technique la plus utile avec les exceptions personnalisées est de traduire une exception de bas niveau en une exception de domaine, en préservant l'original :
public Config load(Path p) {
try {
return parser.parse(Files.readString(p));
} catch (IOException e) {
throw new ConfigLoadException("could not read " + p, e);
} catch (ParseException e) {
throw new ConfigLoadException("invalid config in " + p, e);
}
}L'appelant voit un type d'exception unique qui correspond au vocabulaire de sa couche. L'échec original n'est pas perdu — il est accessible via getCause() et apparaît dans printStackTrace() sous une ligne Caused by:.
C'est ainsi que vous gardez les couches séparées. L'API Config ne laisse pas fuir IOException ou ParseException ; les deux se traduisent en quelque chose qui signifie "le chargement de la configuration a échoué".
Une petite hiérarchie
Quand vous avez une famille d'échecs liés, donnez-leur un parent commun :
public class PaymentException extends RuntimeException {
public PaymentException(String message) { super(message); }
public PaymentException(String message, Throwable c) { super(message, c); }
}
public class CardDeclinedException extends PaymentException {
public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
public FraudCheckFailedException(String message) { super(message); }
}Les appelants peuvent être précis (catch (CardDeclinedException)) ou généraux (catch (PaymentException)) selon les besoins. Un parent commun vous donne également une seule importation à placer dans une clause throws quand la méthode peut lever l'une d'entre elles.
Ce qu'il faut éviter
- N'étendez pas
ThrowableouErrordirectement. Passez toujours parExceptionouRuntimeException. - Ne surchargez pas
getMessage()pour calculer des chaînes à chaque appel. Construisez le message dans le constructeur et laissez la classe parente le stocker. - Ne mettez pas de logique dans l'exception. Elle existe pour transporter de l'information. La récupération appartient au catch.
- Ne proliférez pas. Chaque nouveau type d'exception est un petit contrat que les appelants pourraient vouloir gérer. Si deux échecs demandent exactement le même traitement, ils veulent probablement être du même type.
Un exemple complet
Un petit module de traitement des commandes avec sa propre famille d'exceptions. La classe de base encapsule les échecs de bas niveau ; les sous-classes transportent des détails de domaine ; le programme principal les attrape à différents niveaux de précision pour montrer comment la hiérarchie vous laisse choisir.
Le programme principal attrape EmptyOrderException en premier (le cas spécifique qu'il veut gérer différemment), puis OrderException comme attrape-tout pour la famille. Quand la validation échoue, la chaîne de causes remonte jusqu'à l'IllegalStateException originale, de sorte que vous ne perdez pas d'information lors de la traduction vers le type de domaine.
Et ensuite ?
Vous disposez maintenant de tous les mécanismes. Le chapitre final traite du jugement — quand lever une exception, quand l'attraper, quoi enregistrer, et les modèles qui distinguent un code de gestion des exceptions mature du bruit défensif. Continuez vers les bonnes pratiques de gestion des exceptions en Java.