💡 はじめに
例外処理は責務分離や保守性に直結する重要な設計ポイントです。
場当たり的に try-catch を書き散らすと、ログが多重に出たり、異常が握りつぶされたり、レイヤ間が密結合になってしまいます。
本記事では、Javaの例外処理を 「どこでcatchするべきか」 に焦点を当て、レイヤごとの責務を整理しながら アーキテクチャが綺麗になる例外設計を解説します!
⚠️ Javaの例外処理の問題は「catchする場所」にある
例外処理でよくある問題は、例外が「発生した場所」と「扱うべき場所」が一致しないことです。
例えば、DBアクセスで SQLException が発生した場合
try {
// DB処理
} catch (SQLException e) {
e.printStackTrace();
return null;
}
一見キャッチして対処したように見えますが、
- 呼び出し元から見ると「成功なのか失敗なのか」判断不能
- nullハンドリングがアプリ全体に波及
- DB例外がInfrastructure層に閉じず、上位層の判定ロジックが複雑化
こういった形でシステムの保守性が徐々に崩れていきます。
例外は "意味があるレイヤ" で扱わないと、設計全体に悪影響を与えるのです。
🧭 理想的な例外伝播と責務の分離
では、どこでcatchし、どこではcatchしないのか?
レイヤアーキテクチャを例に考えます:
| 層 | 役割 | 例外の扱い方 | catchすべき? |
|---|---|---|---|
| Presentation (Controller) | UI/API境界 | 最終的に例外をレスポンスに変換 | ◎ 最後でまとめてcatch |
| Application (Service/UseCase) | ユースケース調整 | 必要なら業務例外へ変換 | △ 必要時のみ |
| Domain | 業務ルール | 技術詳細は知らない | △ 原則Throw/Domain例外 |
| Infrastructure | DB/外部API | 技術例外が発生する場所 | × 原則catchしない(翻訳のみ) |
例外は、意味のある層でだけ扱う
技術的例外はInfrastructureで翻訳し、UI層でまとめて処理
これにより、
- プレゼンテーション層は業務例外だけを知ればよい
- ドメインは技術詳細から独立できる
- 例外が一貫した抽象で伝播し可読性が向上
設計が綺麗に保たれやすくなります。
🔄 例外翻訳(Exception Translation)の考え方
下位層の例外を、上位が理解できる例外へ変換するテクニックが 例外翻訳 です。
Effective Java でも推奨されているアプローチで、Springの DataAccessExceptionなどが良い例です。
public User findById(UserId id) {
try {
return jdbcTemplate.queryForObject(...);
} catch (SQLException e) {
// DBの詳細は上位に漏らさない
throw new RepositoryException("ユーザ取得に失敗", e);
}
}
もしこれを翻訳せずに SQLException を直接投げると、ControllerやServiceがDB例外を知ってしまいます。
こうなると「NoSQLに移行した」「外部APIに変更した」という変更が上位層に波及し、柔軟性を失う原因になります。
翻訳により、
- 上位層は抽象例外のみ扱えばよい
- 実装変更がレイヤを越えて伝染しない
- フレームワーク依存からドメインを守れる
という大きなメリットが得られます。
🧪 実装例
ここでは、例外がどの層でどのように扱われるかを、短いコードで追ってみます。
- Infrastructure層で SQLException が発生
- RepositoryException に翻訳して上位に投げる(ここで 例外連鎖 が発生)
- Application層で必要に応じて BusinessException に変換
- 最後はController層でHTTPレスポンスへ変換
ポイントは 例外翻訳 + 例外連鎖 を意識するとアーキテクチャが綺麗に保てることです。
class UserRepositoryImpl implements UserRepository {
@Override
public User findById(long id) {
try {
return jdbcTemplate.queryForObject(...);
} catch (SQLException e) {
// 元例外を cause として保持したままラップ → 例外連鎖
throw new RepositoryException("ユーザ取得に失敗", e);
}
}
}
例外連鎖とは、元の例外を内部に保持したまま別の例外としてスローすることです。
これにより 上位層はDB例外を意識せずに済むのに、原因調査は容易になります。
class UserService {
public UserDto getUser(long id) {
try {
return UserDto.from(userRepository.findById(id));
} catch (RepositoryException e) {
// ここでも cause を渡せば連鎖は維持される
throw new BusinessException("ユーザ取得に失敗しました", e);
}
}
}
Presentation層で最終的にレスポンスへ変換する
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
ResponseEntity<?> handleBusiness(BusinessException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
@ExceptionHandler(Exception.class)
ResponseEntity<?> handleGeneral(Exception e) {
return ResponseEntity.internalServerError()
.body("サーバ内部でエラーが発生しました");
}
}
🏁 まとめ
例外処理は、「どこでcatchするか」を意識するだけで設計の見通しと保守性が大きく変わります。
技術例外は Infrastructureで翻訳し(Exception Translation)、元例外は causeとして保持(例外連鎖)、最終的なハンドリングは Presentation層で対応
この流れを守ることで、レイヤ責務が明確になり、アーキテクチャが綺麗に保てます
例外は発生場所ではなく、意味のある場所で扱う、これがシンプルで強力な指針です。