例外ラップは「生の例外をそのまま外に漏らさない」ための技
業務システムを書いていると、標準ライブラリや外部ライブラリから、いろいろな種類の例外が飛んできます。SQLException, IOException, ParseException, TimeoutException…名前も意味もバラバラです。
これをそのままアプリの上位層(サービス層・コントローラ層)まで投げ上げてしまうと、
「どこで何が起きたのか分かりにくい」「呼び出し側がライブラリ固有の例外に依存してしまう」という問題が出てきます。
そこで使うのが「例外ラップ」です。
「低レイヤの例外」を「アプリケーション固有の例外」に包み直して投げることで、
境界をはっきりさせ、呼び出し側のコードをシンプルにできます。
まずは「チェック例外」と「実行時例外」の違いを押さえる
チェック例外は「コンパイル時に強制される例外」
IOException や SQLException のように、メソッドシグネチャに 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 を意識しなくてよくなっている」ことです。readAll は RuntimeException(AppException)を投げるので、メソッドシグネチャに throws を書く必要がありません。
上位層は「アプリケーションエラーが起きたらまとめてハンドリングする」という方針を取りやすくなります。
二つ目は、「メッセージにドメイン情報(どのファイルか)を含めている」ことです。IOException のメッセージだけでは分かりにくい情報を、ラップ時に補ってあげることで、ログやアラートが読みやすくなります。
三つ目は、「元の例外 e を cause として必ず渡している」ことです。
これにより、スタックトレースをたどれば「どの行で本当のエラーが起きたか」が分かります。
ラップするときに cause を捨ててしまうのは絶対に避けるべきです。
例外ラップユーティリティを汎用化する
チェック例外を投げる処理をラムダで受け取る
「チェック例外を投げる処理」を、汎用的にラップしたくなることがあります。
例えば、IOException や SQLException を投げる処理を、毎回 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);
}
}
}
JavaDataAccessException は、アプリケーション固有の実行時例外です。
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 のパターンを一箇所に集約できる。
