カスタム例外を一言でいうと
「カスタム例外」は、
自分のアプリやライブラリ専用の例外クラスを新しく定義して、
エラーの意味を分かりやすく、扱いやすくするためのものです。
NullPointerException や IOException だけでは表現しづらい
「業務的に意味のあるエラー」を、名前のついた独自クラスとして表せるようになります。
「何が起きたか」が例外クラス名だけで伝わるようになるのが最大のメリットです。
まずは「どこにぶら下げるか」を決める(checked か unchecked か)
Exception を継承するか、RuntimeException を継承するか
カスタム例外を作るときに最初に決めるのは、Exception を継承するか、RuntimeException を継承するかです。
Exception 継承ならチェック例外、RuntimeException 継承なら非チェック例外(実行時例外)になります。
チェック例外にした場合、その例外を投げるメソッドは必ず throws 宣言が必要になり、
呼び出し側も try-catch か throws を強制されます。
非チェック例外にした場合、throws も try-catch も強制されません。
起きたときには普通の RuntimeException と同じように実行時に飛びます。
初心者向けの判断基準としては、次のイメージを持ってください。
「この失敗は普通に起こりうるので、呼び出し側でちゃんと対応してほしい」
という性質ならチェック例外(Exception 継承)。
「これは基本的にバグや致命的な異常で、起きたら落ちて構わない」
という性質なら非チェック例外(RuntimeException 継承)。
設計としてどちらにしたいか、という話です。
一番基本的なカスタム例外クラスの定義
チェック例外のカスタム例外(Exception 継承)
まずは、チェック例外としてのカスタム例外の例から。
例えば「業務上のルール違反」を表す BusinessException を作ってみます。
public class BusinessException extends Exception {
public BusinessException() {
super();
}
public BusinessException(String message) {
super(message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
public BusinessException(Throwable cause) {
super(cause);
}
}
Javaポイントは、コンストラクタで super(...) を呼んで、
メッセージや原因例外(cause)を親クラスに渡していることです。
実務では、よく使う 2 パターン(message だけ、message+cause)だけに絞っても構いません。
これを使うと、例えば次のように書けます。
public class OrderService {
public void placeOrder(int quantity) throws BusinessException {
if (quantity <= 0) {
throw new BusinessException("数量は 1 以上である必要があります: " + quantity);
}
// 正常な処理…
}
}
Java呼び出し側は必ず BusinessException を意識させられます。
public class App {
public static void main(String[] args) {
OrderService service = new OrderService();
try {
service.placeOrder(0);
} catch (BusinessException e) {
System.err.println("注文エラー: " + e.getMessage());
}
}
}
Java「業務的に NG な状況」が BusinessException という名前で、そのままコード上に現れているのが分かると思います。
非チェック例外のカスタム例外(RuntimeException 継承)
今度は、非チェック例外としてのカスタム例外です。
public class BusinessRuntimeException extends RuntimeException {
public BusinessRuntimeException() {
super();
}
public BusinessRuntimeException(String message) {
super(message);
}
public BusinessRuntimeException(String message, Throwable cause) {
super(message, cause);
}
public BusinessRuntimeException(Throwable cause) {
super(cause);
}
}
JavaRuntimeException を継承しているだけで、あとはほぼ同じです。
例えば、「このアプリの設計上、本来ありえないはずの状態」を検知したときに使えます。
public class UserStatusService {
public void processStatus(String status) {
if (!status.equals("ACTIVE") && !status.equals("INACTIVE")) {
throw new BusinessRuntimeException("想定外のステータス: " + status);
}
// 正常処理…
}
}
Javaこの場合、呼び出し側は try-catch を強制されません。
「テストで気付いて修正すべきバグ」として扱う意図が感じられます。
メッセージと cause をどう設計するか(ここは大事)
メッセージは「人間に事情を伝える文章」
例外クラス名は「何の種類のエラーか」を表し、
メッセージは「具体的に何がまずかったか」を表します。
例えば、
throw new BusinessException("数量は 1 以上である必要があります: " + quantity);
Javaというメッセージがあると、ログを見たときに
「どの条件に引っかかったか」
「実際の値がいくつだったか」
が一目で分かります。
最低限、
「どの入力値が原因か」
「どのルールに違反しているか」
が分かるようにメッセージを書くと、後からの分析が段違いに楽になります。
原因例外(cause)をラップする
他の例外を捕まえて、自分のカスタム例外に包んで投げ直したい場面はよくあります。
例えば、DAO 層での SQLException をカスタム例外にラップする例です。
public class UserDao {
public User findById(String id) throws DataAccessException {
try {
// DB アクセス(ここで SQLException が出るかもしれない)
} catch (SQLException e) {
throw new DataAccessException("ユーザー取得に失敗しました: id=" + id, e);
}
}
}
JavaDataAccessException は例えば次のように定義します。
public class DataAccessException extends Exception {
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
Javaこうしておくと、スタックトレースには
DataAccessException
の下に
SQLException
が「原因」としてぶら下がって出てきます。
原因例外を無視してしまうと、
「結局、元の何が悪かったのか」が分からなくなります。
カスタム例外を使うときは、
捕まえた例外を、そのまま cause として渡す
ことを強く意識してください。
カスタム例外を使うメリットを具体的なコードで見る
例:ユーザー登録のバリデーションエラー
ユーザー登録処理で、入力チェックに失敗したときに
「ただ IllegalArgumentException を投げる」のと
「UserValidationException を投げる」のでは、読みやすさが全然違います。
まず、カスタム例外を定義します。
public class UserValidationException extends Exception {
public UserValidationException(String message) {
super(message);
}
}
Javaサービス側で使います。
public class UserService {
public void register(String name, String email) throws UserValidationException {
if (name == null || name.isBlank()) {
throw new UserValidationException("名前は必須です");
}
if (email == null || !email.contains("@")) {
throw new UserValidationException("メールアドレスの形式が不正です: " + email);
}
// 正常な登録処理…
}
}
Java呼び出し側は、次のように扱えます。
public class App {
public static void main(String[] args) {
UserService service = new UserService();
try {
service.register("", "invalid-email");
} catch (UserValidationException e) {
System.err.println("入力エラー: " + e.getMessage());
}
}
}
Javaログやコードを読む人にとって、
「これはユーザー入力のバリデーションエラーなんだな」ということがひと目で分かります。
もしここで、単に IllegalArgumentException を投げていたらどうでしょうか。
例外クラス名からは「どの層の、どの種類のエラーか」が読み取りづらくなります。
カスタム例外は、
「エラーにも “ドメインの名前” を付ける」行為だと思ってください。
カスタム例外を乱造しないための考え方
何でもかんでも作ると逆効果になる
カスタム例外の利点を知ると、
アクセスエラー、保存エラー、取得エラー、更新エラー…
みたいに、細かく例外クラスを作りたくなるかもしれません。
しかし、増やしすぎると、
どれを catch すればいいのか分からない
例外クラスの一覧を見ても、違いがよく分からない
という「名前だけ増えた混乱状態」になります。
カスタム例外を作るときは、
次のような問いを自分に投げてみてください。
その例外クラス名を catch 節に書いたとき、
「どんな失敗をまとめて扱いたいか」がはっきりしているか。
例えば、
業務ルール違反全般 → BusinessException
ユーザー入力の妥当性エラー → UserValidationException
永続化層(DB やファイル)のエラー → DataAccessException
くらいの粒度にしておくと、
呼び出し側から見ても扱いやすい「まとまり」になります。
既存の標準例外で足りるなら、それを使う
例えば、引数チェックで
if (x < 0) throw new IllegalArgumentException(...);
のように、標準の IllegalArgumentException で十分な場面は多いです。
「自分のドメインにとって意味のある分類があるか?」
「それをクラスとして切り出すことで、catch する側が嬉しくなるか?」
このあたりを基準にして、
標準例外で足りるところはそれを使い、
どうしても表現しきれないところだけカスタム例外を導入する、と考えるとバランスが良くなります。
まとめ:カスタム例外を自分の中でこう位置づける
カスタム例外は、初心者向けにまとめると、
「自分のアプリの“失敗の種類”に名前を与えて、コードとログを読みやすくする仕組み」
です。
特に意識してほしいポイントは、
- Exception を継承すればチェック例外、RuntimeException を継承すれば非チェック例外になる
- クラス名で「どんな種類のエラーか」、メッセージで「具体的に何が悪いか」を伝える
- 既存の例外(IOException, IllegalArgumentException など)をラップして、原因情報を失わないようにする
- 増やしすぎると逆効果なので、「catch したいまとまり」を基準にクラス設計する
