JavaのException: checked、unchecked、そして設計方法

1. 概要

Exceptionはアプリケーションが期待通りに動作しないことを報告するJavaのメカニズムです。多くのプロジェクトでは、Exceptionが一貫性なく使われています。ある場所にはthrows Exceptionが散らばっていて、別の場所には空のcatchブロックがあります。

この記事では、checked exceptionとunchecked exceptionの違いを理解できます。また、それぞれをいつ使うべきかを知り、アプリケーション用のカスタム例外システムを設計する方法も学べます。最後に、JUnit 5とAssertJを使ってエラースロー動作をテストする方法も含まれています。

2. Throwableの階層体系

throwで投げられるすべてのものはjava.lang.Throwableを継承します。簡略化した階層は次の通りです。

Throwable
├── Error                      (unchecked) – JVMの深刻なエラー
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception                  (checked)
    ├── IOException
    ├── SQLException
    └── RuntimeException       (unchecked)
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── NullPointerException

分類ルールは非常にシンプルです。

  • Checked exceptionはExceptionとそのサブクラスです。ただしRuntimeExceptionのブランチは除外です。Compilerはこのタイプを処理することを強制します。
  • Unchecked exceptionはRuntimeException、Error、およびそれらのサブクラスです。Compilerは処理を強制しません。

Errorをキャッチするべきではありません。JVMがメモリ不足になった場合、アプリケーションは自動的に復旧することはできません。

3. Checked exception

Checked exceptionはメソッドの「契約」の一部です。メソッドがthrowsを宣言する場合、呼び出し側は2つの方法のいずれかを選択する必要があります。1つ目はtry/catchで例外をキャッチする方法です。2つ目は上位層にthrowsを宣言する方法です。

支払いゲートウェイが取引を拒否する可能性があるシナリオを考えましょう。これはまったく起こりうる状況で、呼び出し側は次に何をするかを決める必要があります。

public class PaymentDeclinedException extends Exception {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("支払いが拒否されました: " + 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("制限額を超過");
        }
        System.out.println("カード" + cardNumber + "から" + amount + "を引き落とします");
    }
}

PaymentDeclinedExceptionはExceptionを継承しているため、checked exceptionです。charge(...)を呼び出してエラーを処理しない場合、compilerは報告します。

error: 報告されていない例外PaymentDeclinedException; キャッチするか、スローするために宣言する必要があります

呼び出し側は失敗の可能性に対処する必要があります。

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("理由: " + 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));
    }
}

出力:

カード4111-xxxxから50000を引き落とします
SUCCESS
理由: 制限額を超過
DECLINED

ここでエラーはビジネス状態(DECLINED)に変換されています。これはchecked exceptionが最も役に立つタイプのシナリオです。

4. Unchecked exception

Unchecked exceptionは通常、プログラミングエラーまたは前提条件の違反を示します。例えば、nullを渡す、負の数を渡す、またはオブジェクトが間違った状態にあるときにメソッドを呼び出す場合があります。

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は > 0である必要があります。受け取った値: " + quantity);
        }
        this.id = id;
        this.quantity = quantity;
    }

    public void changeQuantity(int newQuantity) {
        if (shipped) {
            throw new IllegalStateException("注文" + id + "はすでに配送されているため、数量を変更できません");
        }
        this.quantity = newQuantity;
    }

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

コンストラクタは入力が無効な場合にIllegalArgumentExceptionをスローします。changeQuantityメソッドは注文がすでに配送された場合にIllegalStateExceptionをスローします。呼び出し側のすべての場所でこれらのエラーを「キャッチして処理」してはいけません。正しい方法は、呼び出すコードを修正することです。

JDKにある既存の例外を再利用してから、新しいクラスを作成してください。

シナリオ 使用すべき例外
パラメータが無効 IllegalArgumentException
オブジェクトが間違った状態 IllegalStateException
必須パラメータがnull NullPointerException (Objects.requireNonNullを使用)
サポートされていない操作 UnsupportedOperationException
インデックスが範囲外 IndexOutOfBoundsException

5. Checked か unchecked か?

実用的な原則があります。呼び出し側がこのエラーで有用な処理ができるかどうかを自問してください。

  • 呼び出し側が適切に復旧できる場合、例えば再試行、別のオプションを選択、またはユーザーに通知する場合があります。この場合、checked exceptionは許容できる選択肢です。
  • バグまたはインフラの障害が原因で、呼び出し側が何もできない場合があります。この場合、unchecked exceptionを使用してください。

実際には、SpringやHibernateなどの最新フレームワークはほぼunchecked exceptionのみを使用します。例えば、SpringはSQLExceptionをDataAccessExceptionでラップします。Checked exceptionにはいくつかの欠点があるためです。

  1. 複数の層に広がります。repository層のthrowsは、service層とcontroller層のthrowsを引き起こします。
  2. Lambda および Stream API と互換性がありません(セクション 8 を参照)。
  3. 実装の詳細が公開されます。インターフェースOrderRepositoryはthrows SQLExceptionを宣言すべきではありません。

注意: Spring Bootアプリケーションでは、デフォルトではビジネスエラーに対してunchecked exceptionを使用してください。次に、@RestControllerAdviceで一元的に処理します。呼び出し側にその場で処理を強制したい場合にのみchecked exceptionを使用してください。

6. アプリケーション用のカスタムException設計

6.1. ベースクラスの作成

各ドメイン用に基本的な例外クラスを作成する必要があります。このアプローチにより、必要に応じて単一のcatchブロックでドメインのすべてのエラーをキャッチできます。

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

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

このクラスは常にcauseを受け取るコンストラクタを持つ必要があります。そうすることで、ラップする際に元のエラーが失われません。

6.2. コンテキストを持つサブクラス

優れた例外は「何が起こったのか、どのデータを使用したのか」という質問に答える必要があります。単に"Not found"と書かないでください。

public class OrderNotFoundException extends OrderException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("注文id=" + orderId + "が見つかりません");
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

orderIdフィールドは上位層が使用するために保存されます。例えば、controller adviceはこれを読んでクライアントにエラーJSONを返す際に、メッセージ文字列を解析する必要がありません。

6.3. 下位層エラーのラップ (exception translation)

サービス層がインフラストラクチャを呼び出すとき、技術的なエラーをドメインエラーに変換し、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("注文" + orderId + "を" + target + "にエクスポートすることに失敗しました", e);
        }
    }
}

2番目のパラメータeはcauseです。スタックトレースを出力すると、両方の層が表示されます。

OrderException: 注文42を/readonly/order.txtにエクスポートすることに失敗しました
    at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
    ...

throw new OrderException(e.getMessage())と書いてeを渡さない場合、Caused by部分は消えてしまいます。その時点でデバッグは非常に困難になります。

7. よくあるアンチパターン

例外を無視する。 これが最も危険なエラーです。アプリケーションは何も起こらなかったかのように実行され続けるためです。

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

本当にエラーを無視したい場合は、コメントに理由を明記してください。最小限でも、そのエラーをログに記録する必要があります。

ログと再スローの両方。 log.error(...)を書いてからthrow e;するとと、同じエラーがログに何度も表示されます。1つだけ選択する必要があります。その場で処理(およびログ)するか、上位層にスローしてもらう。

ExceptionまたはThrowableをキャッチする範囲が広すぎる。 そうすることで、NullPointerExceptionや他のバグも誤ってキャッチしてしまいます。処理できる正確なタイプをキャッチしてください。唯一の合理的な例外は、アプリケーションの最外層、例えばグローバルハンドラーです。

例外を制御フローとして使用する。 スタックトレースを収集する必要があるため、例外の作成にはコストがかかります。このようなコードは読みにくくもあります。

// 非推奨
try {
    return Integer.parseInt(input);
} catch (NumberFormatException e) {
    return 0;
}

頻繁に失敗することが予想されるデータの場合は、事前にチェックしてください。また、Optionalを返すこともできます。例外は例外的な状況のために取っておくべきです。

throws Exceptionを宣言する。 この方法は、発生する可能性のあるエラーの種類に関する情報を削除します。また、すべての呼び出し側に非常に広い型をキャッチすることを強制します。

8. Lambda のchecked exception

インターフェースFunctionはthrowsを宣言していないため、次のコードはコンパイルされません。

List<String> contents = paths.stream()
        .map(Files::readString) // エラー: 報告されていない例外IOException
        .toList();

一般的な処理方法は、エラーをUncheckedIOExceptionでラップすることです。このクラスは、この目的のためにJDKで提供されています。

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(path + "を読み取ることができません", e);
        }
    }
}

readQuietlyメソッドはtry/catchブロックをパイプラインから分離するため、ストリームは簡潔なままです。readAllの呼び出し側はUncheckedIOExceptionをキャッチし、getCause()で元のIOExceptionを取得できます。

9. try-with-resourcesとsuppressed exception

リソースを閉じる必要があるときは、try-with-resourcesを使用してください。注意すべき点があります。try本体とclose()の両方がエラーをスローする場合、close()のエラーは主要なエラーを上書きしません。これは抑制されたエラーとして主要なエラーにアタッチされます。

public class SuppressedDemo {

    static class ReportConnection implements AutoCloseable {
        void query() {
            throw new IllegalStateException("クエリエラー");
        }

        @Override
        public void close() {
            throw new IllegalStateException("クローズエラー");
        }
    }

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

出力:

メイン: クエリエラー
抑制: クローズエラー

自分でtry/finallyを書いてfinallyの中でclose()を呼び出す場合、「クローズエラー」は「クエリエラー」を上書きします。この場合、実際の原因が失われます。

10. 例外スローの動作テスト

Spring Bootを使用している場合、spring-boot-starter-testにはJUnit 5とAssertJが含まれています。純粋なJavaプロジェクトの場合は、以下の依存性を追加します。

<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>

例外の型、メッセージ、コンテキストデータ、および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は > 0である必要があります");
    }

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

        assertThatIllegalStateException()
                .isThrownBy(() -> order.changeQuantity(5))
                .withMessage("注文7はすでに配送されているため、数量を変更できません");
    }

    @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("制限額を超過"));
    }

    @Test
    void whenExportFails_thenCauseIsPreserved() {
        var service = new OrderExportService();
        Path invalid = Path.of("/存在しないディレクトリ/order.txt");

        assertThatThrownBy(() -> service.export(42L, invalid))
                .isInstanceOf(OrderException.class)
                .hasMessageContaining("注文42をエクスポート")
                .hasCauseInstanceOf(java.io.IOException.class);
    }
}

いくつかの重要なポイント:

  • assertThatThrownByはchecked exceptionを含む任意のThrowableをスローできるラムダを受け入れます。したがって、テストでtry/catchは必要ありません。
  • hasFieldOrPropertyWithValueはゲッターgetOrderId()を読みます。そのおかげで、コンテキストが正しく渡されていることを確認できます。
  • hasCauseInstanceOfはエラーをラップする際にcauseが失われていないことを確認します。このテストがないと、causeを忘れるエラーは簡単に見落とされます。テストでは、親ディレクトリが存在しないため、Files.writeStringはIOExceptionのサブクラスであるNoSuchFileExceptionをスローします。

11. まとめ

この記事では、呼び出し側が復旧できるシナリオではchecked exceptionが使用される一方で、unchecked exceptionはプログラミングエラー、条件違反、または呼び出し側が何もできないインフラストラクチャエラーに使用されることを学びました。最新のアプリケーション、特にSpring Bootでは、unchecked exceptionがデフォルトの合理的な選択肢です。

カスタム例外を設計する場合は、次の原則に従う必要があります。

  • 各ドメイン用に基本クラスを作成する。
  • コンテキストデータをサブクラスに含める。
  • 層間でエラーを変換する際は常にcauseを保持する。

最後に、エラーを無視する、ログと再スロー、またはExceptionを広くキャッチするなどのアンチパターンを避けるべきです。同時に、コードのエラースロー動作をロックするためのテストを記述する必要があります。

javaexceptionclean-codejunit5