エンジニア3年目になりましたが、今までちゃんと例外処理を設計ベースで考えたことがあまりなかったので、初級者から脱するために調べてみました。
1. 正常ルートと異常ルートを切り分ける
コードを書く際、常に2つの流れをまずは抑える。
-
正常系:
returnが繋いでいく「期待通り」の流れです。 -
異常系:
throwが切り拓く、通常処理を中断して「脱出する」流れです。
2. throwは「一番近いcatch」へジャンプ
throw が実行されると、プログラムは現在の処理を即座に中断し、「自分を囲んでいる一番近い catch」を探して一気にジャンプします。
呼び出し元をさかのぼる挙動
もし実行中のメソッド内に catch がなければ、呼び出し元のメソッドへ、さらにその親へと、エラーは「処理できる場所」が見つかるまで上へさかのぼっていきます。
public void top() {
try {
middle();
} catch (Exception e) {
// ここで捕まる
System.out.println("一番外側でキャッチしました");
}
}
public void middle() {
bottom(); // ここで起きたエラーが上流へ伝わります
}
public void bottom() {
// 1. ここで投げられ
throw new RuntimeException("エラー発生");
// 2. middleをスルーして
// 3. topのcatchまで一気にジャンプします
}
この「近くの catch を探しに行く」というイメージは、例外処理を理解する上で非常に重要です。
3. throw は「契約破棄」の通知
return と throw は、どちらもメソッドの出口ですが、その意味は大きく異なります。
- return: 「約束通り、結果を持ってきました」という報告です。呼び出し元は受け取った結果を前提に、次の処理を安心して実行できます。
- throw: 「現在の約束は守れません、処理を中断してください」という拒絶です。呼び出し元に対して、通常の続きをやってはいけないと強制的にストップをかけます。
4. 「責任を取れる場所」で catch する
設計思想として、「そのエラーに対して、具体的に何ができるか」という基準で catch する場所を決めます。
避けるべき例:意味のない握りつぶし
try {
userRepository.save(user);
} catch (Exception e) {
e.printStackTrace(); // ログを出力するだけ
}
// 保存に失敗しているのに、成功した前提で処理が続いてしまいます
望ましい例:役割に応じた分担
- リポジトリ層: エラーを検知しますが、画面を持っていないためユーザーへの通知はできません。そのまま上流へ投げます。
- サービス層: データの整合性を守るため、必要に応じて「業務的なエラー」に変換してさらに上流へ流します。
-
コントローラー層: ここで初めて
catchし、ユーザーに適切なメッセージを表示したり、エラーページへ遷移させたりします。
「その場でエラーを解決できないのであれば、無理に捕まえない」のが鉄則です。
5. finally は「確実な後始末」
finally は単なるおまけではなく、「成功しても失敗しても、これだけは絶対にやり遂げる」という保証です。
try {
db.connect();
return db.query();
} finally {
// 途中でエラーが起きてジャンプしても、
// 無事にデータが返せるときでも、確実に接続を閉じます
db.close();
}
後片付けを保証することで、メモリリークや接続不足といった二次被害を防ぐことができます。
まとめ:異常を「隠す」のではなく「正しく伝える」
例外処理の本質は、エラーを隠蔽することではありません。「異常を安全に上位へ伝え、ふさわしい場所で処理すること」です。
-
初級者: 異常終了しないように
try-catchで囲む。 -
中級者: どこで
throwし、どこまで飛ばし、どの層にcatchさせるかという「エラーの導線」を設計する。