例外処理でcatchとログはどこに置くべきか

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

更新履歴(4件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
導入部を追加し、この記事で使う用語(失敗単位・主ログ・結果化)を定義しました。あわせて悪い例と良い例のコード、1回の失敗で各層が実際に出すログの例、3つの判断表の使い分けを追加しています。
本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589783)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「例外処理でcatchとログはどこに置くべきか」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589783 https://staging.comcomponent.com/blog/2026/04/15/000-exception-catching-logging-error-handling/

DOI(最新版)
10.5281/zenodo.21589783
DOI(この版)
10.5281/zenodo.21732801

例外処理のコードレビューで繰り返し出てくる指摘は、だいたい次の 3 つに集約されます。

  • 一番深い共通関数が catch (Exception) していて、呼び出し元からは「データが無かった」のか「途中で壊れた」のか区別できない
  • 1 回の障害なのに、Repository、Service、Controller、未処理例外ハンドラで同じスタックトレースが 4 本並ぶ
  • 利用者がキャンセルしただけなのに Error ログが出て、本当に危ない障害がその中に埋もれる

どれも try / catch の書き方が下手なわけではありません。どこで受けるか、誰がログを出すか、どこで失敗の形を決めるか という役割分担が決まっていないだけです。役割が決まっていないと、各層の開発者がそれぞれ「念のため」で catch とログを足していき、結果として原因が見えないコードになります。

この記事は、C# / .NET で業務アプリや Web API を書いている開発者と、その設計レビューをする立場の方に向けて、例外を捕まえる境界、主ログを出す場所、回復判断の責務をどう分けるかを整理します。呼び出し階層のどこで何をするかを先に決めてしまえば、レビューでも障害調査でも判断がぶれにくくなります。

この記事で使う用語

本文で繰り返し出てくる 3 つの言葉を、先に定義しておきます。一般的な用語ではなく、この記事の中での意味です。

用語 この記事での意味
失敗単位 「何が 1 回失敗したか」を表す、業務上まとまりのある処理の単位です。画面操作 1 回、HTTP リクエスト 1 本、ジョブ 1 件、メッセージ 1 件、CSV の 1 行などが該当します。ログにも応答にも、この単位が現れます
主ログ 1 つの失敗に対して 1 回だけ出す、Error または Critical の記録です。失敗単位と運用文脈 (requestId、userId、対象 ID など) を伴います。これ以外の記録は補助ログとして Debug / Information / Warning で扱います
結果化 例外として投げ続けるのをやめて、Result 型や失敗を表す DTO のような 戻り値 へ変えることです。想定内の失敗を、呼び出し元が分岐で扱える形にする操作を指します

目次

  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. 関連記事

この記事の知識マップ

この記事はC#/.NETの例外処理において、例外を捕まえる境界と主ログを出す場所を分けることを提案します。一番深いhelper/utility層では広くcatchせず、Repository/Gateway層は下位実装固有の例外を意味のある失敗へ翻訳し、Application Service/UseCase層は想定内の失敗を結果化し、UI/HTTP/Job境界が失敗単位と運用文脈をそろえて主ログを1回だけ出す地点になりやすいとしています。未処理例外ハンドラは回復ポイントではなく最後の記録地点で、終了・再起動導線を整えます。各層での重複したErrorログ、throw exによるスタックトレースの喪失、OperationCanceledExceptionを障害ログとして扱うことは、いずれも原因追跡を難しくするため避けるべき対応として位置づけられています。

例外処理でcatchとログをどこに置くべきかの知識マップ例外を捕まえる境界・主ログ・結果化・例外の翻訳という役割を、helper層からRepository/Gateway層・UseCase層・UI/HTTP/Job境界・未処理例外ハンドラへどう配分するかを示す図用いるのは非推奨推奨される対応原因になり得る原因になり得る防止する実装を担う利用する実装を担う推奨される対応実装を担う用いるのは非推奨原因になり得る原因になり得る実装を担う推奨される対応より先に行うべき前提とする利用する利用する用いるのは非推奨用いるのは非推奨用いるのは非推奨推奨される対応推奨される対応用いるのは非推奨例外を捕まえる境界主ログ深い層での広いcatchhelper/utility/private method層失敗単位障害原因追跡の困難各層での重複ログRepository/Gateway/SDKラッパー層例外の.NET変換局所的なretryApplication Service/UseCase層結果化想定内の失敗UI/HTTP/Job/Message境界OperationCanceledExceptionthrow ex;によるスタックトレース書き換えスタックトレースの喪失未処理例外ハンドラ終了・再起動導線BackgroundServiceの未処理例外想定外の例外

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全25件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

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. まず見る判断表

この記事には、似た形の表が 3 つ出てきます。役割が違うので、先に使い分けを書いておきます。

いつ見るか 何が書いてあるか
3 章 まず見る判断表 設計時。層ごとの責務を決めるとき 場所ごとの基本方針、主ログを出すかどうか、主な責務
6 章 ログ地点の表 実装時。ログ行を書く手が止まったとき 失敗の種類ごとに、どこで何レベルで記録するか
9 章 ざっくり使い分け レビュー時。最後の確認をするとき 3 章と 6 章を catch / ログ / エラーハンドリングの 3 列に畳んだもの

まずはこの表で、大きい方針を決めてしまうのが楽です。

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

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

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

この図のポイントは 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 だったか
  • 失敗したらユーザーや呼び出し元へ何を返すか

この 5 つがそろっているのは、多くのアプリでこの層だけです。下の層は技術詳細を知っていても運用文脈を持たず、上の未処理例外ハンドラまで来ると今度は失敗単位が分からなくなります。だから、

  • 想定外例外をここでまとめて受ける
  • 文脈付きで主ログを 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 本の呼び出し階層で見る

たとえば、こんな流れを考えます。

UI / Controller / Job 境界Application Service / UseCaseDomain / 業務ロジックRepository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

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

保存ボタン → SaveOrderUseCasePaymentGateway → HTTP

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

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

各層が実際に出すログ

同じ 1 回の失敗に対して、各層が実際に何を書くかまで具体化すると、分担がはっきりします。この 1 回の注文で「支払いサービスが 2 回タイムアウトし、3 回目で成功したが、その後の在庫引き当てで前提崩れが起きた」という想定です。

出すログ レベル メッセージの例
PaymentGateway retry の各試行 Warning 支払いサービスへの接続を再試行します。attempt={Attempt}/{MaxAttempts}, orderId={OrderId}
PaymentGateway 翻訳して再スローするとき 出さない ─ (主ログは境界の役目)
SaveOrderUseCase 想定内の失敗を結果化したとき Information 注文の支払いが拒否されました。orderId={OrderId}, reason={DeclineReason}
SaveOrderUseCase 想定外の例外 出さない ─ (そのまま境界へ送る)
UI ボタンハンドラ / Controller 想定外例外の主ログ Error 注文の確定に失敗しました。orderId={OrderId}, userId={UserId} + 例外オブジェクト
未処理例外ハンドラ 最終記録 Critical 未処理例外のためプロセスを終了します + 例外オブジェクト

ポイントは、Error が出るのは 1 行だけ という点です。retry の各試行は Warning、想定内の失敗は Information に落としてあるので、Error で検索すればこの障害が 1 件としてヒットします。

// 主ログ。例外オブジェクトを第 1 引数に渡し、失敗単位の文脈を名前付きで乗せる
_logger.LogError(ex, "注文の確定に失敗しました。orderId={OrderId}, userId={UserId}",
    orderId, userId);

例外オブジェクトを第 1 引数に渡すのを忘れると、スタックトレースが記録されません。_logger.LogError(ex.Message) のように文字列だけを渡すと、後から原因を追えなくなります。

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 の位置よりも、誰が主ログを出すか を先に決めるほうが重要です。

基本ルールは 6 つです。

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

ログ地点をざっくり表にしておきます。3 章の表が「どの層に何の責務を置くか」だったのに対して、こちらは 失敗の種類ごとに、どこで何レベルの記録を残すか の表です。実装中に「この catch でログを出すべきか」で迷ったら、ここを見てください。

状況 主にログする場所 レベルの目安 補足
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

ここからは、レビューで実際によく出てくる書き方を並べます。代表的な 3 つには、NG と OK の最小コードを付けました。コードは C# 10 / .NET 6 以降、nullable 参照型を有効にした前提 で、System.Text.JsonMicrosoft.Extensions.Logging を使っています。

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

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

// NG: 深い層で広く受けて null を返す
private static Order? LoadOrder(string path)
{
    try
    {
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<Order>(json);
    }
    catch (Exception)
    {
        // 呼び出し元からは、ファイルが無かったのか、JSON が壊れていたのか、
        // ディスクが読めなかったのかが区別できない
        return null;
    }
}

この層は、失敗をどう扱うべきかを判断できません。判断は境界へ渡し、ここでは 意味のある失敗へ翻訳するところまで にとどめます。

// この層が投げる、意味のある失敗を表す例外型
public sealed class OrderFileFormatException : Exception
{
    public OrderFileFormatException(string message, Exception? innerException = null)
        : base(message, innerException)
    {
    }
}

// OK: 翻訳だけして、判断は上の境界へ渡す
private static Order LoadOrder(string path)
{
    string json = File.ReadAllText(path);

    try
    {
        return JsonSerializer.Deserialize<Order>(json)
            ?? throw new OrderFileFormatException($"注文ファイルが空です: {path}");
    }
    catch (JsonException ex)
    {
        // 下位実装の都合である JsonException を、この層で意味のある失敗へ変える
        throw new OrderFileFormatException($"注文ファイルの形式が不正です: {path}", ex);
    }

    // IOException や UnauthorizedAccessException は翻訳せず、そのまま上へ送る。
    // 「ファイルが読めない」は、この層が意味を足せる失敗ではないため
}

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

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

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

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

// NG: 下位層でログしてから再スローする。上位でも同じ例外がログされて 2 本になる
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "支払いに失敗しました");
        throw;
    }
}

下位層は、翻訳して素通しするだけにします。

// PaymentGatewayException は OrderFileFormatException と同じ形の、
// 「支払いサービスとのやり取りが失敗した」ことを表す独自例外型です

// OK: 下位層 (PaymentGateway) は翻訳のみ。ログは出さない
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        throw new PaymentGatewayException(
            $"支払いサービスへ接続できませんでした。orderId={payment.OrderId}", ex);
    }
}

そのうえで、失敗単位と運用文脈がそろう境界で、主ログを 1 回だけ出します。

// OK: 境界で主ログを 1 回だけ出し、呼び出し元への応答を決める
[ApiController]
public sealed class PaymentController : ControllerBase
{
    private readonly ILogger<PaymentController> _logger;
    private readonly SaveOrderUseCase _useCase;

    public PaymentController(ILogger<PaymentController> logger, SaveOrderUseCase useCase)
    {
        _logger = logger;
        _useCase = useCase;
    }

    [HttpPost("orders/{orderId}/pay")]
    public async Task<IActionResult> PayAsync(string orderId, CancellationToken ct)
    {
        try
        {
            Receipt receipt = await _useCase.ExecuteAsync(orderId, ct);
            return Ok(receipt);
        }
        catch (PaymentGatewayException ex)
        {
            // 失敗単位 (この注文の支払い 1 回) と運用文脈がそろうのはここだけ
            _logger.LogError(ex, "注文 {OrderId} の支払いに失敗しました", orderId);
            return StatusCode(StatusCodes.Status502BadGateway);
        }
    }
}

C# で再スローするなら、スタックトレースを壊さないように throw; を使うのが基本です。throw ex; と書くと、その行でスタックトレースが上書きされ、本当の発生位置が失われます。

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

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

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

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

// NG: 広い catch にキャンセルまで巻き込んで Error にする
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
    // 利用者が「中止」を押しただけでも、ここへ来て Error が出る
    _logger.LogError(ex, "取り込みに失敗しました");
    throw;
}

catch 節は上から順に評価されるため、キャンセルだけを先に、より具体的な型で受けます。when 句を付けておくと、こちらが渡したトークンによる中断と、内部のタイムアウトなど別の理由で起きた OperationCanceledException を取り違えずに済みます。

// OK: キャンセルを先に拾って、障害ログから分ける
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // 利用者の中断やシャットダウン。制御フローの一部なので Error にしない
    _logger.LogInformation("取り込みを中断しました。fileId={FileId}", file.Id);
}
catch (Exception ex)
{
    // ここへ来るのは想定外の失敗だけ。失敗単位の文脈を付けて主ログを 1 回出す
    _logger.LogError(ex, "取り込みに失敗しました。fileId={FileId}", file.Id);
    throw;
}

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. ざっくり使い分け

最後に、3 章と 6 章を 1 枚に畳んだ確認用の表を置いておきます。レビューのときや、書き終えたコードを見直すときに、この 1 枚だけ見れば足りるようにしたものです。

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

迷ったときは、まずこの 5 つだけで十分です。

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

10. まとめ

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

見る順番は、だいたいこれで十分です。

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

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

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

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

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

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

11. 参考資料

12. 関連記事

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

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

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

例外はどの層でcatchすべきですか?
原則は、深い層で広くcatchせず、失敗単位を定義できる境界に寄せることです。画面操作1回、HTTPリクエスト1本、ジョブ1件、メッセージ1件のような処理境界が自然な受け口になります。catchできる場所ではなく、retry・結果化・継続可否を責任を持って判断できる場所で受けるのが基本です。深いhelperやutilityでは、finallyでの後始末や局所ロールバック、例外の翻訳までにとどめます。
例外のログは各層で出すべきですか?
1つの失敗に対して主たるError / Criticalログは1回が基本です。Repositoryで Error、Serviceで同じ例外をError、ControllerでまたError、と各層で出すと、1回の障害で同じスタックトレースが何本も並び、読む側が困ります。下位層は翻訳と文脈追加までにとどめ、requestIdやuserIdのような運用文脈がそろう上位の境界で主ログを出します。例外を飲み込んで結果へ変える層だけが、その失敗の記録責任を持ちます。
想定内の失敗と想定外の例外はどう分ければよいですか?
想定内の失敗は設計で先に決められる失敗で、validation不備やNotFoundなどはユースケースの単位で結果化し、毎回Errorログにしません。ユーザーキャンセルによるOperationCanceledExceptionも普通はError扱いしません。一方、NullReferenceExceptionのような前提崩れはrequest / job境界で主ログして失敗応答にし、AccessViolationExceptionや深刻なOutOfMemoryExceptionはCriticalとして終了寄りに扱います。この2つを分けるだけで、本当に危ない失敗が埋もれる事故が減ります。
未処理例外ハンドラでは何をすべきですか?
AppDomain.UnhandledException、WPFのDispatcherUnhandledException、WinFormsのThreadExceptionなどは、回復ポイントではなく最後の記録地点です。主な責務は最終ログ、flush、ダンプ採取導線、終了コードや再起動導線の整備までです。ここまで漏れた時点で状態が壊れている可能性があるため、見かけ上続けられても継続してよいとは限りません。回復戦略は、その前のrequestやjobの境界に置くほうが安全です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る