例外処理の目的は、エラーを「なかったこと」にすることではなく、原因がわかる形で、適切な場所に伝えることです。try-catchの文法そのものは難しくありませんが、書き方しだいで、障害が起きたときに原因がすぐわかるシステムにも、何が起きたのかまったく追えないシステムにもなります。

この記事では、実務で守りたい例外処理のルールを10個にまとめ、それぞれを直す前と後のコードと、実際の実行結果で解説します(Java 21で確認)。

例外処理のルール早見表

# ルール
1 例外を握りつぶさない(空のcatchを書かない)
2 catch (Exception e) で広く捕まえすぎない
3 例外を変換するときは、原因(cause)を引き継ぐ
4 リソースは try-with-resources で閉じる
5 finally の中で return しない
6 ログに出すか、再スローするか、どちらか一方にする
7 業務のエラーは、意味のわかる独自の例外にする
8 メッセージには、調査に必要な値を入れる
9 引数のチェックは、入口で早めに行う
10 例外を「処理の分岐」に使わない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Javaの例外処理|実務で守りたいルールとよくある書き方の改善例」

検査例外と非検査例外

種類 代表例 コンパイラーのチェック 主な用途
検査例外(checked) IOException、SQLException catch か throws が必須 呼び出し側が対処できる、外部要因のエラー
非検査例外(unchecked) IllegalArgumentException、NullPointerException 不要 プログラムの誤り、前提条件の違反
エラー(Error) OutOfMemoryError 不要 通常は捕まえない、JVMレベルの深刻な問題

非検査例外は RuntimeException を継承したものです。現在のJavaのフレームワーク(Spring Bootなど)では、業務のエラーは非検査例外で表し、共通の仕組みでまとめて処理するスタイルが主流です。

ルール1:例外を握りつぶさない

// 直す前
String s = null;
int len = -1;
try {
    len = s.length();
} catch (Exception e) {
    // 何もしない
}
System.out.println("len=" + len);
len=-1(NullPointerExceptionが起きたことに、誰も気づけない)

空のcatchは、エラーを「なかったこと」にします。処理はそのまま続き、間違ったデータで後続の処理が進むため、原因から遠く離れた場所で別の問題として現れます。対処できないなら、catchせずに呼び出し元へ伝えます。

ルール2:catch (Exception e) で広く捕まえすぎない

上の例では、本来は想定していない NullPointerException まで一緒に捕まえてしまっています。

// 直した後:想定している例外だけを捕まえる
try {
    int qty = Integer.parseInt(input);
} catch (NumberFormatException e) {
    throw new IllegalArgumentException("数量は整数で入力してください: " + input, e);
}

複数の例外に同じ対処をするときは、マルチキャッチが使えます。

try {
    // ...
} catch (NumberFormatException | ArithmeticException e) {
    // 同じ対処
}

ルール3:例外を変換するときは、原因(cause)を引き継ぐ

// 直す前:原因が失われる
catch (IOException e) {
    throw new DataAccessException("注文の読み込みに失敗");
}
// 直した後:原因を渡す
catch (IOException e) {
    throw new DataAccessException("注文の読み込みに失敗", e);
}

実行結果の違いです。

直す前:DataAccessException: 注文の読み込みに失敗 / cause=null
直した後:DataAccessException: 注文の読み込みに失敗 / cause=java.io.IOException: 接続がタイムアウトしました

直した後のスタックトレースには、Caused by: として本当の原因が表示されます。

DataAccessException: 注文の読み込みに失敗
Caused by: java.io.IOException: 接続がタイムアウトしました

原因を渡さないと、「読み込みに失敗した」ことはわかっても、「なぜ失敗したのか(タイムアウト)」がわからなくなります。障害の調査で最も困るパターンの1つです。

ルール4:リソースは try-with-resources で閉じる

ファイル、DB接続、ネットワーク接続などは、使い終わったら必ず閉じる必要があります。

try (var r1 = new Res("A"); var r2 = new Res("B")) {
    throw new IllegalStateException("本体の処理で失敗");
} catch (Exception e) {
    System.out.println("caught: " + e.getMessage());
    for (Throwable s : e.getSuppressed()) {
        System.out.println("  suppressed: " + s.getMessage());
    }
}

閉じる処理(close)でも例外が起きるようにして実行した結果です。

open A
open B
close B
close A
caught: 本体の処理で失敗
  suppressed: close失敗: B
  suppressed: close失敗: A
確認できたこと 内容
閉じる順番 開いたのと逆の順番(B→A)で閉じられる
例外の優先 本体の例外が優先され、close の例外は消えずに getSuppressed() に残る

finallyで閉じる古い書き方では、close の例外が本体の例外を上書きしてしまうことがありました。AutoCloseable を実装したものは、try-with-resources で閉じるのが基本です。

ルール5:finally の中で return しない

static int calc() {
    try {
        throw new IllegalStateException("本当のエラー");
    } finally {
        return -1;
    }
}
result=-1

例外が起きたのに、何事もなかったように -1 が返りました。finallyの中の return は、try の中で起きた例外を消してしまいます。finallyは後片付けだけに使い、return や throw は書かないようにします。

ルール6:ログに出すか、再スローするか、どちらか一方にする

// 直す前:ログに出して、さらに再スロー
catch (IOException e) {
    log.error("読み込みに失敗", e);
    throw new DataAccessException("読み込みに失敗", e);
}

呼び出し元でも同じようにログを出すと、1つのエラーで、同じスタックトレースが何回もログに出ます。ログが読みにくくなり、エラーの件数の監視も正しくできなくなります。

方針 内容
再スローする場合 ログには出さず、原因を付けて投げる。最終的に受け止めた場所で1回だけログに出す
ここで対処を終える場合 ログに出して、処理を続ける(再スローしない)

Spring Bootなどでは、共通の例外ハンドラーで1回だけログに出す形にすると、自然とこのルールが守れます。

ルール7:業務のエラーは、意味のわかる独自の例外にする

public class OrderNotFoundException extends RuntimeException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("注文が見つかりません: orderId=" + orderId);
        this.orderId = orderId;
    }
    public long getOrderId() { return orderId; }
}
注文が見つかりません: orderId=42
独自の例外にする利点 内容
意味が伝わる RuntimeException("not found") より、何が起きたかが名前でわかる
対処を分けられる 例外の種類ごとに、404を返す・再試行するなどの処理を決められる
調査に必要な値を持てる orderId のような値をフィールドとして持たせられる

ただし、標準の例外で意味が足りる場合は、標準の例外を使います。引数が不正なら IllegalArgumentException、状態が不正なら IllegalStateException です。

ルール8:メッセージには、調査に必要な値を入れる

直す前 直した後
"エラーが発生しました" "注文が見つかりません: orderId=42"
"不正な値です" "数量は1〜99で指定してください: quantity=0"
"接続に失敗" "在庫APIへの接続に失敗しました: url=..., timeout=3s"

「どの値で、何が起きたか」がメッセージだけでわかると、ログを見ただけで調査が進みます。ただし、パスワードや個人情報はメッセージに入れないようにします。

ルール9:引数のチェックは、入口で早めに行う

public Order create(String customerId, int quantity) {
    Objects.requireNonNull(customerId, "customerId は必須です");
    if (quantity < 1) {
        throw new IllegalArgumentException("数量は1以上で指定してください: quantity=" + quantity);
    }
    // ...
}
customerId は必須です

おかしな値を、使われる前に入口で止めると、エラーの原因がすぐにわかります。奥の処理で NullPointerException が起きるより、ずっと調査が楽になります。

なお、Java 15以降では標準で、NullPointerException のメッセージが詳しくなり、何がnullだったかが表示されます。

Cannot invoke "String.trim()" because the return value of "java.util.Map.get(Object)" is null

ルール10:例外を「処理の分岐」に使わない

// 直す前:存在チェックに例外を使う
try {
    Order order = service.get(id);
    return true;
} catch (OrderNotFoundException e) {
    return false;
}
// 直した後:存在確認用のメソッドやOptionalを使う
return repository.findById(id).isPresent();

例外は、想定外の事態を伝えるための仕組みです。「あるかないか」のような通常の分岐に使うと、コードの意図が読みにくくなり、性能にも影響します。見つからないことが普通にありうる場合は、Optional を返す設計にします。

Spring Bootでの共通のエラー処理

Webアプリでは、例外をControllerの中で1つずつ処理するのではなく、共通の例外ハンドラーでまとめて受け止めるのが基本です。

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception e) {
        log.error("想定外のエラー", e);   // ここで1回だけログに出す
        return ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "システムエラーが発生しました");
    }
}
  • 業務のエラーは、種類ごとに適切なステータスコードで返す
  • 想定外のエラーは、ここで1回だけログに出し、利用者には内部の情報(スタックトレースなど)を返さない

よくある質問

Q. 検査例外は使わない方がいいのですか? A. 呼び出し側がその場で対処できるエラー(ファイルがない、など)を表すのには、今でも有効です。一方で、対処できない例外を何層も throws で伝えるだけになっている場合は、原因を付けて非検査例外に変換すると、コードが読みやすくなります。

Q. e.printStackTrace() はダメですか? A. 標準エラー出力に出るだけで、ログの仕組み(出力先、ログレベル、日時)に乗らないため、本番のアプリでは避けます。ロガーを使って log.error("メッセージ", e) のように出力します。

Q. Exception と Throwable を catch してもいいですか? A. Throwable には OutOfMemoryError などのErrorも含まれるため、通常は捕まえません。Exception を捕まえるのは、共通の例外ハンドラーのような最後の受け皿に限ります。

まとめ

  • 空のcatchで握りつぶさない。対処できないなら呼び出し元に伝える
  • 捕まえるのは想定している例外だけ。広く捕まえすぎない
  • 例外を変換するときは原因(cause)を渡す。Caused by が調査の手がかりになる
  • リソースは try-with-resources。閉じる順は逆順、close の例外は suppressed に残る
  • finallyでreturnしない。例外が消える
  • ログか再スローか、どちらか一方。ログは最後に受け止めた場所で1回だけ
  • 業務のエラーは意味のわかる独自の例外、メッセージには調査に必要な値を入れる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Javaの例外処理|実務で守りたいルールとよくある書き方の改善例」

株式会社RJCでは、Javaを使った開発の中で、障害に強く、原因を追いやすいコードの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?