Exception en Java : checked, unchecked et conception

1. Aperçu

Exception est un mécanisme Java pour signaler qu’une opération ne s’est pas déroulée comme prévu. Nombreux sont les projets qui utilisent les exceptions de manière incohérente. Il y a des endroits où des throws Exception prolifèrent. D’autres où les erreurs sont silencieuses dans un bloc catch vide.

Dans cet article, vous comprendrez la différence entre les exceptions checked et unchecked. Vous saurez également quand utiliser l’une ou l’autre et comment concevoir un système d’exceptions personnalisé pour votre application. En fin d’article, il y a une section sur les tests du comportement d’exception avec JUnit 5 et AssertJ.

2. La hiérarchie Throwable

Tout ce qui peut être lancé avec throw hérite de java.lang.Throwable. L’arbre de hiérarchie réduit ressemble à ceci :

Throwable
├── Error                      (unchecked) – erreur grave de la JVM
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception                  (checked)
    ├── IOException
    ├── SQLException
    └── RuntimeException       (unchecked)
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── NullPointerException

La règle de classification est très simple :

  • Exception checked est Exception et ses sous-classes, sauf la branche RuntimeException. Le compilateur vous force à gérer ce type.
  • Exception unchecked est RuntimeException, Error et leurs sous-classes. Le compilateur ne vous force pas à les gérer.

Vous ne devriez pratiquement jamais capturer Error. Quand la JVM manque de mémoire, l’application peut rarement se rétablir.

3. Exception checked

Une exception checked fait partie du « contrat » d’une méthode. Quand une méthode déclare throws, le code appelant doit choisir entre deux approches. La première est de capturer l’erreur avec try/catch. La seconde est de déclarer throws à nouveau vers le haut.

Considérez une passerelle de paiement qui peut refuser une transaction. Cette situation est tout à fait possible et le code appelant doit décider de la suite :

public class PaymentDeclinedException extends Exception {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("Paiement refusé : " + reason);
        this.reason = reason;
    }

    public String getReason() {
        return reason;
    }
}

public class PaymentGateway {
    public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
        if (amount > 10_000_000) {
            throw new PaymentDeclinedException("dépassement de limite");
        }
        System.out.println("Débité " + amount + " de la carte " + cardNumber);
    }
}

PaymentDeclinedException hérite de Exception, donc c’est une exception checked. Si vous appelez charge(...) sans gérer l’erreur, le compilateur signalera :

error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown

Le code appelant est forcé de considérer l’échec :

public class CheckoutService {
    private final PaymentGateway gateway = new PaymentGateway();

    public String checkout(String card, long amount) {
        try {
            gateway.charge(card, amount);
            return "SUCCESS";
        } catch (PaymentDeclinedException e) {
            System.out.println("Raison : " + e.getReason());
            return "DECLINED";
        }
    }

    public static void main(String[] args) {
        var service = new CheckoutService();
        System.out.println(service.checkout("4111-xxxx", 50_000));
        System.out.println(service.checkout("4111-xxxx", 20_000_000));
    }
}

Sortie :

Débité 50000 de la carte 4111-xxxx
SUCCESS
Raison : dépassement de limite
DECLINED

Ici, l’erreur est transformée en un état métier (DECLINED). C’est le type de situation où l’exception checked fonctionne bien.

4. Exception unchecked

Une exception unchecked signale généralement une erreur de programmation ou une violation d’une condition préalable. Par exemple, passer null, passer un nombre négatif ou appeler une méthode quand l’objet est dans le mauvais état.

public class Order {
    private final long id;
    private int quantity;
    private boolean shipped;

    public Order(long id, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity doit être > 0, reçu : " + quantity);
        }
        this.id = id;
        this.quantity = quantity;
    }

    public void changeQuantity(int newQuantity) {
        if (shipped) {
            throw new IllegalStateException("Commande " + id + " est expédiée, impossible de modifier la quantité");
        }
        this.quantity = newQuantity;
    }

    public void ship() {
        this.shipped = true;
    }
}

Le constructeur lève IllegalArgumentException quand l’entrée n’est pas valide. La méthode changeQuantity lève IllegalStateException quand la commande est expédiée. Personne ne devrait « capturer et traiter » ces erreurs à chaque appel. La bonne façon est de corriger le code qui les cause.

Vous devriez réutiliser les exceptions du JDK avant de créer vos propres classes :

Situation Exception à utiliser
Paramètre invalide IllegalArgumentException
Objet dans le mauvais état IllegalStateException
Paramètre obligatoire est null NullPointerException (via Objects.requireNonNull)
Opération non supportée UnsupportedOperationException
Index hors limites IndexOutOfBoundsException

5. Choisir checked ou unchecked ?

Il y a une règle pratique : demandez-vous si le code appelant peut faire quelque chose d’utile avec cette erreur.

  • Le code appelant peut se rétablir de manière raisonnable, par exemple en réessayant, en choisissant une alternative ou en avertissant l’utilisateur. Alors une exception checked est acceptable.
  • L’erreur est due à un bug ou à une défaillance d’infrastructure que le code appelant ne peut pas corriger. Alors utilisez une exception unchecked.

En pratique, les frameworks modernes comme Spring et Hibernate utilisent presque exclusivement les exceptions unchecked. Par exemple, Spring encapsule SQLException dans DataAccessException. Les raisons sont que les exceptions checked ont plusieurs inconvénients :

  1. Elles se propagent à travers les couches : un throws dans la couche repository entraîne throws dans le service et le contrôleur.
  2. Elles ne sont pas compatibles avec les lambdas et l’API Stream (voir section 8).
  3. Elles exposent les détails d’implémentation. L’interface OrderRepository ne devrait pas déclarer throws SQLException.

Remarque : Avec une application Spring Boot, préférez les exceptions unchecked pour les erreurs métier par défaut. Gérez-les de manière centralisée avec @RestControllerAdvice. N’utilisez les exceptions checked que si vous voulez vraiment forcer le code appelant à gérer immédiatement.

6. Concevoir des exceptions personnalisées pour votre application

6.1. Créer une classe de base

Vous devriez créer une exception de base pour chaque domaine. Cela vous permet de capturer toutes les erreurs du domaine avec un seul bloc catch si nécessaire :

public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

Cette classe doit toujours avoir un constructeur qui accepte cause. Ainsi, l’erreur d’origine ne se perd pas quand vous l’encapsulez.

6.2. Sous-classes avec contexte

Une bonne exception doit pouvoir répondre à la question « qu’est-il arrivé et avec quelles données ? ». Ne vous contentez pas d’écrire "Not found" :

public class OrderNotFoundException extends OrderException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Commande introuvable, id=" + orderId);
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

Le champ orderId est conservé pour que les couches supérieures puissent l’utiliser. Par exemple, un controller advice peut le lire pour retourner JSON d’erreur au client sans avoir besoin d’analyser la chaîne de message.

6.3. Encapsuler les erreurs des couches inférieures (traduction d’exceptions)

Quand la couche service appelle l’infrastructure, vous devez traduire les erreurs techniques en erreurs du domaine et préserver la cause :

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class OrderExportService {

    public void export(long orderId, Path target) {
        try {
            Files.writeString(target, "order=" + orderId);
        } catch (IOException e) {
            throw new OrderException("Échec de l'export de la commande " + orderId + " vers " + target, e);
        }
    }
}

Le deuxième paramètre e est la cause. Quand vous imprimez la stack trace, vous verrez les deux niveaux :

OrderException: Échec de l'export de la commande 42 vers /readonly/order.txt
    at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
    ...

Si vous écrivez throw new OrderException(e.getMessage()) sans passer e, la partie Caused by disparaît. Le débogage devient alors très difficile.

7. Anti-patterns courants

Étouffer l’exception. C’est l’erreur la plus dangereuse car l’application continue comme si de rien n’était :

try {
    gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
    // TODO
}

Si vous voulez vraiment ignorer une erreur, expliquez-le dans un commentaire. Au minimum, vous devez la journaliser.

Journaliser ET relancer. Écrire log.error(...) puis throw e; fait apparaître la même erreur plusieurs fois dans les logs. Vous devez choisir : traiter (et journaliser) sur place, ou relancer vers la couche supérieure.

Capturer Exception ou Throwable trop largement. Vous risquez de capturer accidentellement NullPointerException et d’autres bugs. Capturez uniquement le type que vous savez gérer. La seule exception raisonnable est à la limite externe de l’application, par exemple un gestionnaire global.

Utiliser l’exception pour le contrôle de flux. Créer une exception a un coût car il faut collecter la stack trace. Ce style de code est aussi difficile à lire :

// À éviter
try {
    return Integer.parseInt(input);
} catch (NumberFormatException e) {
    return 0;
}

Pour les données que vous prévoyez souvent invalides, vérifiez d’abord. Vous pouvez aussi retourner Optional. Les exceptions doivent être réservées aux situations exceptionnelles.

Déclarer throws Exception. Cela efface toutes les informations sur le type d’erreur possible. Cela force aussi tous les appelants à capturer un type très large.

8. Exceptions checked dans les lambdas

L’interface Function ne déclare pas throws, donc ce code ne compilera pas :

List<String> contents = paths.stream()
        .map(Files::readString) // erreur : unreported exception IOException
        .toList();

Le moyen habituel est d’encapsuler l’erreur dans UncheckedIOException. Cette classe existe dans le JDK pour précisément cette raison :

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class OrderFileReader {

    public List<String> readAll(List<Path> paths) {
        return paths.stream()
                .map(OrderFileReader::readQuietly)
                .toList();
    }

    private static String readQuietly(Path path) {
        try {
            return Files.readString(path);
        } catch (IOException e) {
            throw new UncheckedIOException("Impossible de lire " + path, e);
        }
    }
}

La méthode readQuietly isole le bloc try/catch du pipeline, donc le stream reste lisible. Le code appelant readAll peut capturer UncheckedIOException et obtenir le IOException original via getCause().

9. try-with-resources et exceptions supprimées

Quand vous travaillez avec des ressources à fermer, utilisez try-with-resources. Un point que peu remarquent : si à la fois le corps du try et le close() lèvent une erreur, l’erreur du close() n’écrase pas l’erreur principale. Elle est attachée à l’erreur principale comme supprimée :

public class SuppressedDemo {

    static class ReportConnection implements AutoCloseable {
        void query() {
            throw new IllegalStateException("Erreur de requête");
        }

        @Override
        public void close() {
            throw new IllegalStateException("Erreur de fermeture");
        }
    }

    public static void main(String[] args) {
        try (var connection = new ReportConnection()) {
            connection.query();
        } catch (IllegalStateException e) {
            System.out.println("Principal : " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("Supprimée : " + s.getMessage());
            }
        }
    }
}

Sortie :

Principal : Erreur de requête
Supprimée : Erreur de fermeture

Si vous écrivez vous-même try/finally et appelez close() dans le finally, l’erreur “Erreur de fermeture” écraserait “Erreur de requête”. Vous perdriez la véritable cause.

10. Tester le comportement d’exception

Si vous utilisez Spring Boot, spring-boot-starter-test inclut déjà JUnit 5 et AssertJ. Pour un projet Java pur, ajoutez les dépendances suivantes :

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Vous devez tester le type d’exception, le message, les données de contexte et la cause :

import org.junit.jupiter.api.Test;

import java.nio.file.Path;

import static org.assertj.core.api.Assertions.*;

class OrderExceptionTest {

    @Test
    void whenQuantityIsZero_thenThrowIllegalArgument() {
        assertThatThrownBy(() -> new Order(1L, 0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("quantity doit être > 0");
    }

    @Test
    void whenOrderShipped_thenCannotChangeQuantity() {
        var order = new Order(7L, 2);
        order.ship();

        assertThatIllegalStateException()
                .isThrownBy(() -> order.changeQuantity(5))
                .withMessage("Commande 7 est expédiée, impossible de modifier la quantité");
    }

    @Test
    void notFoundException_carriesOrderId() {
        assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
                .isInstanceOf(OrderException.class)
                .hasFieldOrPropertyWithValue("orderId", 42L);
    }

    @Test
    void whenPaymentOverLimit_thenThrowCheckedException() {
        var gateway = new PaymentGateway();

        assertThatExceptionOfType(PaymentDeclinedException.class)
                .isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
                .satisfies(e -> assertThat(e.getReason()).isEqualTo("dépassement de limite"));
    }

    @Test
    void whenExportFails_thenCauseIsPreserved() {
        var service = new OrderExportService();
        Path invalid = Path.of("/repertoire-inexistant/order.txt");

        assertThatThrownBy(() -> service.export(42L, invalid))
                .isInstanceOf(OrderException.class)
                .hasMessageContaining("Échec de l'export de la commande 42")
                .hasCauseInstanceOf(java.io.IOException.class);
    }
}

Quelques points à noter :

  • assertThatThrownBy prend une lambda qui peut lever n’importe quel Throwable, y compris les exceptions checked. Vous n’avez donc pas besoin de try/catch dans le test.
  • hasFieldOrPropertyWithValue lit le getter getOrderId(). Cela garantit que le contexte est bien transmis.
  • hasCauseInstanceOf assure que la cause ne se perd pas quand vous encapsulez l’erreur. Oublier de préserver la cause est facile et peut être manqué sans ce test. Dans le test, le répertoire parent n’existe pas, donc Files.writeString lève NoSuchFileException, une sous-classe de IOException.

11. Conclusion

Dans cet article, vous avez vu que l’exception checked est pour les situations où le code appelant peut se rétablir. L’exception unchecked est pour les erreurs de programmation, les violations de contrats ou les défaillances d’infrastructure que le code appelant ne peut pas corriger. Avec les applications modernes, en particulier Spring Boot, l’exception unchecked est le choix par défaut raisonnable.

Quand vous concevez des exceptions personnalisées, suivez ces principes :

  • Créez une classe de base pour chaque domaine.
  • Incluez les données de contexte dans les sous-classes.
  • Préservez toujours la cause quand vous traduisez une erreur entre les couches.

Enfin, évitez les anti-patterns tels qu’étouffer les erreurs, journaliser ET relancer, ou capturer Exception trop largement. En même temps, écrivez des tests pour verrouiller le comportement d’exception de votre code.

javaexceptionclean-codejunit5