連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
例外は「不具合が自分から名乗る瞬間」です。握り潰すと、現象が「たまに落ちる」から「何も起きてない(ことになってる)」に変わる。
そして、障害対応は“最初に疑うのが自分の記憶になる作業”を強いられます。
このページは、C#/.NET(.NET 8中心)で 例外を「検索で引ける証拠」 に変えるための掟をまとめます。
題材はシンプルに3つです。
ここで言う「層」は、プログラムのスコープ(ネスト)の話ではありません。
UI/アプリ/ドメイン/インフラのような 役割のレイヤ(責務の境界) を指しています。
| ここでの層(レイヤ) | 具体例 | 例外の扱い |
|---|---|---|
| UI(画面/クライアント) | WinFormsのForm/イベント/表示 | 表示用に整形(基本は握り潰さない) |
| アプリ(ユースケース) | 受注更新/保存処理/トランザクション | 失敗を“業務の言葉”に揃える(契約の統一) |
| ドメイン(業務ルール) | 値オブジェクト/整合性/ルール | ルール違反を明確化(ドメイン例外) |
| インフラ(DB/外部/IO) | DB/HTTP/ファイル/OS | 下位例外を捕捉し、証拠を残して上位へ渡す |
この記事の「例外変換」は、例えば インフラ→アプリ、アプリ→UI の境界で「利用側が判断できる例外」に揃える話です。
- 握り潰し禁止(証拠の保持)
- 再throwの作法(足跡の維持)
- 層をまたぐときの例外変換(利用側の約束に揃える)(契約の統一)
0. このページの読み方(困ったらここから)
障害が起きて、すでにログを見に行っている場合は、この順で十分です。
- 例外ログの発見(Error/Exceptionの探索)
- 発火点の特定(スタックトレースの先頭付近の確認)
- 同一操作の束の回収(相関ID/操作IDがある場合の絞り込み)
- 前後関係の追跡(同一操作のInfo/Warnの確認)
- 契約違反の疑い(下位例外の漏れ/握り潰し/多重ログの確認)
「例外が出ているはずなのに何も出ない」場合は、最初に握り潰し(空catch/成功扱い)を疑います。
1. このページのゴール
このページの到達点は、次の状態です。
- 握り潰し禁止の固定(証拠の消失防止)
- 再throw手順の固定(
throw;の統一) - 層をまたぐときの例外変換(利用側の約束に揃える)方針の固定(上位へ渡す約束の統一)
- ログ位置の整理(多重ログの抑止)
- レビュー観点への分解(機械的な指摘の可能化)
2. 似ているが別物(混ぜると詰む3つ)
よく混ざるのは、次の3つです。
- 握り潰し(失敗の成功扱い)
- 再throw(例外の上位伝搬)
- 層をまたぐときの例外変換(利用側の約束に揃える)(契約の統一)
この3つが混ざると「どこで、何が、なぜ消えたか」が追えなくなります。
3. 掟(要点)
- 握り潰し禁止(少なくとも証拠の保持)
- 再throwは
throw;(足跡の維持) - 層をまたぐときの例外変換(利用側の約束に揃える)(上位へ渡す約束の統一)
掟1: catchで例外を握り潰さない
なぜ効くのか
握り潰しは再現不能の入口です。
現象が「たまに失敗する」から「何も起きていない」に変わり、さらに危険な「成功扱いの仕様」へ化けます。
失敗の証人(例外)を消すと、調査の入口が閉じます。
実装の要点
- 空catchの禁止
- 証拠の残置(ログ/相関ID/入力/状態)
- 復旧不能時の上位伝搬(再throw/言い換え)
失敗例(握り潰し)
try
{
DoWork();
}
catch
{
// 証拠ゼロ、成功扱い
}
最小の形(証拠を残して流す)
try
{
DoWork();
}
catch (Exception ex)
{
_logger.LogError(ex, "DoWork failed: {JobId}", jobId);
throw; // 再throw、足跡の維持
}
掟2: 再throwはthrow;(足跡の維持)
足跡(スタックトレース)の意味
例外には「どのメソッドを経由してここまで来たか」という呼び出し履歴(スタックトレース)が付いています。
この履歴が残っていると、原因箇所(最初に落ちた地点)へ最短で辿れます。
throw ex; は、catch地点で“新しく投げた”形になるため、履歴の起点がcatch側へ寄ります。
その結果、調査がcatch付近で行き止まりになりやすいです。
ここが誤解されやすい点
throw; は「新しい例外の作成」ではありません。
同じ例外を、そのまま上位へ再送出します(情報の取りこぼしは発生しません)。
「契約を変える」必要がある場合だけ、例外型を作り直します。
そのときは 元の例外をInnerExceptionとして保持します。
使い分け
- 原因の保持(例外型を変えない):
throw; - 契約の変更(例外型を変える):
throw new XxxException("...", ex);
例(OK/NG)
catch (Exception ex)
{
_logger.LogError(ex, "UseCase failed");
throw; // OK: 同一例外の再送出、足跡の維持
}
catch (Exception ex)
{
_logger.LogError(ex, "UseCase failed");
throw ex; // NG: 起点の寄り、発火点の追跡困難
}
掟3: 層をまたぐときに「利用側の約束」に揃える(上位へ渡す約束の統一)
なぜ効くのか
層をまたぐときに例外の意味を「利用側の約束」に揃えると、上位は下位の都合を知らずに済みます。
取り決めがないまま下位例外(ライブラリ例外)がUIまで漏れると、UIが「DB/HTTP/ファイル」などの都合で分岐し始めます。
表示文言、リトライ方針、問い合わせ導線が散らばり、呼び出し側が保守不能になりやすいです。
下位例外(ライブラリ例外)の代表例
| 分類 | 例外型(例) | よくある場面 |
|---|---|---|
| DB |
SqlException / DbException
|
制約違反、接続断、タイムアウト |
| HTTP/通信 |
HttpRequestException / SocketException
|
接続失敗、DNS、プロキシ |
| タイムアウト/キャンセル | TaskCanceledException |
タイムアウト、Cancel |
| IO |
IOException / FileNotFoundException
|
欠損、共有違反 |
| 権限 | UnauthorizedAccessException |
ACL/UAC |
| 形式不正 |
FormatException / JsonException
|
入力、設定、JSON破損 |
| 破棄済み | ObjectDisposedException |
Dispose後アクセス |
| 状態不整合 | InvalidOperationException |
呼び出し順、状態破綻 |
層ごとの基本方針(ログと契約)
| 層 | 例外の扱い | 目的 |
|---|---|---|
| インフラ | 下位例外の発生源ログ化 | 証拠の保持 |
| アプリ | 上位契約への言い換え | 契約の統一 |
| UI | 表示向け整形 | 体験の保護 |
実装例(発生源で証拠、境界で言い換え)
try
{
return await _repo.LoadAsync(id, ct);
}
catch (SqlException ex)
{
_logger.LogError(ex, "DB failed: {Id}", id);
throw new InfrastructureException("DB access failed", ex); // 境界での契約統一
}
実装例(アプリで業務寄りへ寄せる)
public async Task<Customer> LoadCustomerAsync(CustomerId id, CancellationToken ct)
{
try
{
return await _repo.LoadAsync(id, ct);
}
catch (InfrastructureException ex)
{
throw new UseCaseException($"顧客情報の取得に失敗しました: {id}", ex); // 上位向け契約
}
}
UIでの扱い(多重ログの回避)
- 例外ログの境界集約(同一例外の多重ログ回避)
- UI表示の最小化(詳細追跡のログ側寄せ)
try
{
await _useCase.LoadCustomerAsync(id, ct);
}
catch (UseCaseException)
{
ShowError("処理に失敗しました。再試行しても改善しない場合は問い合わせが必要です。");
}
どこでcatchするか(採用判断基準)
- その場で復旧可能: 捕捉+復旧+証拠
- 契約統一が必要: 境界で言い換え+証拠
- 契約変更なし: 証拠+
throw; - 成功扱いへの変換: 原則禁止(必要なら根拠と代替証拠経路)
catchを総点検する羽目になる典型ケース(業務で起きる)
検索で辿り着きやすい言い回しを、症状として並べます。
| 検索で来る状況(症状) | よくある原因 | まず見る所 |
|---|---|---|
| catchしているのにログが出ない | 空catch、成功扱いreturn | catchブロック、return/continue |
| エラーが出ているはずなのに画面は平気 | 握り潰し、UI状態だけ更新 | catch内の分岐、成功扱い |
| ログはあるが原因箇所が分からない |
throw ex;混入 |
再throw箇所 |
| 端末や環境によってだけ落ちる | 下位例外の漏れ、契約不在 | 境界の言い換え箇所 |
| 似たログが大量に出る | 多層LogError、多重ログ | ログ位置、例外の同一性 |
判例(OK/NG)
| 観点 | OK例 | NG例 | 事故の形 | レビュー視点 |
|---|---|---|---|---|
| 握り潰し | LogError + throw; |
catch { } |
証拠消失、再現不能 | 空catchの有無 |
| 再throw | throw; |
throw ex; |
発火点追跡困難 | 再throwの書式 |
| 境界 | 契約例外への言い換え | 下位例外の漏れ | 上位の下位依存 | UIまでの例外型 |
| ログ位置 | 境界で1回 | 多層で連発 | ノイズ埋没 | LogErrorの重複 |
| 後始末 | finally/復旧 | 状態復旧なし | UI破綻継続 | 例外経路の復旧 |
容赦なき断罪: レビュー観点
| 観点 | ありがちな見落とし | 事故の形 | 指摘の方向性 |
|---|---|---|---|
| 証拠 | 空catch、情報不足 | 調査入口の消失 | 証拠経路の追加 |
| 履歴 |
throw ex;混入 |
発火点不明 |
throw;統一 |
| 契約 | 下位例外の漏れ | UI分岐爆発 | 境界で言い換え |
| 多重ログ | 層ごとLogError | ノイズ埋没 | 境界集約 |
| 後始末 | 例外時復旧なし | 状態破綻 | finally/using追加 |
禁書庫A: 逆引き(症状→原因→対策)
| 症状 | ありがちな原因 | 切り分け | 最短の対処 | 再発防止 |
|---|---|---|---|---|
| ログが残らず調査が止まる | 空catch、未ログ | catch確認 | LogError+throw | 空catch禁止 |
| 発火点が不明 | throw ex; |
再throw確認 |
throw;修正 |
ルール化 |
| UIに原因が見えない | 契約不在 | UI直下catch | 契約例外化 | 境界統一 |
| 例外ログが多すぎる | 多重ログ | 例外同一性 | 境界集約 | 運用固定 |
| 例外後に画面が壊れる | 復旧不足 | 状態確認 | finally復旧 | 観点固定 |
禁書庫B: チートシート
- 最初に見る地点(最初の発火点)
- ログが無い疑い(握り潰し/成功扱い)
- 例外型漏れ疑い(契約不在)
- 多重ログ疑い(境界未整理)
- 後始末不足疑い(finally/using不足)
.NET Framework 4.8で差が出やすい点(実務上有意な場合のみ)
- 例外のシリアライズ運用が絡む場合の注意(別プロセス/旧連携)
-
[Serializable]、シリアライズ用コンストラクタ
-
- DB例外型の差分(利用クライアント差)
- .NET Framework 4.8:
System.Data.SqlClient - .NET 8:
Microsoft.Data.SqlClient - 捕捉対象の実ライブラリ合わせ
- .NET Framework 4.8:
推奨タグ(最大5)
- C#
- .NET
- 例外設計
- 障害対応
- レビュー