独自の例外を作る

生田 陸人
LuaGate エンジニア / 現役エンジニア
編集 LuaGate編集部

このレッスンで分かること

  • 業務固有の例外は extends RuntimeExceptionextends Exception で自作する
  • コンストラクタは最低 4 種類 (()(msg)(msg, cause)(cause)) を用意するのが定石
  • 業務語彙で名付ける ─ UserNotFoundException InvalidOrderStateException
  • extends RuntimeException が多くの場面で楽 (検査例外を強制しない)

独自の例外を作る とは

Java の例外クラスを継承して独自の InsufficientFundsException を作り、銀行口座の出金処理で throw / catch する流れを学んでみよう。

なぜカスタム例外を作るのか

Java には IllegalArgumentException NullPointerException IOException など、最初から数百種類の例外クラスが用意されています。「だったら自分で例外を作る必要なんてないのでは?」と思うかもしれません。しかし、実務でドメインロジック (業務処理) を書いていると、標準の例外だけでは「何が起きたのか」を正確に伝えきれない場面が必ず出てきます。

例えば銀行口座の出金処理を考えてみましょう。残高 1000 円の口座から 1500 円を引き出そうとしたとき、IllegalArgumentException を投げても間違いではありません。でも、コードを読む人や呼び出し側からすると「引数がおかしい」だけでは「残高不足」なのか「マイナスの金額が渡された」のか「上限を超えた」のかが分かりません。catch 側でも if 文や getMessage() の文字列比較で分岐するハメになり、保守性も型安全性もガタガタになります。

業務システムでは「何が起きたか」を で表現することが、後から読み手に親切なコードを書くコツです。残高不足 という現象は InsufficientFundsException という固有の型で表現する、これが Java らしいオブジェクト指向の発想です。

そこで登場するのが カスタム例外 です。やることは単純で、ExceptionRuntimeException を継承した自作クラスを 1 つ作るだけ。たったそれだけで、try-catch でその例外専用のハンドラを書けるようになり、コードの意図が読み手にもコンパイラにもしっかり伝わります。

diagram (will load when visible)

上の図のとおり、Throwable を頂点に ErrorException が分岐し、さらに Exception の下に RuntimeException がぶら下がっています。自作の InsufficientFundsException をどこに位置付けるかが、設計の最初のポイントです。

Exception を継承する? RuntimeException を継承する?

カスタム例外を作るときに最初に悩むのが「親クラスをどれにするか」です。実務でよく使われる選択肢は次のとおりです。

  • RuntimeException を継承する → 非検査例外 (unchecked exception) になる
  • Exception を継承する → 検査例外 (checked exception) になる

検査例外 は、呼び出し元が try-catch で捕まえるか throws で再送するかをコンパイラに強制される例外です。一方 非検査例外 は、コンパイラからのお咎めなしで自由に投げられます。

古い Java の本では「Exception を継承するのが正しい」と書かれていることもありますが、現代の Java では RuntimeException を継承する流派の方が主流になっています。Spring Hibernate といった代表的なフレームワークも、ほぼ全てのカスタム例外を RuntimeException 系で組み立てています。

理由は次のとおりです。検査例外を多用すると、throws InsufficientFundsException のような宣言があらゆるメソッドに伝染し、ラムダや Stream API とも相性が悪くなります。さらに「とりあえず catch して何もしない」という握り潰しコードも誘発しやすくなります。本章では実務に近い書き方として RuntimeException を継承するパターンで進めますが、業務ドメインで「呼び出し側に対応を強制したい」例外なら Exception 継承を選ぶ、と覚えておきましょう。

最小のカスタム例外クラス

まず一番シンプルな形を見てみます。RuntimeException を継承して、message を受け取るコンストラクタを 1 つ書けば、それで立派なカスタム例外です。

Java

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

たったこれだけです。super(message) で親クラス (RuntimeException) のコンストラクタに message を渡しているのがポイントで、ここを忘れると getMessage()null になります。

投げる側はこんな感じです。

Java

if (amount > balance) { throw new InsufficientFundsException( "残高不足です: balance=" + balance + ", amount=" + amount); }

受け取る側は普通の catch ブロックでつかまえます。

Java

try { int rest = account.withdraw(1500); } catch (InsufficientFundsException e) { System.out.println("出金できません: " + e.getMessage()); }

catch (InsufficientFundsException e) のように専用の型で受けられるので、「残高不足」と「それ以外のエラー」を完全に区別できるのが大きな利点です。

cause を渡せるコンストラクタも用意する

実務では、ある例外をきっかけにして別の例外を投げ直す 例外チェーン というパターンがよく出てきます。例えば DB アクセスで SQLException が出たけど、上の層には InsufficientFundsException として伝えたい、というケースです。このときに「元の原因 (cause) は何だったか」を残しておくと、後でログを追うときに非常に助かります。

Java

public class InsufficientFundsException extends RuntimeException { public InsufficientFundsException(String message) { super(message); } public InsufficientFundsException(String message, Throwable cause) { super(message, cause); } }

この 2 個セットが、Spring などのコードでもよく見るカスタム例外の標準スタイルです。コンストラクタを 2 つ並べる、super(...) をきっちり呼ぶ、それだけで OK です。

diagram (will load when visible)

ドメインロジックでの活用例

もう少し本物っぽい例として、口座クラスを書いてみます。withdraw メソッドで残高チェックをして、不足なら例外を投げ、足りていれば残高を更新する、というよくある業務処理です。

Java

public class BankAccount { private int balance; public BankAccount(int initialBalance) { if (initialBalance < 0) { throw new IllegalArgumentException("初期残高はマイナス不可"); } this.balance = initialBalance; } public int withdraw(int amount) { if (amount > balance) { throw new InsufficientFundsException( "残高不足: balance=" + balance + ", amount=" + amount); } balance -= amount; return balance; } }

注目してほしいのは、引数がそもそも不正 (初期残高がマイナス) のときは IllegalArgumentException を、業務的に成立しない (残高不足) のときは InsufficientFundsException を投げ分けているところです。「引数の妥当性」と「ドメインの状態」を別の型で表す、というのがカスタム例外を作る最大の意味です。

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

Java

BankAccount acc = new BankAccount(1000); try { acc.withdraw(1500); } catch (InsufficientFundsException e) { System.out.println("残高不足のため出金不可"); } catch (IllegalArgumentException e) { System.out.println("引数がそもそも不正: " + e.getMessage()); }

例外を「投げる」のか「戻り値で返す」のか

例外を作る話と並んで悩ましいのが、「業務エラーは例外で表現すべきか、戻り値で表現すべきか」という設計判断です。例えば残高不足のとき、-1 を返してもよいし、Optional.empty() を返してもよいし、InsufficientFundsException を投げてもよい。

一般論として「例外的な状況」「呼び出し元が無視してはいけない異常」は例外、「予期される失敗」「呼び出し元が普通に分岐したい結果」は戻り値、と使い分けるのが定石です。残高不足は微妙なラインで、ATM のように「ユーザーに普通に返すべきエラー」なら戻り値、サーバー間 API のように「上の層でまとめてエラーログを出したい」なら例外、というふうにユースケース次第で選びます。

今回の課題では「シンプルに -1 を返す」というスタイルにしますが、本文サンプルでは InsufficientFundsExceptiontry-catch してから -1 を返す書き方を見せています。両方の書き方ができるようになっておくと、業務で設計を任されたときに自信を持って選択できます。

よくある間違い

カスタム例外まわりで初学者が踏みがちな落とし穴を 3 つ紹介します。

  • Exception を継承して検査例外まみれになる — 何の気なしに extends Exception と書くと、その例外を投げるメソッドには throws InsufficientFundsException の宣言が必要になります。呼び出し元、その呼び出し元、と上に向かってずっと宣言が伝染し、ラムダや Stream の中で投げられなくなる、という悲劇が起きがちです。意図的に検査例外にしたいのでなければ RuntimeException 継承にしましょう
  • super(message) を呼び忘れて getMessage()null になる — コンストラクタの中で super(message) を呼ばないと、親クラスにメッセージが伝わりません。後で e.getMessage() を見たら null で原因が全く分からない、というハマり方をします。引数で受け取った message は必ず super(message) (もしくは super(message, cause)) で渡してください
  • catch (Exception e) {} で握り潰す — カスタム例外を作っておきながら、上の層で catch (Exception e) {} のように何もしないと、例外を作った意味が完全に消えます。最低でも e.printStackTrace() か、構造化されたロガー (SLF4Jlogger.error(...)) で出力するクセを付けましょう。投げる例外と捕まえる例外は、必ずペアで設計するのが鉄則です

もう一つだけ補足すると、InsufficientFundsException のようなドメイン例外は「機械的な型情報」+「人間向けの message」の両方を持たせるのがコツです。型は catch 側の分岐に使い、message はログに残す、という役割分担をはっきりさせると、後から読んだ自分も他人も助かります。

実務で遭遇するパターン

中級レッスンで学んだことが、実際の業務コードでどう登場するかを整理します。

  • API のエラー応答UserNotFoundException → HTTP 404、AccessDeniedException → 403
  • ドメイン層のバリデーションInvalidOrderException で業務ルール違反を表現
  • 外部 API ラップ ─ HTTP の IOExceptionPaymentGatewayException に変換
  • フレームワークの例外階層 ─ Spring の DataAccessException のようにライブラリ専用ツリーを作る

コードレビューで指摘されがちなポイント

PR を出すとシニアから入りやすい指摘です。先回りで身につけておけばレビューが一発で通ります。

  • extends ExceptionRuntimeException ─ 呼び出し側に対処を強制するかで選ぶ。最近は RuntimeException 派が多い
  • 4 種類のコンストラクタを揃えていないcause を保持しないと原因のチェーンが切れる
  • 例外名が動詞FailToLoadException ではなく LoadFailedException のように形容詞的に
  • メッセージに動的情報を含めない"user not found" ではなく "user not found: id=" + id で原因追跡

パフォーマンス考慮事項

  • stacktrace の生成コスト ─ 1 回数 μs。例外をフロー制御に使うのは禁じ手
  • コンストラクタで super(msg, cause, false, false) ─ Java 7+ で stacktrace を抑制してパフォーマンス向上
  • 例外の事前生成 + 使い回し ─ 非常時のために static final で事前作成するパターン (ただし stacktrace は misleading)
  • ロガーへの渡し方log.error("msg", e)stacktrace を出力、+ e.getMessage() だと消える
この章のポイント

ここまでの要点 業務固有例外は extends RuntimeException で 4 コンストラクタ用意。業務語彙で名付け、メッセージに識別情報を含める。

やってみよう

今回の課題は、口座の出金処理を表す Solution.withdraw(int balance, int amount) を完成させることです。挙動は次のとおりです。

  1. amountbalance 以下なら、出金後の残高 balance - amount を返す
  2. amountbalance より大きいなら -1 を返す
  3. 本文サンプルのように、内部で InsufficientFundsExceptionthrow して catch する書き方が推奨です

本文中に出てきた InsufficientFundsExceptionSolution.java の中で static class (内部クラス) として定義し、withdraw の中で残高チェックを書きましょう。try-catch の構造そのものに慣れることが目的なので、if で直接 return -1 する書き方ではなく、できれば一度 throw して catch 側で -1 を返す形に挑戦してみてください。

テストケースの例は次のとおりです。

  • withdraw(1000, 300)700
  • withdraw(500, 1000)-1
  • withdraw(0, 0)0
  • withdraw(100, 100)0

境界値 (balance == amount のとき) で 0 が返ることに気をつけてください。>>= を間違えると、残高ピッタリの全額出金で -1 を返してしまう実装ミスが発生します。条件は amount > balance (amount の方が大きい、つまり残高不足) のときに throw する、と覚えましょう。

慣れてきたら、cause を受け取るコンストラクタを追加したり、balance がマイナスのときに IllegalArgumentException を投げる分岐を増やしたりして、本物の業務コードに近い形に育ててみると勉強になります。次のレッスンでは、try-with-resources や複数 catch の書き方など、もう一歩実践的な例外処理に踏み込んでいきます。

よくある質問

Q. 例外を catch する単位はどれくらいが適切ですか?

A. 意味のあるリカバリができる範囲ごとに細かく catch するのが基本です。広く catch (Exception e) で全部受けると本当の原因が見えなくなります。具体的な例外型(IOException, SQLException)で個別対応し、最後に総合受けを置くと安全と網羅の両立ができます。

Q. checked と unchecked の使い分けは?

A. 回復可能な業務エラーは checked(IOException 系)、プログラマのバグは unchecked(RuntimeException 系)が基本ですが、現代の API(Spring、Stream)では unchecked 寄りが主流です。throws 宣言の煩雑さを避けたいなら unchecked を選びます。

Q. finally で何を書くべきですか?

A. リソース解放(ファイルクローズ、DB 接続クローズ)が定番です。ただし AutoCloseable を実装したクラスなら try-with-resources を使うと finally を書かずに自動クローズできるため、現代の Java では try-with-resources が第一選択です。

次のレッスン

次は try-with-resources で、try-with-resources を学びます。

事前確認 — 進む前に次の 3 つができることを確認しましょう。

  1. カスタム例外 の要点を自分の言葉で説明できる
  2. このレッスンの最小コード (または操作手順) を見ずに書ける
  3. 練習問題やクイズで間違えた箇所を読み直して理解した

理解度チェック (30 秒)

Q. カスタム例外 とは何か、1 文で説明してください。

この章のポイント

A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。

関連レッスン

要件

  1. InsufficientFundsException という名前のカスタム例外クラスを Solution の内部 (static class) として定義すること
  2. InsufficientFundsExceptionRuntimeException を継承し、String message を受け取るコンストラクタで super(message) を呼ぶこと
  3. withdraw メソッドの中で、amount > balance のときに InsufficientFundsExceptionthrow し、同じメソッド内の catch で受けて -1 を返すこと

入出力例

test-cases.txt

withdraw(1000, 300)700 withdraw(500, 1000)-1 withdraw(0, 0)0 withdraw(100, 100)0 withdraw(999, 1000)-1 withdraw(1000000, 1)999999

ヒント

main.java
main.java
学習モード

メモ

独自の例外を作る

⌘S で保存