独自の例外を作る
このレッスンで分かること
- 業務固有の例外は
extends RuntimeExceptionかextends Exceptionで自作する- コンストラクタは最低 4 種類 (
()、(msg)、(msg, cause)、(cause)) を用意するのが定石- 業務語彙で名付ける ─
UserNotFoundExceptionInvalidOrderStateException等extends RuntimeExceptionが多くの場面で楽 (検査例外を強制しない)
独自の例外を作る とは
Java の例外クラスを継承して独自の
InsufficientFundsExceptionを作り、銀行口座の出金処理でthrow/catchする流れを学んでみよう。
なぜカスタム例外を作るのか
Java には IllegalArgumentException NullPointerException IOException など、最初から数百種類の例外クラスが用意されています。「だったら自分で例外を作る必要なんてないのでは?」と思うかもしれません。しかし、実務でドメインロジック (業務処理) を書いていると、標準の例外だけでは「何が起きたのか」を正確に伝えきれない場面が必ず出てきます。
例えば銀行口座の出金処理を考えてみましょう。残高 1000 円の口座から 1500 円を引き出そうとしたとき、IllegalArgumentException を投げても間違いではありません。でも、コードを読む人や呼び出し側からすると「引数がおかしい」だけでは「残高不足」なのか「マイナスの金額が渡された」のか「上限を超えた」のかが分かりません。catch 側でも if 文や getMessage() の文字列比較で分岐するハメになり、保守性も型安全性もガタガタになります。
業務システムでは「何が起きたか」を
型で表現することが、後から読み手に親切なコードを書くコツです。残高不足という現象はInsufficientFundsExceptionという固有の型で表現する、これが Java らしいオブジェクト指向の発想です。
そこで登場するのが カスタム例外 です。やることは単純で、Exception か RuntimeException を継承した自作クラスを 1 つ作るだけ。たったそれだけで、try-catch でその例外専用のハンドラを書けるようになり、コードの意図が読み手にもコンパイラにもしっかり伝わります。
上の図のとおり、Throwable を頂点に Error と Exception が分岐し、さらに Exception の下に RuntimeException がぶら下がっています。自作の InsufficientFundsException をどこに位置付けるかが、設計の最初のポイントです。
Exception を継承する? RuntimeException を継承する?
カスタム例外を作るときに最初に悩むのが「親クラスをどれにするか」です。実務でよく使われる選択肢は次のとおりです。
RuntimeExceptionを継承する →非検査例外(unchecked exception) になるExceptionを継承する →検査例外(checked exception) になる
検査例外 は、呼び出し元が try-catch で捕まえるか throws で再送するかをコンパイラに強制される例外です。一方 非検査例外 は、コンパイラからのお咎めなしで自由に投げられます。
古い Java の本では「
Exceptionを継承するのが正しい」と書かれていることもありますが、現代の Java ではRuntimeExceptionを継承する流派の方が主流になっています。SpringHibernateといった代表的なフレームワークも、ほぼ全てのカスタム例外を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 です。
ドメインロジックでの活用例
もう少し本物っぽい例として、口座クラスを書いてみます。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 を返す」というスタイルにしますが、本文サンプルでは InsufficientFundsException を try-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()か、構造化されたロガー (SLF4Jのlogger.error(...)) で出力するクセを付けましょう。投げる例外と捕まえる例外は、必ずペアで設計するのが鉄則です
もう一つだけ補足すると、InsufficientFundsException のようなドメイン例外は「機械的な型情報」+「人間向けの message」の両方を持たせるのがコツです。型は catch 側の分岐に使い、message はログに残す、という役割分担をはっきりさせると、後から読んだ自分も他人も助かります。
実務で遭遇するパターン
中級レッスンで学んだことが、実際の業務コードでどう登場するかを整理します。
- API のエラー応答 ─
UserNotFoundException→ HTTP 404、AccessDeniedException→ 403 - ドメイン層のバリデーション ─
InvalidOrderExceptionで業務ルール違反を表現 - 外部 API ラップ ─ HTTP の
IOExceptionをPaymentGatewayExceptionに変換 - フレームワークの例外階層 ─ Spring の
DataAccessExceptionのようにライブラリ専用ツリーを作る
コードレビューで指摘されがちなポイント
PR を出すとシニアから入りやすい指摘です。先回りで身につけておけばレビューが一発で通ります。
extends ExceptionかRuntimeExceptionか ─ 呼び出し側に対処を強制するかで選ぶ。最近は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) を完成させることです。挙動は次のとおりです。
amountがbalance以下なら、出金後の残高balance - amountを返すamountがbalanceより大きいなら-1を返す- 本文サンプルのように、内部で
InsufficientFundsExceptionをthrowしてcatchする書き方が推奨です
本文中に出てきた InsufficientFundsException を Solution.java の中で static class (内部クラス) として定義し、withdraw の中で残高チェックを書きましょう。try-catch の構造そのものに慣れることが目的なので、if で直接 return -1 する書き方ではなく、できれば一度 throw して catch 側で -1 を返す形に挑戦してみてください。
テストケースの例は次のとおりです。
withdraw(1000, 300)→700withdraw(500, 1000)→-1withdraw(0, 0)→0withdraw(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 つができることを確認しましょう。
- カスタム例外 の要点を自分の言葉で説明できる
- このレッスンの最小コード (または操作手順) を見ずに書ける
- 練習問題やクイズで間違えた箇所を読み直して理解した
理解度チェック (30 秒)
Q. カスタム例外 とは何か、1 文で説明してください。
A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。
関連レッスン
要件
InsufficientFundsExceptionという名前のカスタム例外クラスをSolutionの内部 (static class) として定義することInsufficientFundsExceptionはRuntimeExceptionを継承し、String messageを受け取るコンストラクタでsuper(message)を呼ぶことwithdrawメソッドの中で、amount > balanceのときにInsufficientFundsExceptionをthrowし、同じメソッド内の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