カスタム例外の作り方 — 明確なエラー伝搬
「何が問題か」を正確に伝えるのが例外の役割。標準例外だけでは業務の意図が伝わりにくい場面で、カスタム例外が活躍します。APIレイヤでは、呼び出し側が“何をどう処理すべきか”判断しやすい形でエラーを返す設計が大切です。
目的と使いどころ
- 意味の明確化: 「在庫不足」「権限なし」「入力不正」など、業務語で伝える。
- 捕捉の簡略化: 例外型ごとに対応を分岐(再試行、400/403/404/409などへのマッピング)。
- 診断容易化: IDや状態など、原因追跡に必要な情報を例外に持たせる。
checked と unchecked の選び分け
- checked(Exception継承): 呼び出し側に“対処義務”を課したい。外部要因(IO、ネット、DB)や回復可能性があるとき。
- unchecked(RuntimeException継承): プログラムのバグや前提違反。呼び出し側に明示のtry/catchを強要しない。
- 指針:
- 外部障害は checked。
- 契約違反・ドメイン整合性違反は unchecked。
- フレームワーク/ライブラリ境界での再マッピングも有効(内部はuncheckedで高速に伝搬、API出口でHTTPへ変換)。
基本的な定義パターン
最小構成(メッセージのみ)
// checked例外
public class DataNotFoundException extends Exception {
public DataNotFoundException(String message) { super(message); }
}
// unchecked例外
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) { super(message); }
}
Java原因例外(cause)を伝搬
public class ExternalServiceException extends Exception {
public ExternalServiceException(String message, Throwable cause) {
super(message, cause);
}
}
Java追加フィールドでコンテキストを保持
public class StockShortageException extends RuntimeException {
private final String productId;
private final int requested;
private final int available;
public StockShortageException(String productId, int requested, int available) {
super("在庫不足: productId=" + productId + ", requested=" + requested + ", available=" + available);
this.productId = productId;
this.requested = requested;
this.available = available;
}
public String getProductId() { return productId; }
public int getRequested() { return requested; }
public int getAvailable() { return available; }
}
Javathrow / throws と伝搬のテンプレ
例外を投げる(throw)
void validateAge(int age) {
if (age < 0) throw new IllegalArgumentException("年齢は0以上");
}
Java呼び出し側に委ねる(throws)
// checked例外を宣言して、呼び出し側に処理を委ねる
public Order fetchOrder(String id) throws DataNotFoundException {
// 見つからなければ投げる
throw new DataNotFoundException("注文が見つかりません: id=" + id);
}
Javaトランスレート(別の意味へ変換)
public Customer find(String id) {
try {
return repository.load(id);
} catch (SQLException e) {
throw new ExternalServiceException("DBエラー", e); // 意味付けして再スロー
}
}
JavaAPI 層での捕捉とマッピング
コントローラで型ごとにHTTPへ変換
public Response getOrder(String id) {
try {
Order o = service.getOrder(id);
return Response.ok(o);
} catch (DataNotFoundException e) {
return Response.status(404).body(e.getMessage());
} catch (InvalidOrderStateException e) {
return Response.status(409).body(e.getMessage());
} catch (ExternalServiceException e) {
return Response.status(502).body("上流障害: " + e.getMessage());
}
}
Java- ラベル化: 型名でエラー意図を表現し、catchの分岐を読みやすく。
- メッセージ方針: ユーザー表示用とログ用を分ける設計(必要ならコードや詳細フィールド)。
例題で身につける
例題1: 在庫不足を明確化(unchecked)
class OrderService {
void place(String productId, int qty) {
int available = checkStock(productId);
if (qty > available) {
throw new StockShortageException(productId, qty, available);
}
// 正常処理…
}
int checkStock(String productId) { return 3; }
}
Java- ポイント: ドメイン違反はRuntimeExceptionで高速伝搬。例外が保持する追加情報で、上位でのメッセージ生成や監視を簡単に。
例題2: 外部連携失敗の委ね方(checked)
class PaymentService {
void pay(String orderId) throws ExternalServiceException {
try {
callGateway(orderId);
} catch (IOException e) {
throw new ExternalServiceException("決済ゲートウェイ通信失敗: orderId=" + orderId, e);
}
}
void callGateway(String orderId) throws IOException { /* ... */ }
}
Java- ポイント: 外部要因はcheckedで呼び出し側に再試行/フォールバック判断を委ねる。
例題3: API層で一元ハンドリング
class ApiController {
OrderService orderService = new OrderService();
PaymentService paymentService = new PaymentService();
Response handleCheckout(String productId, int qty, String orderId) {
try {
orderService.place(productId, qty);
paymentService.pay(orderId);
return Response.ok("完了");
} catch (StockShortageException e) {
return Response.status(409).body("在庫不足: " + e.getAvailable() + "個のみ");
} catch (ExternalServiceException e) {
return Response.status(502).body("外部連携失敗。後で再試行してください");
} catch (Exception e) {
return Response.status(500).body("不明なエラー");
}
}
}
Java- ポイント: 型別マッピングで、呼び出し側(クライアント)にとって分かりやすい反応を返す。
すぐ使えるテンプレ
- 最小限のカスタム例外
public class DomainException extends RuntimeException {
public DomainException(String message) { super(message); }
public DomainException(String message, Throwable cause) { super(message, cause); }
}
Java- IDとコード付き(API設計向け)
public class ApiErrorException extends RuntimeException {
private final String code; // "ORDER_NOT_FOUND" など
private final String id; // 関連ID
public ApiErrorException(String code, String message, String id) {
super(message);
this.code = code; this.id = id;
}
public String getCode() { return code; }
public String getId() { return id; }
}
Java- ユーティリティで再スロー統一
public final class Exceptions {
private Exceptions() {}
public static <T> T wrapSql(SqlSupplier<T> s) {
try { return s.get(); }
catch (SQLException e) { throw new ExternalServiceException("DB障害", e); }
}
@FunctionalInterface interface SqlSupplier<T> { T get() throws SQLException; }
}
Javaありがちな落とし穴と対処
- 抽象的すぎる例外名:
- 対処: 具体名で意図を示す。 DataNotFoundException / InvalidStatusException など。
- 階層が過剰に深い:
- 対処: 2階層程度に抑える。 基底(ドメイン/外部連携)+具体型。
- causeを捨てる:
- 対処: 必ず原因を保持。 コンストラクタでcauseを受けてsuperへ。
- ユーザー向けとログ向けの混同:
- 対処: 表示文言と技術詳細を分離。 コード+人間向けメッセージ+詳細はログ。
- checkedの乱用でtry/catchだらけ:
- 対処: 境界でまとめて処理。 ドメイン内はunchecked、インフラ境界でchecked→ドメイン例外へ変換。
まとめ
- 意図の可視化: 例外名と型で“何が起きたか”を即伝達。
- 伝搬設計: checked/uncheckedを使い分け、API出口で意味あるレスポンスへ。
- 追跡容易: IDや状態を例外に持たせ、原因解析を短時間で。
