どこで例外を `catch` してログを出し、エラーハンドリングするべきなのか - 呼び出し階層の境界と責務を実務向けに整理

· · 例外処理, ログ, エラーハンドリング, 設計, C# / .NET

目次

  1. まず結論
  2. catch とログとエラーハンドリングは別物
    • 2.1. catch すること
    • 2.2. ログを出すこと
    • 2.3. エラーハンドリングすること
    • 2.4. 例外を翻訳すること
  3. まず見る判断表
  4. 呼び出し階層のどこで何をするか
    • 4.1. 一番深い helper / utility / private method
    • 4.2. 外部 I/O 境界: Repository / Gateway / SDK ラッパー
    • 4.3. Application Service / UseCase
    • 4.4. UI / HTTP / Job / Message の境界
    • 4.5. 最後の未処理例外ハンドラ
    • 4.6. 1 本の呼び出し階層で見る
  5. 想定内の失敗と想定外の例外を分ける
  6. ログはどこで何回出すべきか
  7. よくある NG
  8. レビュー時のチェックリスト
  9. ざっくり使い分け
  10. まとめ
  11. 参考資料
  12. 関連記事

1. まず結論

  • 原則は、深い層で広く catch しない です。catch する場所は、失敗単位を定義できる 境界 に寄せます。
  • ログは、1 つの失敗に対して 1 つの主ログ を基本にします。各層で同じ例外を Error し続けると、読む側が困ります。
  • 一番深い層の責務は、後始末、局所ロールバック、例外の翻訳、必要なら限定的な retry です。再スローするなら、通常はそこでは主ログを出しません。
  • 画面操作、HTTP リクエスト、1 件のジョブ、1 件のメッセージ処理のような 処理境界 が、もっとも自然な主ログ地点になりやすいです。
  • 想定内の失敗は、そのユースケースの単位で 結果化 します。必ずしも全部を例外として上まで投げ続ける必要はありません。
  • AppDomain.UnhandledException、WPF の DispatcherUnhandledException、WinForms の ThreadException、ASP.NET Core の例外ハンドラ、ホストの最終例外処理は、回復ポイントというより最後の記録地点 です。
  • ユーザーキャンセルやシャットダウンによる OperationCanceledException は、普通は Error 扱いしません
  • 迷ったら、次の順で見ます。
    1. この場所で本当に判断できるか
    2. 失敗した単位がここで分かるか
    3. ここで状態を戻せるか、作り直せるか
    4. ここでログすると、同じ例外を上でもログしないか

要するに、catch できる場所 ではなく、責任を持って判断できる場所 で受ける のが基本です。

2. catch とログとエラーハンドリングは別物

2.1. catch すること

catch は、例外を一度受け取って処理の流れを変えることです。
ただ、それ自体は 回復 ではありません。

たとえば、下位メソッドで例外を受けても、

  • 何をユーザーへ見せるべきか分からない
  • その失敗で画面全体を止めるべきか、今回の操作だけ失敗でよいか分からない
  • その request や job を継続してよいか分からない

のであれば、その場所は catch の適地ではない ことが多いです。

2.2. ログを出すこと

ログは、「例外が起きた」という事実だけでなく、どの仕事が失敗したか を後から追うための記録です。

そのため、よいログ地点には、たいてい次のどれかがあります。

  • requestId / traceId
  • userId
  • orderId / fileId / batchId
  • 何件目の入力か
  • どの画面操作か
  • どのキュー・どのメッセージか

深い helper や共通関数は、技術詳細は知っていても、この文脈を持っていないことが多いです。
だから、技術詳細を知っている場所運用文脈を知っている場所 は、しばしば別です。

2.3. エラーハンドリングすること

ここでいうエラーハンドリングは、たとえば次です。

  • 画面へエラーメッセージを出す
  • HTTP では 4xx / 5xx を返す
  • 1 件だけ失敗として次の件へ進む
  • その subsystem を再初期化する
  • プロセスを終了して再起動に任せる
  • リソースを解放して安全に抜ける

つまり、呼び出し元やユーザーから見た失敗の形を決めること です。

2.4. 例外を翻訳すること

実務では、catch と「処理する」の間に、もう 1 つ大事な仕事があります。
それが 翻訳 です。

たとえば、

  • HttpRequestException
  • IOException
  • JsonException
  • DB ドライバ固有の例外
  • vendor SDK 固有の例外

を、そのまま UI や Controller へ漏らすと、上位層が下位実装の都合を知り始めます。

そこで境界面では、

  • 「支払いサービスへ接続できなかった」
  • 「CSV 形式が壊れていた」
  • 「保存先へ書き込めなかった」
  • 「装置応答が不正だった」

のように、その層で意味のある失敗 へ変換します。

ここで大事なのは、翻訳とログは同じではない ことです。
翻訳して上へ投げるだけなら、普通は主ログまでは出しません。

3. まず見る判断表

まずは次の表で、大きい方針を決めると整理しやすいです。

場所 基本方針 主ログ 主な責務
helper / utility / private method 原則として広く catch しない 出さない finally による後始末、局所ロールバック、必要最小限の文脈追加
Repository / Gateway / SDK ラッパー 具体例外だけ受ける 通常は出さない 例外翻訳、限定的 retry、接続やハンドル破棄
Application Service / UseCase 想定内の失敗を結果化する 飲み込むならここで必要に応じて 失敗単位の定義、部分失敗化、ユースケース単位の判断
UI / Controller / API / Job / Message 境界 想定外例外の主な受け口 ここが主ログになりやすい ユーザー向け応答、HTTP 応答、次件継続、abort 判断
未処理例外ハンドラ / ホスト最終境界 取りこぼし防止の最後の砦 Critical 最終記録、flush、dump、終了・再起動導線

次の図にすると、だいたいこうです。

flowchart TD
    A["例外が起きた"] --> B{"この場所で retry / 結果化 / 継続可否を決められる?"}
    B -- "いいえ" --> C["原則 catch せず上へ送る"]
    B -- "はい" --> D{"ここは層の境界か?"}
    D -- "いいえ" --> E["局所 cleanup のみ"]
    D -- "はい" --> F["必要なら意味のある例外へ翻訳"]
    E --> G{"ここで失敗単位と運用文脈が分かる?"}
    F --> G
    G -- "いいえ" --> H["主ログは出さず上位へ送る"]
    G -- "はい" --> I["主ログを 1 回出して応答を決める"]
    I --> J["必要なら終了 / 再初期化 / 次件継続"]

この図のポイントは 2 つです。

  1. catch の最初の理由は、回復か cleanup であって、ログではない
  2. ログの最初の理由は、運用文脈がそろったことであって、例外を見つけたことではない

4. 呼び出し階層のどこで何をするか

4.1. 一番深い helper / utility / private method

ここでは、原則として 広く受けない のが基本です。

たとえば、文字列変換、パース、計算、内部整形、共通 helper のような場所は、

  • どの画面操作だったか
  • どの request だったか
  • 今回だけ失敗でよいのか
  • 画面全体を閉じるべきか

を判断できません。

この層でやってよいのは、主に次です。

  • finally での resource 解放
  • 途中まで壊した局所状態のロールバック
  • 例外メッセージへの最小限の文脈追加
  • より適切な例外型への置き換え
  • 再利用不能になったオブジェクトの破棄

逆に避けたいのは次です。

  • catch (Exception) して null / false / 空配列を返す
  • ここで MessageBox を出す
  • ここで Error ログを出してから再スローする
  • 元に戻せないのに「とりあえず続ける」

特に危ないのは、途中まで自分の状態を書き換えたあとで失敗したのに、そのまま使い続ける パターンです。
この場合は、その場で元に戻せるなら戻す、戻せないなら破棄前提にする、のどちらかです。

4.2. 外部 I/O 境界: Repository / Gateway / SDK ラッパー

ここは、catch する理由がはっきりしている層です。

なぜなら、ここでは下の層の実装都合が表に出るからです。

  • DB ドライバ例外
  • HTTP 通信例外
  • ファイル I/O 例外
  • COM / P/Invoke / vendor SDK の固有例外
  • パースライブラリやシリアライザの例外

この層でやることは、だいたい次の 4 つです。

  1. 具体例外を受ける
    広い Exception ではなく、意味がある具体例外を受けます。

  2. 意味のある失敗へ翻訳する
    上位層が下位の都合を直接知らなくてよいようにします。

  3. 局所的に retry するなら、ここでやる
    ただし、条件は厳しめです。
    • 一時的失敗と分かっている
    • 冪等性がある
    • 上限回数と待ち方が決まっている
    • 失敗時の最終挙動が明確
      この 4 つが揃うときだけです。
  4. 壊れた接続やハンドルを捨てる
    「次も同じオブジェクトで続ける」より、「接続を作り直す」のほうが安全なことは多いです。

ここでのログ方針は、次のように考えるとぶれにくいです。

  • 上へ再スローするなら、普通は主ログを出さない
  • ここで例外を飲み込んで結果へ変えるなら、その時点で必要なログやメトリクスを出す
  • retry 中の各試行は Debug / Information / Warning の範囲で扱い、最終失敗だけを強めに記録 する

この層は、翻訳する場所 であって、通常は 最終判断する場所 ではありません。

4.3. Application Service / UseCase

ここは、「今回の仕事をどう失敗させるか」を決める層です。

たとえば、

  • 保存処理
  • 注文確定
  • CSV 取り込み
  • バッチ 1 件分の処理
  • メッセージ 1 件分の反映

のような、ユースケースとしてまとまりのある単位 がここにあります。

この層では、次のような判断ができます。

  • validation エラーは今回だけ失敗
  • NotFound は 404 相当
  • 業務ルール違反はユーザー修正待ち
  • CSV の 1 行不正は Warning で継続
  • 外部サービス一時障害は処理全体を失敗
  • 途中成果を破棄して最初からやり直し

つまり、失敗単位 を決められる場所です。

この層が向いているのは、たとえば次です。

  • 想定内の失敗を Result や失敗 DTO の形にする
  • 部分失敗を集計する
  • 何件まで失敗を許して継続するか決める
  • エラーコードやユーザー向けメッセージキーへ変換する

逆に、この層でやるべきでないのは、UI 表示や HTTP 応答本文の組み立てを持ち込みすぎることです。
ここでは、ユースケースとしての意味 までを決め、最終的な見せ方は境界側へ任せるほうが分離しやすいです。

4.4. UI / HTTP / Job / Message の境界

ここが、多くのアプリで 主ログ地点 になりやすいです。

たとえば次です。

  • WinForms / WPF の「保存」ボタン押下 1 回
  • ASP.NET Core の HTTP request 1 本
  • worker のメッセージ 1 件
  • バッチの入力 1 件
  • スケジュール実行ジョブ 1 回

この場所は、次を知っています。

  • 何の操作だったか
  • 誰の操作だったか
  • 何件目だったか
  • どの request / batch / message だったか
  • 失敗したらユーザーや呼び出し元へ何を返すか

だから、

  • 想定外例外をここでまとめて受ける
  • 文脈付きで主ログを 1 回出す
  • エラーダイアログ、HTTP 500、Problem Details、ジョブ失敗、次件継続などへ変換する

という役割を持ちやすいです。

この層で大事なのは、広く受けること自体 ではなく、広く受けたあとに何を返すかが定義されていること です。

たとえば batch や queue では、次の 2 段階で分けると整理しやすくなります。

  • 1 件境界で受ける
    1 件だけ失敗として次へ進めるかどうかを決める
  • 親ループでは広く握りつぶさない
    親ループが死んだら、プロセス全体の再起動に寄せる

「1 件ずつ失敗させて継続」と「親ループが想定外例外で落ちても黙って生きる」は、全然違います。

4.5. 最後の未処理例外ハンドラ

ここは 最後の砦 です。
魔法の回復ポイントではありません。

代表的には、次があります。

  • AppDomain.UnhandledException
  • WPF の Application.DispatcherUnhandledException
  • WinForms の Application.ThreadException
  • ASP.NET Core の例外処理ミドルウェアやハンドラ
  • Generic Host / worker / BackgroundService の最終例外処理

この層の主な責務は、次です。

  • 最終ログ
  • flush
  • dump 採取導線
  • セッション情報や直前文脈の退避
  • 終了コードや再起動導線の整備

逆に、ここへ期待しすぎないほうがよいこともあります。

  • ここまで来た時点で、上の設計漏れであることが多い
  • すでに状態が壊れている可能性がある
  • ロック保持中のこともあり、重い処理は危ない
  • 見かけ上続けられても、続けて安全とは限らない

.NET まわりで押さえておきたい実務上の注意もあります。

  • AppDomain.UnhandledException は、未処理例外の通知と記録 のためのイベントです。以後に回復処理を盛り込みすぎるのは危険です。
  • WPF の DispatcherUnhandledException では Handled = true にして見かけ上続ける道がありますが、回復可能かどうかの判断 が先です。
  • WinForms の ThreadException も、そこで対処したあとにアプリケーションが 不明な状態 になる可能性があります。
  • ASP.NET Core の例外処理ミドルウェアは、後続の例外を受けられるようパイプラインの早い段階 に置く必要があります。
  • BackgroundService の未処理例外は、.NET 6 以降では ログされ、既定でホスト停止 に寄ります。親ループで全部握りつぶすより、停止して再起動戦略へ乗せるほうが安全なことがあります。

特にデスクトップアプリでは、「未処理例外を拾って継続する」道が存在します。
ただ、継続できること継続してよいこと は別です。

4.6. 1 本の呼び出し階層で見る

たとえば次のような流れを考えます。

flowchart LR
    A["UI / Controller / Job 境界"] --> B["Application Service / UseCase"]
    B --> C["Domain / 業務ロジック"]
    C --> D["Repository / Gateway / SDK wrapper"]
    D --> E["DB / HTTP / File / Vendor SDK"]

このとき、役割はだいたい次のように分かれます。

保存ボタン → SaveOrderUseCasePaymentGateway → HTTP

  • PaymentGateway
    • 通信失敗や応答形式異常を受ける
    • 「支払いサービス接続失敗」「支払いサービス応答不正」へ翻訳する
    • retry するならここで条件付きで行う
    • 再スローするなら、通常は主ログしない
  • SaveOrderUseCase
    • 支払い拒否のような想定内失敗を、結果へ変える
    • 「今回の注文確定だけ失敗」として扱う
    • 失敗結果を UI や API に返しやすい形にする
  • UI ボタンハンドラ / Controller
    • 想定外例外をまとめて受ける
    • orderIduserIdrequestId 付きで主ログする
    • ダイアログ表示や 500 / 503 応答へ変換する
  • 未処理例外ハンドラ
    • そこまで漏れたものだけ記録する
    • dump や最終 flush を行う
    • 回復ではなく、終了導線を優先する

この分け方にすると、技術詳細は下で閉じ、運用文脈は上で付け、判断は境界で行う という形になります。

5. 想定内の失敗と想定外の例外を分ける

このテーマで一番大事なのは、全部を同じ「例外」として扱わない ことです。

まずは次のように分けると整理しやすいです。

失敗の種類 まず扱う場所 典型的な扱い
validation 不備 UseCase / request 境界 入力エラーとして返す
NotFound / Conflict UseCase / Controller 404 / 409 や画面メッセージ
ユーザーキャンセル / シャットダウン 操作境界 キャンセル扱い。通常は Error にしない
CSV の 1 行不正 1 行境界 Warning で記録し、次へ進む
一時的 timeout で最終的に失敗 I/O 境界〜request 境界 retry 後に失敗として返す
NullReferenceException、前提崩れ request / job 境界 主ログして失敗応答
AccessViolationException、深刻な OutOfMemoryException、native 境界破壊臭 最終境界 Critical として終了寄り

想定内の失敗は、設計で先に決められる失敗 です。
想定外の例外は、このあとも状態を信用してよいか怪しい失敗 です。

この 2 つを分けるだけで、次の事故が減ります。

  • NotFound を毎回 Error にする
  • ユーザーキャンセルを障害扱いする
  • 本当に危ない前提崩れを、「今回だけ失敗」で流してしまう

6. ログはどこで何回出すべきか

ログの設計では、catch の位置よりも、誰が主ログを出すか を先に決めるほうが重要です。

基本ルールは次のとおりです。

  1. 1 つの失敗に対して、主たる Error / Critical ログは 1 回
  2. 下位層は、必要なら 翻訳と文脈追加 をする
  3. 上位の境界は、失敗単位と運用文脈付きで主ログ を出す
  4. その場で 飲み込む層だけ が、その飲み込んだ失敗の記録責任を持つ
  5. 想定内の失敗は、毎回 Error にしない
  6. OperationCanceledException は、普通の障害ログから分ける

ログ地点をざっくり表にすると、次のようになります。

状況 主にログする場所 レベルの目安 補足
validation エラー request / use case 境界 Information またはログなし 障害ではなく契約上の失敗
ユーザーキャンセル / shutdown 操作境界 Debug / Information 普通は Error にしない
retry 中の一時失敗 retry を持つ層 Debug / Warning 最終失敗前は騒ぎすぎない
retry し尽くして失敗 request / job 境界、またはその場で飲み込む層 Warning / Error 失敗単位付きで記録
1 行だけ不正で継続 item 境界 Warning fileId, rowNumber を付ける
request 全体を落とす想定外例外 request / UI / job 境界 Error requestId, userId, entityId を付ける
プロセス終了級 未処理例外境界 Critical flush, dump, 再起動導線

実務では、次の重複ログがかなり多いです。

  • Repository が Error
  • Service が同じ例外を Error
  • Controller がまた Error
  • 最後の未処理例外ハンドラでも Critical

これだと、1 回の障害で同じスタックトレースが何本も並びます。
読む側にとって欲しいのは、同じ stack trace の 4 本ではなく、1 本の主ログと、必要なら少数の補助ログ です。

言い換えると、ログは 1 回、文脈は必要なだけ が基本です。

7. よくある NG

7.1. 深い層で catch (Exception) して null / false を返す

これは原因の情報を落としやすいです。
しかも、呼び出し側は「本当にデータがなかった」のか「途中で壊れた」のか区別できなくなります。

7.2. 各層で Error ログしてから再スローする

最も多い重複ログの原因です。

  • 下位層は翻訳だけ
  • 上位境界が主ログ

という分担にすると、かなり減らせます。

C# で再スローするなら、スタックトレースを壊さないように throw; を使うのが基本です。

7.3. ライブラリ層や共通部品が UI を直接出す

共通部品が MessageBox を出したり、HTTP 応答本文を直接決めたりすると、再利用性も責務分離も崩れます。
下位層は、意味のある失敗を返すところまで に寄せたほうが安全です。

7.4. OperationCanceledException を障害として Error ログする

キャンセルは制御フローの一部です。
毎回 Error にすると、本当の障害が埋もれます。

7.5. 外部副作用があるのに、安易に retry する

メール送信、課金、装置コマンド、ファイル移動のように、同じ操作をもう一度やると事故るものは多いです。
retry は 一時的失敗冪等性 の両方が見えているときだけです。

7.6. 最後の未処理例外ハンドラで何でも回復しようとする

ここは、最後の保険です。
設計の中心に置く場所ではありません。

回復戦略は、その前の層、つまり request / job / subsystem の境界に置いたほうが安全です。

8. レビュー時のチェックリスト

例外処理のレビューでは、次を順番に見ると整理しやすいです。

  • この catch は、何を判断するためにあるのか を 1 文で言えるか
  • この場所で、retry / 結果化 / 継続可否 / ユーザー応答を本当に決められるか
  • ここでログしたら、上の層でも同じ失敗を Error しないか
  • 下位実装固有の例外を、境界で意味のある失敗へ翻訳しているか
  • 途中で壊れた状態を、ここで戻せるか。戻せないなら破棄前提になっているか
  • OperationCanceledException を普通の障害から分けているか
  • item 単位継続なのか、request 単位失敗なのか、process 終了なのかが明確か
  • 最後の未処理例外ハンドラへ、回復ではなく記録を期待しているか
  • ログへ requestId / userId / batchId / fileId / rowNumber など、失敗単位の文脈が乗っているか
  • 「想定内の失敗」と「前提崩れ」を同じ扱いにしていないか

このチェックリストで特に効くのは、「この catch は何を決めているのか」 を毎回言葉にすることです。
ここが言えない catch は、たいてい不要か、場所が深すぎます。

9. ざっくり使い分け

最後に、かなり短くまとめると次の表です。

場面 catch ログ エラーハンドリング
helper / utility 原則しない しない しない
Repository / Gateway / SDK ラッパー 具体例外だけ受ける 通常は主ログしない 翻訳、局所 retry、接続破棄
UseCase / Application Service 想定内失敗を受ける 飲み込むなら必要に応じて 結果化、部分失敗化
UI / Controller / request / item / job 境界 想定外例外を広く受ける 主ログ 応答、メッセージ、継続 / abort
未処理例外ハンドラ 漏れたものだけ Critical 最終記録、終了導線

迷ったときは、まず次だけで十分です。

  1. 深い層では広く握らない
  2. 境界で受ける
  3. 主ログは 1 回
  4. 飲み込む層が責任を持つ
  5. 最後の未処理例外は記録と終了導線

10. まとめ

例外処理は、「どこでも catch できるから、どこでも catch する」話ではありません。

見る順番としては、だいたい次で十分です。

  1. この場所で本当に判断できるか
  2. ここで失敗単位が分かるか
  3. ここで状態を戻せるか、作り直せるか
  4. ここでログすると重複しないか
  5. ここは回復地点か、それとも最後の記録地点か

この順で見ると、呼び出し階層の整理はかなりしやすくなります。

特に大事なのは、次の 3 つです。

  • 深い層は、主に翻訳と cleanup
  • 境界は、主に判断と主ログ
  • 最後の未処理例外ハンドラは、主に記録と終了導線

言い換えると、
例外は境界で受けて、文脈を付けて、回復できる場所だけで処理する のが基本です。

これが決まると、コードレビューでも障害調査でも、かなりぶれにくくなります。

11. 参考資料

12. 関連記事

関連する記事

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

Windows アプリがプログラムミスによる例外で落ちても確実にログを残すには - in-process に賭けない設計と WER / 最終ログ / 監視プロセスのベストプラクティス

Windows アプリが想定外例外やプログラムミスで落ちても、あとから原因を追える証跡を残すために、通常ログ、最終クラッシュマーカー、WER LocalDumps、監視プロセスをどう組み合わせるべきかを整理します。

記事を読む

関連トピック

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

ブログ一覧に戻る