みなさんこんにちは!ヒロポンです!!
今回は業務系のC#でガチで踏みやすいやつ!!の話。
「先輩の古いコードがtry { ... } catch (Exception){ }で全部握りつぶしてて、本番障害の原因がどこにも残ってない」「SqlConnectionをtry-finallyで閉じてる、でもusingでいいんじゃね??」「throw ex;でスタックトレースが消えてた」みたいな例外処理の事故って、業務系の保守現場で誰しも一回は出くわしますよね??
俺も2年目くらいの流通系SIer時代に、catch (Exception){ }で全部握りつぶしてた古いストアド呼び出しが本番で音もなく失敗してて、ユーザーから「データが入ってない」って報告で初めて気付いた事件をやらかしました。夕方の運用報告で初めて気付いて半日デバッガで追ってハマったやつ。原因は1行で済む話で、Console.WriteLine(ex.Message)すら入ってなくて例外が完全に消えてたんですよね。
この記事ではVS2019 / .NET Framework 4.7.2 / C# 7.3環境で、C#例外処理の4パターン(try-catch / try-finally / using / Exceptionフィルタwhen句)の使い分けと、業務系で踏みやすい落とし穴5つ、throw vs throw exのスタックトレース挙動を、コード9本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。
3行で結論:
IDisposableを扱うならusing寄せ(try-finallyの構文糖衣で書き忘れ事故が消える)- 条件付きキャッチは
when句(catch内if分岐は禁忌)/throw ex;は禁止、throw;かthrow new ...(ex)寄せcatch (Exception){ }で全握りつぶしは完全NG(ログ出力なし=障害原因隠蔽の温床)
定石1: try-catch / try-finally / usingの関係(usingは構文糖衣)
まず3つの基本構文を並べて比較すると、こんな感じになります:
// ✅定石1-a: try-catch —例外を捕まえて処理を続ける
try
{
var dt = LoadFromDb(orderId);
Render(dt);
}
catch (SqlException ex)
{
Logger.Error($"DBエラー: {ex.Number} {ex.Message}");
ShowErrorDialog("データの読み込みに失敗しました");
}
// ✅定石1-b: try-finally —リソース解放を保証
SqlConnection conn = new SqlConnection(connStr);
try
{
conn.Open();
//処理
}
finally
{
conn.Close();
conn.Dispose();
}
// ✅定石1-c: using — try-finallyの構文糖衣(ILレベルで等価)
using (var conn = new SqlConnection(connStr))
{
conn.Open();
//処理
}
// ↑ブロック終了時にconn.Dispose()がfinallyで確実に呼ばれる
usingステートメントはtry-finally + Dispose()にILレベルで展開されるので、SqlConnection / SqlDataAdapter / StreamReaderのようなIDisposable実装クラスを扱う時は、using寄せが書き忘れ事故を消せるので本命っす。
ん?じゃあtry-finallyはもう使わなくていいの??って思うかもだけど、IDisposableを実装してない自前クラスや、リソース解放以外の処理(ログ書き込みの最終フラッシュ等)をfinallyでやりたい場面ではtry-finallyが現役です。
定石2:複数IDisposableは入れ子usingで書く
SqlConnection + SqlCommand + SqlDataReaderのような複数のIDisposableを扱う業務系定番パターンは、入れ子usingで書くといい感じに整理できます:
// ✅定石2:入れ子usingで複数IDisposableを扱う
using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("SELECT id, name FROM users WHERE status = @s", conn))
{
conn.Open();
cmd.Parameters.AddWithValue("@s", "active");
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
int id = reader.GetInt32(0);
string name = reader.GetString(1);
//処理
}
}
}
// ↑ブロック終了時にreader → cmd → connの順でDispose()が呼ばれる
using (...)using (...)のように{ }を省略して並べると、入れ子のインデントが浅くなって読みやすい。3つ以上のIDisposableを扱う時はこの書き方が業務系の鉄板パターンっす。
C# 8.0+のusing var宣言(using var conn = new SqlConnection(...);のスコープ末で自動Dispose)が使えるならさらにスッキリ書けるけど、.NET Framework 4.7.2 / C# 7.3ではusing (...)ブロック形式が現役。レガシー業務系の書き方として覚えておくのが安全です。
定石3: Exceptionフィルタ(when句)でcatch内if分岐を排除
C# 6以降で書けるwhen句を使うと、catch条件を絞り込めてcatchブロック内のif分岐がいい感じに消えます:
// ✅定石3-a: Exceptionフィルタ(when句)でデッドロックだけリトライ
try
{
ExecuteSql();
}
catch (SqlException ex)when (ex.Number == 1205)
{
// SQL Serverデッドロック(Error 1205)の時だけリトライ
Thread.Sleep(100);
ExecuteSql();
}
catch (SqlException ex)when (ex.Number == -2)
{
//タイムアウトの時だけ別処理
Logger.Warn($"SQL Timeout: {ex.Message}");
throw new TimeoutException("DBアクセスがタイムアウトしました", ex);
}
catch (SqlException ex)
{
//上のwhenにマッチしないSqlExceptionはここで捕まる
Logger.Error($"DBエラー: {ex.Number} {ex.Message}");
throw;
}
// ❌ NG: catch内でif分岐すると、フィルタにマッチしなかった例外を再スローし忘れる事故が起きる
try
{
ExecuteSql();
}
catch (SqlException ex)
{
if (ex.Number == 1205){ /* リトライ */ }
else if (ex.Number == -2){ /* タイムアウト */ }
//それ以外を握りつぶしてる罠
}
when句のメリットは2つ:
- マッチしない例外はそもそもcatchに入らない(スタックトレースが綺麗、デバッガで止まる位置も期待通り)
- catch内の握り潰し事故が消える(
if分岐のelse漏れがなくなる)
業務SE現場のSqlException処理(デッドロック・タイムアウト・接続切断のリトライ判定)でめちゃくちゃ効くパターンなので、覚えておくと便利っす。
定石4: throw vs throw exのスタックトレース挙動
例外を再スローする時、throw;とthrow ex;でスタックトレースの保持挙動が違うのが業務SEで地味に詰まる罠:
// ❌ NG: throw ex;はスタックトレースが「rethrowした行」でリセットされる
public void OuterMethod()
{
try
{
InnerMethod();
}
catch (SqlException ex)
{
Logger.Error($"DBエラー: {ex.Message}");
throw ex; // ←ここでスタックトレースが上書きされる、元の発生箇所が消える
}
}
// ✅ OK: throw単独は元のスタックトレースを保持
public void OuterMethod()
{
try
{
InnerMethod();
}
catch (SqlException ex)
{
Logger.Error($"DBエラー: {ex.Message}");
throw; // ←元のInnerMethod内の発生行が保持される
}
}
// ✅ OK:別の例外型でラップする時は内部例外を保持
public void OuterMethod()
{
try
{
InnerMethod();
}
catch (SqlException ex)
{
throw new ApplicationException("注文処理に失敗しました", ex);
// ↑ inner exceptionとして元のSqlExceptionが保持される、スタックトレースも辿れる
}
}
throw ex;の事故、業務系のログ運用で本当に多い。スタックトレースの一番下がOuterMethodのキャッチ行になって、本来の例外発生箇所が消えるので、ログだけ見ても原因が分からない。
俺は2社目でthrow ex;でスタックトレース消えてデバッグに半日かかった事件を踏んで、それ以来grep -rn "throw ex" .で全件throw;に書き換えるリファクタを入れたんですよね。レガシー業務系の保守で過去コードにthrow ex;が残ってたら、見つけ次第潰すのが安全。

落とし穴5つ—業務系本番事故の常連
1. catch (Exception){ }で全握りつぶし
// ❌ NG:例外を捕まえてログも吐かず処理を続ける
try
{
SaveOrder(order);
}
catch (Exception)
{
//何もしない→障害原因が完全に消える
}
本番事故ワースト1の温床。例外が握りつぶされてログにも残らないので、後から原因特定が不可能になる。新規コードではcatch (Exception)自体を原則禁止に揃えるのが現代的な判断軸です。
2. finallyで例外スローして元の例外が消える
// ❌ NG: finally内で例外が出ると元の例外が上書きされる
SqlConnection conn = null;
try
{
conn = new SqlConnection(connStr);
conn.Open();
DoSomething(conn);
}
finally
{
conn.Close(); // ← connがnullだったらNullReferenceExceptionが飛ぶ
//元のDoSomethingの例外が消える
}
finally節は元の例外を上書きする性質があるので、finally内で例外を出すのは禁忌。usingで書けばこの問題は自動的に消える(usingはnullチェック相当を内部でやる)ので、リソース解放はusing寄せが本命っす。
3. usingとOpen()の順序ミス
// ❌ NG: Open()で例外が出るとDispose()が呼ばれない
SqlConnection conn = new SqlConnection(connStr);
conn.Open(); // ←ここで例外が出ると、connがusingの外なのでDispose()されない
using (conn)
{
//処理
}
// ✅ OK: usingで囲ってからOpen()
using (var conn = new SqlConnection(connStr))
{
conn.Open(); // ←例外が出てもfinallyでDispose()される
//処理
}
usingの中にOpen()を入れるのが正解。これを逆にしてた古いコードベース、業務系で本当によく見ます。
4. catch (DbException)とcatch (Exception)の順序
// ❌ NG:派生型を後ろに書くと到達不能(コンパイルエラー)
try { ... }
catch (Exception ex){ /* 全部ここに来る */ }
catch (SqlException ex){ /* ←ここには到達しない、コンパイルエラー */ }
// ✅ OK:派生型を上、基底型を下
try { ... }
catch (SqlException ex){ /* DB専用処理 */ }
catch (DbException ex){ /* DB系の他の例外 */ }
catch (Exception ex){ /* それ以外、ログ+上位通知 */ }
派生型→基底型の順で書く。これはC#コンパイラがエラーで止めてくれるので踏みにくいけど、コードレビューで見落とすと大改修になる順序ミスです。
5.例外をラップする時に内部例外を渡し忘れる
// ❌ NG:内部例外を渡さないと元の情報が失われる
catch (SqlException ex)
{
throw new ApplicationException("注文処理に失敗"); // ← exを渡してない
}
// ✅ OK:第2引数に元の例外を渡す
catch (SqlException ex)
{
throw new ApplicationException("注文処理に失敗", ex); // ← inner exception保持
}
Exception(string message, Exception innerException)のコンストラクタを使って内部例外を保持するのが鉄則。これも業務系の保守でよく見る雑な書き方の一つで、後段のログ集約システムが内部例外をたどれなくて原因不明になる事故の温床です。
ハマりポイント—実体験ベースの本番事故3点
1. catch (Exception){ }で半日デバッガで追ってハマった
2社目の流通系SIer時代、夜間バッチが音もなく失敗してた事件。原因は完全にcatch (Exception){ }で全握りつぶし。ログには「バッチ完了」しか出てなくて、実際にはデータが半分も入ってなかった。夕方の運用報告でユーザーから「データが足りない」って指摘で初めて気付いた。半日デバッガで追ってハマった末に該当箇所を見つけて、ログ出力を追加して原因特定。それ以来「catch (Exception)を見つけたら新規禁止+ログ追加リファクタ」のルールを入れました。
2. throw ex;でスタックトレース消失(30分溶かした)
別案件で、本番障害のスタックトレースがいきなりラッパー関数のcatch行から始まっていて原因特定が困難になった事件。30分溶かした末にthrow ex;だと気付いて、throw;に置換するリファクタを入れた。grep -rn "throw ex" .が全件ヒットしないのを確認するのが、業務系チームの保守ルーチンになりました。
3. finally内で例外スロー→元の例外消失(数日プロファイラで追った)
finally内のconn.Close()でNullReferenceExceptionが出てて、元のSqlExceptionが完全に消えてた事件。本番でログを見ても「NullReferenceException」しか出てないので、数日プロファイラで追ってようやく気付いた。usingに書き換えるだけでfinallyのnullチェックが要らなくなって、再発しなくなった。リソース解放はusing寄せが業務系の鉄則っす。
俺の現場メモ—業務系チームでの例外処理ルール
流通系SIer時代に、過去コードをgrep -rn "catch (Exception)" .でひっかけたら、60箇所近く出てきたんですよね。書き方がバラバラで、catch (Exception){ }で全握り潰してるやつ、catch (Exception ex){ Logger.Error(ex); throw ex; }でスタックトレース消してるやつ、finallyでconn.Close()してるやつ、全部入り。
んで、後輩と一緒に3行ルールにまとめた:
catch (Exception)新規禁止(既存箇所はリファクタ時に派生型catch (SqlException)等に絞り込み+ログ出力)throw ex;全置換(grep -rn "throw ex" .が0件になるまでthrow;に書き換え)- リソース解放は
using寄せ(try-finallyで書かれたClose()/Dispose()をusingブロックに移行)
このルール化で、本番の音もなく失敗する事故が消えた。例外処理を1パターンに揃えるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。
C# 7.3 + .NET Framework 4.7.2のレガシー業務系って、usingもwhen句も普通に使える環境なのに、書き方がC# 4時代から進化してないコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。
まとめ
| 状況 | 推奨パターン |
|---|---|
IDisposableリソース解放 |
using寄せ(try-finallyの構文糖衣) |
複数IDisposable |
入れ子using |
| 例外型ごとの分岐 | 派生型→基底型の順+ when句 |
| 条件付きリトライ・絞り込み | Exceptionフィルタwhen句 |
| 例外を上位に伝播 | throw;単独(throw ex;禁止) |
| 別の例外型でラップ | throw new XxxException("...", ex);で内部例外保持 |
ログなしのcatch (Exception){ } |
完全NG(障害原因隠蔽) |
例外処理の事故は、「リソース解放はusing」「再スローはthrow;」「全握りつぶし禁止」の3点で9割消えます。catch (Exception)をcatch (SqlException)等の派生型に絞り込むだけで、保守時の原因特定がいい感じに楽になる。業務系の例外処理は書き方を1パターンに揃えるのが本命の対処です。
よくある質問
Q1. try-finallyとusingステートメントは何が違いますか?
A. usingはtry-finallyの構文糖衣で、ILレベルでは同じtry-finallyに展開されます。違いは(1)usingはIDisposable実装オブジェクトに対してしか使えない、(2)usingはfinally節で確実にDispose()を呼んでくれる、の2点。IDisposableオブジェクトを扱うならusingで書くとfinallyの書き忘れ事故が消えるので、ほぼ全ケースでusing寄せが安全です。
Q2. throwとthrow exはどう使い分けますか?
A. 原則throw単独を使ってください。throw ex;だと例外のスタックトレースが「rethrowした行」でリセットされてしまい、元の発生箇所が消えてデバッグが困難になります。throw;なら元のスタックトレースが保持されたまま再スローされる。例外の上位ハンドラに伝播させたい時はthrow単独、ラップして別の例外に置き換えたい時はthrow new XxxException("...", ex);で内部例外を保持してください。
Q3. Exceptionフィルタ(when句)はどんな時に使いますか?
A.「特定の条件の例外だけcatchしたい、それ以外は上位に通したい」場面で使います。例えばSqlExceptionで特定のエラー番号だけリトライしたい時、catch (SqlException ex)when (ex.Number == 1205){ /* デッドロックリトライ */ }のように書ける。catchブロック内にif分岐を書いて握り潰すより、フィルタで弾いたほうがスタックトレースが綺麗で、デバッガで止まる位置も期待通りになります。C# 6以降で書けるので.NET Framework 4.7.2 / C# 7.3でも使えます。
Q4. catch (Exception)で全部捕まえる書き方はダメですか?
A.業務系の本番事故ワースト1の温床です。例外を握りつぶしてログも吐かないcatch (Exception){}のような書き方は、障害原因が完全に隠蔽されるので完全NG。どうしても全例外を握りたい場面なら、(1)ログ出力する、(2)特定の場面に絞る(バックグラウンドジョブのトップレベルなど)、(3)UI側ならユーザーに通知する、の3つを最低限担保してください。新規コードではcatch (Exception)自体を原則禁止に揃えるのが現代的な判断軸です。
Q5. usingとSqlConnection.Open()はどっちの順序が正解?
A. usingで囲ってから.Open()を呼ぶのが正解です。using (var conn = new SqlConnection(connStr)){ conn.Open(); /* 処理 */ }の形。Open()で例外が出た場合でもusingのDispose()がfinallyで呼ばれて接続が解放されます。逆にOpen()をusingの外で呼ぶと、Open()失敗時にDispose()が呼ばれず接続リーク事故になるので注意。
ここまででC#例外処理の主要パターンと業務系本番事故ポイントは押さえた。IDisposableリソース管理の隣接トピックも貼っておきます。
関連記事
- WinFormsでUseWaitCursorが戻らないバグの解決法(業務SE目線) —
try-finally/IDisposableラッパーで例外時の戻し漏れを消す時に効く - C# OpenFileDialogをフォームのフィールドにする時の正しい書き方 —
IDisposableリソースのライフサイクル管理を整える時に効く - C# DataAdapter.Update()でDBNull例外が出た時の最短対処 —派生型
SqlException/InvalidCastExceptionをwhen句で絞り込む時に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント