Java Tips | 基本ユーティリティ:例外ラップ

Java Java
スポンサーリンク

例外ラップは「生の例外をそのまま外に漏らさない」ための技

業務システムを書いていると、標準ライブラリや外部ライブラリから、いろいろな種類の例外が飛んできます。
SQLException, IOException, ParseException, TimeoutException…名前も意味もバラバラです。

これをそのままアプリの上位層(サービス層・コントローラ層)まで投げ上げてしまうと、
「どこで何が起きたのか分かりにくい」「呼び出し側がライブラリ固有の例外に依存してしまう」という問題が出てきます。

そこで使うのが「例外ラップ」です。
「低レイヤの例外」を「アプリケーション固有の例外」に包み直して投げることで、
境界をはっきりさせ、呼び出し側のコードをシンプルにできます。


まずは「チェック例外」と「実行時例外」の違いを押さえる

チェック例外は「コンパイル時に強制される例外」

IOExceptionSQLException のように、メソッドシグネチャに throws が必要な例外を「チェック例外」と呼びます。

public void readFile() throws IOException {
    // ...
}
Java

呼び出し側は、必ず try-catch するか、さらに throws で投げ直さなければなりません。
これは「このメソッドは失敗し得るよ」ということをコンパイル時に強制する仕組みです。

ただし、業務アプリの上位層までこれを持ち上げてしまうと、
「どこもかしこも throws IOException だらけ」「呼び出し側がライブラリの例外型に縛られる」という状態になりがちです。

実行時例外は「コンパイル時には強制されない例外」

RuntimeException を継承した例外(NullPointerException, IllegalArgumentException など)は、
メソッドシグネチャに throws を書かなくても投げられます。

public void doSomething() {
    throw new IllegalStateException("something wrong");
}
Java

呼び出し側は、必要なら try-catch しますが、強制はされません。
「アプリケーション固有のエラー」を表現するときは、独自の RuntimeException を定義して使うことが多いです。

例外ラップの典型パターンは、
「低レイヤのチェック例外を、アプリケーション固有の RuntimeException に包み直す」
という形です。


例外ラップの最小例:IOException をアプリ例外に包む

ドメインに合わせた例外クラスを作る

まずは、アプリケーション固有の例外クラスを一つ作ります。

public class AppException extends RuntimeException {

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

    public AppException(String message) {
        super(message);
    }
}
Java

これは「アプリ全体で使える汎用的な実行時例外」です。
実務では、これをさらに用途別に分けて FileStorageException, ExternalApiException などを作ることも多いです。

IOException を AppException にラップするユーティリティ

ファイル読み込みユーティリティを例にします。

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

public final class FileUtils {

    private FileUtils() {}

    public static String readAll(Path path) {
        try {
            return Files.readString(path);
        } catch (IOException e) {
            throw new AppException("ファイル読み込みに失敗しました: " + path, e);
        }
    }
}
Java

呼び出し側はこう書けます。

String content = FileUtils.readAll(Path.of("config.yml"));
Java

ここで深掘りしたいポイントは三つです。

一つ目は、「呼び出し側が IOException を意識しなくてよくなっている」ことです。
readAllRuntimeExceptionAppException)を投げるので、メソッドシグネチャに throws を書く必要がありません。
上位層は「アプリケーションエラーが起きたらまとめてハンドリングする」という方針を取りやすくなります。

二つ目は、「メッセージにドメイン情報(どのファイルか)を含めている」ことです。
IOException のメッセージだけでは分かりにくい情報を、ラップ時に補ってあげることで、ログやアラートが読みやすくなります。

三つ目は、「元の例外 e を cause として必ず渡している」ことです。
これにより、スタックトレースをたどれば「どの行で本当のエラーが起きたか」が分かります。
ラップするときに cause を捨ててしまうのは絶対に避けるべきです。


例外ラップユーティリティを汎用化する

チェック例外を投げる処理をラムダで受け取る

「チェック例外を投げる処理」を、汎用的にラップしたくなることがあります。
例えば、IOExceptionSQLException を投げる処理を、毎回 try-catch して AppException に包むのは面倒です。

そこで、「チェック例外を投げる関数型インターフェース」を自分で定義してしまいます。

@FunctionalInterface
public interface CheckedSupplier<T, E extends Exception> {
    T get() throws E;
}
Java

これを使って、汎用的なラップユーティリティを書きます。

public final class Exceptions {

    private Exceptions() {}

    public static <T, E extends Exception> T wrap(
            CheckedSupplier<T, E> supplier,
            String message
    ) {
        try {
            return supplier.get();
        } catch (Exception e) {
            throw new AppException(message, e);
        }
    }
}
Java

使う側はこうなります。

String content = Exceptions.wrap(
        () -> Files.readString(Path.of("config.yml")),
        "設定ファイルの読み込みに失敗しました"
);
Java

ここで深掘りしたいのは、「try-catch のパターンを一箇所に集約できる」という点です。
wrap の中で「どの例外クラスに包むか」「メッセージをどう付けるか」「cause をどう扱うか」を統一しておけば、
呼び出し側は「何が起きたら AppException が飛ぶか」だけを意識すればよくなります。


レイヤーごとに例外をラップして「境界」をはっきりさせる

インフラ層の例外をドメイン層の例外に変換する

典型的な三層構造(コントローラ層・サービス層・リポジトリ層)をイメージしてみましょう。

リポジトリ層では、DB アクセスで SQLException が飛ぶかもしれません。
これをそのままサービス層に投げると、サービス層が JDBC に依存してしまいます。

そこで、リポジトリ層でこうラップします。

public class UserRepository {

    public User findById(long id) {
        try {
            // JDBC で検索
        } catch (SQLException e) {
            throw new DataAccessException("ユーザ取得に失敗しました: id=" + id, e);
        }
    }
}
Java

DataAccessException は、アプリケーション固有の実行時例外です。

public class DataAccessException extends AppException {
    public DataAccessException(String message, Throwable cause) {
        super(message, cause);
    }
}
Java

サービス層は、もはや SQLException を知らなくてよくなり、
「データアクセスに失敗した」という意味だけを扱えばよくなります。

public class UserService {

    private final UserRepository repository;

    public User getUser(long id) {
        return repository.findById(id); // DataAccessException が飛び得る
    }
}
Java

ここでの重要ポイントは、「レイヤーの境界で例外をラップすることで、上位層を下位ライブラリから切り離す」という設計です。
これにより、DB を変えたり、HTTP クライアントを変えたりしても、上位層のコードをほとんど触らずに済むようになります。


例外ラップでやってはいけないこと

cause を捨てる

一番やってはいけないのは、元の例外を無視してしまうことです。

catch (IOException e) {
    throw new AppException("失敗しました"); // cause を渡していない → NG
}
Java

これをやると、ログを見ても「どこで何が起きたのか」が分からなくなります。
必ず new AppException(message, e) のように、cause を渡してください。

情報を減らすメッセージにしてしまう

元の例外メッセージよりも情報量が減ってしまうメッセージも危険です。

catch (IOException e) {
    throw new AppException("エラーが発生しました", e); // 何のエラーか分からない
}
Java

ラップするときは、「元の例外メッセージ+ドメイン情報」を足すイメージで書くとよいです。

catch (IOException e) {
    throw new AppException("設定ファイル読み込みエラー: path=" + path, e);
}
Java

こうしておけば、ログを見たときに「どの処理で、どの入力に対して失敗したか」がすぐに分かります。


例外ラップとログ出力の関係

ラップ時にログを出すか、上位でまとめて出すか

例外ラップをするとき、「ここでログを出すべきか?」という悩みが出てきます。

基本的な考え方はこうです。

ラップする層では、必ずしもログを出さなくてよい(上位でまとめてログ出力する設計もあり)。
ただし、「ここで握りつぶす」場合(再試行して最終的に諦めるなど)は、その場でログを出すべき。

例外ラップは「情報を構造化して上に渡す」役割なので、
ログ戦略(どの層で何をログに残すか)とセットで設計するときれいにハマります。


まとめ:例外ラップで初心者が身につけるべき感覚

例外ラップは、「ライブラリの生の例外をそのまま外に漏らさず、アプリケーションの言葉に翻訳する」行為です。

押さえておきたいポイントは次の通りです。

チェック例外(IOException, SQLException など)を、アプリ固有の RuntimeException に包み直すことで、上位層のコードをシンプルにできる。
ラップするときは、必ず元の例外を cause として渡し、スタックトレースを失わないようにする。
メッセージには「どの処理で」「どの入力に対して」失敗したかというドメイン情報を足す。
レイヤーの境界(リポジトリ層→サービス層など)で例外をラップすることで、上位層を下位ライブラリから切り離す。
汎用的なラップユーティリティ(Exceptions.wrap(...))を用意すると、try-catch のパターンを一箇所に集約できる。

タイトルとURLをコピーしました