1. はじめに
WPF で業務アプリを作っていると、必ず直面するのが「例外処理をどう設計するか」という問題です。
画面のボタンを押した瞬間にクラッシュしてウィンドウが消える――ユーザーからすれば「信用できないソフト」に格下げされる瞬間です。
「じゃあ全部 try-catch
で囲めばいいのでは?」と考えたくなるのですが、それでは以下のような限界があります。
- 全コードに
try-catch
を書くのは非現実的(開発効率が落ちる) - 捕まえた例外をどう扱うか(ユーザーに通知する?ログだけ?続行する?)は設計次第
-
そもそも UI スレッド以外の例外は
try-catch
だけでは拾えない場合もある(例:await
していないTask
の例外、バックグラウンドスレッドでのイベント処理、タイマー/コールバック経由の例外 など)
ここで登場するのが グローバル例外ハンドリング です。アプリ全体に「最後の砦」を用意して、どこで発生したかを問わず例外を受け止め、
- ユーザーに通知する
- ログに残す
- アプリを終了 or 続行する
といった共通処理を実行できます。
ただし、かといって「クラッシュ情報を全部ダンプして保存」すればよいわけではありません。そこには PII(個人識別情報)や機微情報 が含まれる可能性があるからです。
- フォームに入力されたユーザー名やメールアドレス
- DB 接続文字列やパスワード
- アプリ内部で扱う顧客データ
これらをそのまま例外ログに吐き出すと、セキュリティ事故の原因になりかねません。
つまり「全部拾って全部残す」ではなく、「必要な情報だけ残す・見せる」という設計が重要になります。
本記事では、WPF/MVVM アプリを題材にして
- どのイベントで例外を拾えるのか
- 拾った後にどう処理すべきか
- FailFast(即時終了)と Shutdown(安全終了)の線引き
- 機微情報をどう扱うか
を、実際のコードを交えながら徹底解説します。
次章では、WPF アプリにおける「3 つの例外窓口」について概観します。
2. グローバル例外ハンドリングとは何か
まず整理しておきたいのは、「グローバル例外ハンドリング」という言葉の意味です。
ここでいう「グローバル」とは、アプリケーション全体に共通して機能する最後の砦というニュアンスです。
個々の try-catch
では拾いきれない例外が発生しても、最終的に「どこかでキャッチして処理を引き取る仕組み」を指します。
3つの窓口(UI → 非同期タスク → CLR 全体)
WPF アプリでは、大きく分けて以下の 3つの窓口 があります。本記事では「UI → 非同期タスク → CLR 全体」の順で解説していきます。
-
UI スレッド (
Application.DispatcherUnhandledException
)- WPF の画面操作やイベント処理は Dispatcher スレッド上で動いています。
- このスレッドで処理されなかった例外は、最後に
DispatcherUnhandledException
イベントで拾うことができます。 - 典型的には「ボタンクリックイベントの処理で throw したのに catch がなかった」といったケースです。
-
非同期タスク (
TaskScheduler.UnobservedTaskException
)-
Task
を使った非同期処理の例外は、await
していればtry-catch
で拾えます。 - しかし
await
されなかったタスク や 結果を無視したタスク の例外は、ガベージコレクションのタイミングでUnobservedTaskException
として表面化します。 - そのため「いつ発火するかは運次第」という性質があり、再現性が薄め(同じ操作をしても例外発生のタイミングが異なる、例外発生しない場合があるなど)なのが特徴です。
-
3. CLR 全体 (AppDomain.CurrentDomain.UnhandledException
)
- UI スレッド以外(バックグラウンドスレッド、タイマーコールバックなど)で発生した未処理例外は、最終的にここで通知されます。
- このとき .NET Framework ではほぼ常に、.NET Core / .NET 5+ 以降でも原則として ハンドラ終了後にアプリケーションは強制終了 します。
-
UnhandledExceptionEventArgs.IsTerminating
プロパティを確認すると分かりますが、多くの場合はtrue
です。 - したがって「通知される=最後にログやアラートを残すチャンス」であり、ここからアプリを続行させることはできないと考えるべきです。
それぞれの窓口の性質を理解することが大事
- UI スレッド → 比較的扱いやすい。「ユーザーにメッセージを出して続行」も可能。ただしイベントハンドラは同期メソッドであり await は使えない。そのため非同期処理は fire-and-forget で UI Dispatcher に委譲する必要がある。
- 非同期タスク → タイミングが読みにくい。ロギング中心で「握りつぶさない」方針が必要。
- CLR 全体 → 必ず終了する前提。ここではログ・アラート・FailFast など「最後の証跡を残す」ことに集中する。
設計上の心構え
「全部拾って全部握りつぶす」ことはできませんし、してはいけません。
むしろ「どこで発生した例外なら続行できるか/できないか」を整理して、
- ユーザーにどう通知するか
- ログに何を残すか(PII を除外する工夫も含めて)
- どのタイミングでアプリを終了するか
を設計の段階で決めておくことが、実務的には重要です。
次章では、これらの考え方を踏まえて、実際のコード実装(SimplePocApplication
と UIThreadSimpleDispatcher
)を覗いてみます。
3. サンプル実装の全体像
ここからはいよいよ、実際のコードをベースに「全体像」を見ていきます。
今回題材にするのは、以下の固定コミットの SimplePocApplication
クラスです。
コードの役割
SimplePocApplication
は Application
を継承し、WPF 全体の例外処理を集中管理する役割を担っています。
ポイントは以下のとおりです。
-
OnStartup
内でグローバル例外ハンドラを登録-
DispatcherUnhandledException
(UI スレッド用) -
AppDomain.CurrentDomain.UnhandledException
(CLR 全体用) -
TaskScheduler.UnobservedTaskException
(非同期タスク用)
-
- 例外を受け取ると、共通の確認ロジック
ConfirmOrShutdownAsync
に処理を渡す - 必要に応じて
Environment.FailFast
を呼び、即時にプロセスを終了する
UIThreadSimpleDispatcher の存在
もう一つ重要なのが、UIThreadSimpleDispatcher.cs です。
- 目的: 「UI スレッドに処理を戻す」ための薄いラッパー。
-
設計意図:
Dispatcher.CheckAccess()
やDispatcher.InvokeAsync()
を直接呼び回るのではなく、このクラスを経由することで UI スレッド制御の責務を分離。 -
実務的メリット: テスト時にはこのクラスを差し替えることで、UI 依存のコードをモック化できる。
- 例: 単体テスト環境では実際の WPF Dispatcher を呼ばず、疑似 Dispatcher 実装を挟む。
登場人物の整理
-
SimplePocApplication
例外ハンドラの登録とアプリ全体の「最後の砦」ロジックを提供するクラス。 -
UIThreadSimpleDispatcher
UI スレッド制御を抽象化する補助クラス。テスト差し替えを見越した設計になっている。 -
ConfirmOrShutdownAsync
メソッド
共通の「確認ダイアログ or 即時終了」処理を集中管理する非同期メソッド。
全体像を図でイメージすると…
このように、すべての経路が SimplePocApplication
に集約される仕組みになっています。
次章からは、この「3つの例外経路」のそれぞれを順に掘り下げていきます。
まずは UI スレッドの例外処理 (DispatcherUnhandledException
) から見ていきましょう。
4. UI スレッド例外の扱い
DispatcherUnhandledException の仕組み
WPF の画面表示やイベント処理は Dispatcher(UI スレッド) 上で動作します。
このスレッドで未処理の例外が起きると、最後に Application.DispatcherUnhandledException
が発火します。
👉 該当箇所: SimplePocApplication.cs : OnDispatcherUnhandledException
呼ばれる状況
-
UI スレッドのイベントハンドラやバインディング更新で例外が投げられ、
try-catch
で処理されなかった場合 - 非 UI スレッドの例外はここには届かず、別の経路(未観測 Task / CLR 全体)に送られる
ハンドラ設計方針
-
e.Handled = true
にして 即時クラッシュを抑止 - ユーザーに「続行 or 終了」の選択を委ね、その結果に応じて
Application.Current.Shutdown(exitCode)
を呼ぶ - ExitCode は
ExitCodeForUnhandledException
プロパティで定義し、アプリ終了コードを一元管理 - UI 表示は必ず
UIDispatcher
経由で行う(UI スレッド以外から直接 MessageBox を出さない) - Exception メッセージに PII が含まれる可能性を考慮し、UI には詳細を出しすぎない設計が望ましい
サンプル実装
/// <summary>
/// UI スレッド上の未処理例外をハンドルします。
/// </summary>
/// <remarks>
/// ここでは例外を Handled = true とし、
/// 最終的な継続/終了判断は ConfirmOrShutdownAsync に委ねます。
/// </remarks>
protected virtual void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
Exception ex = e.Exception;
var msg = $"""
【DispatcherUnhandledException】
UI スレッドで未処理例外が発生しました。
{ex.GetType().Name}
{ex.Message}
続行しますか?
""";
// 非同期的に確認ダイアログを表示し、結果に応じて終了処理を実行
_ = ConfirmOrShutdownAsync(msg, "UI例外", shutdownAsync: () =>
{
// ユーザーが「続行しない」を選択した場合、WPF の安全終了を試みる
Current?.Shutdown(ExitCodeForUnhandledException);
return Task.CompletedTask;
});
// 即クラッシュを避け、継続可能性を残す
e.Handled = true;
}
今回のサンプル実装のまとめ
-
DispatcherUnhandledException
は UI スレッド例外専用の最後の窓口 -
Handled = true
にすることで「アプリクラッシュ」ではなく「ユーザー選択による終了」へ流せる - 終了は
Current.Shutdown(exitCode)
を基本とし、ExitCode をプロパティで一元管理する設計とした - PII を含めない/マスクするポリシを事前に決めることが、実運用における重要な課題(これは
DispatcherUnhandledException
に限らない話)
次章では、GC のタイミングで表面化する 非同期タスクの未観測例外(TaskScheduler.UnobservedTaskException
) を詳しく解説します。
5. 非同期タスクの未観測例外
TaskScheduler.UnobservedTaskException とは何か
C# の Task
は非同期処理の基本部品ですが、必ずしも await
されるとは限りません。
例えば「fire-and-forget(投げっぱなし)」で実行されるタスクや、結果を無視して破棄されるタスクがそれです。
こうしたタスクが内部で例外を投げた場合、通常の try-catch
では捕捉できません。
その結果は ガベージコレクションのタイミング で TaskScheduler.UnobservedTaskException
として表面化します。
👉 該当箇所: SimplePocApplication.cs : OnUnobservedTaskException
呼ばれる状況
-
await
されなかったタスク(非同期メソッド)が例外を投げたとき -
Task.Result
/Task.Wait()
を呼ばずに放置されたタスク - GC によりタスクが回収されるタイミングで「未観測例外」として通知される
つまり「例外が必ず即時に表面化する」とは限らず、発生のタイミングは運次第という特徴を持ちます。(動作確認用にほぼ強制的に発生させる方法もなくはありません)
ハンドラ設計方針
-
e.SetObserved()
の呼び出しが必須
呼ばないとプロセスが即終了する挙動になり得ます。
→ まずSetObserved()
を呼び、強制クラッシュを抑止してから後続処理を行うのが鉄則です。 -
UI 通知と継続/終了判断
例外はバックグラウンドで発生しているため「UI スレッドに戻してユーザーに告知」する必要があります。
このため実装ではConfirmOrShutdownAsync
に委譲し、Yes/No ダイアログで「続行しますか?」を尋ねています。 -
ExitCode の一元管理
未観測 Task 用の終了コードはExitCodeForUnobservedTaskException
プロパティで定義され、終了時にApplication.Current.Shutdown(exitCode)
が呼ばれます。
サンプル実装
/// <summary>
/// 未観測 Task 例外をハンドルします(GC 最終化タイミング等で表面化)。
/// </summary>
/// <remarks>
/// e.SetObserved() を呼び、プロセス落ちを抑止します。
/// その上で UI 通知 → 継続/終了判断を行います。
/// </remarks>
protected virtual void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
AggregateException agg = e.Exception;
var flat = agg.Flatten();
var inners = string.Join(Environment.NewLine,
flat.InnerExceptions.Select((ex, i) =>
$" [{i + 1}] {ex.GetType().Name}: {ex.Message}"));
var msg = $"""
【UnobservedTaskException】
バックグラウンド Task で未観測例外が発生しました。
{agg.GetType().Name}
{agg.Message}
---- Inner Exceptions ----
{inners}
続行しますか?
""";
// 非同期的に確認ダイアログを表示し、結果に応じて終了処理を実行
_ = ConfirmOrShutdownAsync(msg, "非同期例外", shutdownAsync: () =>
{
// ユーザーが「続行しない」を選択した場合、まずは WPF の安全終了を試みる
Current?.Shutdown(ExitCodeForUnobservedTaskException);
return Task.CompletedTask;
});
// 観測済みにすることで即終了を回避
e.SetObserved();
}
今回のサンプル実装のまとめ
-
UnobservedTaskException
は 再現性が薄くタイミング依存の通知イベント - まず
e.SetObserved()
を呼んで プロセス即死を防ぐ - UI 通知(
ConfirmOrShutdownAsync
)で「続行 or 終了」をユーザーに委ねる - ExitCode は
ExitCodeForUnobservedTaskException
に統一し、実行環境に依存しない設計を目指す
次章では、アプリ全体を巻き込む CLR レベルの未処理例外 (AppDomain.CurrentDomain.UnhandledException
) を扱います。
6. CLR 全体の例外ハンドリング
AppDomain.CurrentDomain.UnhandledException とは何か
UI スレッドや Task 経由では捕まえられない例外が、最終的に CLR 全体の出口として到達するのが AppDomain.CurrentDomain.UnhandledException
です。
👉 該当箇所: SimplePocApplication.cs : OnUnhandledException
呼ばれる状況
- バックグラウンドスレッドやタイマーコールバックで発生した未処理例外
- ネイティブ相互運用中に発生した一部例外
- アプリ全体を巻き込む致命的エラー(例:メインスレッドでの例外未処理)
このイベントが発火した時点で、アプリは原則として継続できません。
.NET Framework
では常に終了、.NET Core
以降でも UnhandledExceptionEventArgs.IsTerminating
が多くのケースで true
を返します。
設計方針
-
多重発火ガード
例外連鎖でイベントが複数回呼ばれるのを避けるため、_fatalEntered
フラグで最初の一度だけ処理。同一プロセスで二度目以降の発生は即終了。 -
ユーザー通知の試み
ConfirmOrShutdownAsync
を呼び出して UI にメッセージを出す。ただし UI が固まっている可能性もあるため、待機にはTimeoutForUnhandledExceptionNotification
(本実装では 15 秒)を設けている。 -
最後は FailFast
どのみち継続不可能なので、ログや通知を済ませたらEnvironment.FailFast()
で即時終了。
→Shutdown
ではなく FailFast を使う理由は、「Dispose/Finally が動く余地を残すと逆に危険(状態が壊れている可能性大)」だからです。
サンプル実装(最新版)
/// <summary>
/// CLR 全体の未処理例外をハンドルします(原則として回復不可能)。
/// </summary>
/// <remarks>
/// UI 告知を試みつつ(最大 TimeoutForUnhandledExceptionNotification まで待機)、
/// 最後は Environment.FailFast により即時終了します。
/// 例外ループを避けるため、多重発火は _fatalEntered でガードします。
/// </remarks>
protected virtual void OnUnhandledException(object? sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception;
// 多重発火防止(すでに致命ダイアログ表示中なら即終了)
if (Interlocked.Exchange(ref _fatalEntered, 1) == 1)
{
Environment.FailFast(ex?.Message ?? "UnhandledException (reentered)");
return;
}
var msg = $"""
【UnhandledException】
CLR 全体で未処理例外が発生しました。
{ex?.GetType().Name}
{ex?.Message}
この例外は続行不可能です。
アプリケーションを終了します。
""";
var sem = new Semaphore(0, 1);
ShutdownWithNotification(msg, "続行できない例外", sem);
// UIでの告知完了/タイムアウトのどちらかまで待機
var shown = sem.WaitOne(TimeoutForUnhandledExceptionNotification);
// 無条件で即時終了(finally/dispose も走らない点に注意)
Environment.FailFast((ex?.Message ?? "UnhandledException") + $"- MessageBox Shown: {shown}");
// ローカル関数: UI経由で通知した後にセマフォを解放
async void ShutdownWithNotification(string msg, string title, Semaphore semaphore)
{
await ConfirmOrShutdownAsync(msg, title, isFatal: true);
semaphore.Release();
}
}
今回のサンプル実装のまとめ
-
UnhandledException
は アプリ継続不能を前提とした最後の窓口 - 複数回発火するのを
_fatalEntered
でガード -
ConfirmOrShutdownAsync
で通知を試みつつ、UI が応答しない場合でも Timeout 後に強制終了 - 最後は必ず
Environment.FailFast
により即終了(状態が壊れている前提なので安全策)
実務上のヒント
- FailFast は「最後の最後」。ここでしか呼ばないようにする
- 通知やログは必ず FailFast 前に完了させる
- 開発中は「本当に FailFast で落ちているか」を確認するため、テスト的に意図的な例外を投げて検証しておくのが有効
次章では、ここまでの共通処理である ConfirmOrShutdownAsync の役割 や FailFast と Shutdown の違い を整理していきます。
次章に進む前に:未処理例外と終了コードの余談
ここで少し寄り道して、「未処理例外で落ちたときの終了コード」 について触れておきます。
実際に WPF アプリを WinEXE(GUI アプリ)としてビルドしたあとに、
> start /wait CozyPoC.SimplePoCBase.App.exe
> echo %ERRORLEVEL%
を叩いてみると、こんな値が返ることがあります。
(アプリケーションを Environment.FailFast()
で終了させた場合):
-2146232797
この値の正体
この値は 16 進数に直すと 0x80131623 で、.NET が内部で定義している COR_E_FAILFAST
という HRESULT です。
Environment.FailFast
で強制終了した場合は、例外の種類にかかわらず常にこのコードになります。
一方、通常の「未処理例外」でアプリが落ちた場合は、例外インスタンスに埋め込まれている HResult
がそのまま終了コードになります。
たとえば基底の System.Exception
の既定値は COR_E_EXCEPTION (0x80131500 = -2146233088)
です。
まとめ
-
Windows のルール
プロセスは 32bit 整数の終了コードを返せる。 -
.NET のルール
すべての例外はHResult
を持っている。 -
CLR の実装
-
未処理例外で落ちた場合 → その例外の
HResult
が終了コードになる。 -
Environment.FailFast
で落ちた場合 → 常にCOR_E_FAILFAST (0x80131623)
が終了コードになる。
-
仕様?実装依存?
ここは注意が必要です。
-
ECMA-335 CLI 仕様では「例外オブジェクトがエラーコードを持つ」ことは規定されていますが、その値を Windows の HRESULT としてどのように使うか、ましてや プロセスの exit code に流すかまでは明記されていません。
-
つまりこれは .NET ランタイムの実装依存の挙動です。
-
とはいえ .NET Framework 2.0 から .NET 8 に至るまで一貫して同じ動作が確認されているため、実務的には「未処理例外=例外の HResult が exit code になる」とみなして差し支えないでしょう。(将来のバージョンで変わる可能性はゼロではありません)
実務上のヒント
-
この負の終了コードを見たら「例外で落ちたな」と察することができます。
-
ただしスクリプトや CI/CD でハンドリングするには扱いにくい値です。
-
そこで本記事の実装のように
Shutdown(exitCode)
やEnvironment.Exit(code)
で 自前の終了コードを返す設計にしておくと、
「どの経路で落ちたか(UI/Task/CLR)」を識別しやすくなります。
さて、次章では、このような個別ハンドラの共通処理として設計している
ConfirmOrShutdownAsync
の役割と FailFast/Shutdown の違い を整理していきましょう。
7. 共通処理:ユーザー通知と終了戦略
ここまで見てきた UI / 非同期タスク / CLR 全体の 3 つの窓口は、今回の設計では、いずれも最終的に 共通の通知・終了処理 に流れ込みます。そのハブとなるのが ConfirmOrShutdownAsync
です。
ConfirmOrShutdownAsync の役割
-
UI スレッドに戻して確認ダイアログを出す
-
UIDispatcher
を経由してMessageBox
を表示し、ユーザーに「続行するか/終了するか」を問う。
-
-
終了判断を一元化する
- 「Yes/No」や「OK」などユーザーの選択に応じて
Application.Current.Shutdown(exitCode)
を呼ぶ。 - 終了コードは
ExitCodeForUnhandledException
やExitCodeForUnobservedTaskException
などプロパティで一元管理。
- 「Yes/No」や「OK」などユーザーの選択に応じて
-
フェイルセーフ
- ダイアログ表示に失敗した場合は「継続扱い」にして落ちないようにしている(SafeShow の設計)。
FailFast と Shutdown の違い
ここで整理しておきたいのが Environment.FailFast
と Application.Current.Shutdown
の違い です。
項目 | FailFast | Shutdown |
---|---|---|
挙動 | 即時プロセス終了 | WPF のライフサイクルに従って安全終了 |
finally/dispose | 実行されない | 実行される |
ダンプ生成 | 生成される(環境による) | されない |
用途 | 継続不能な致命例外 | ユーザー選択や非致命例外での終了 |
-
FailFast
- CLR/アプリ全体が壊れている可能性が高い場合に使用。
- 例:
UnhandledException
経由の致命エラー。 - ログや通知を済ませたら即座にプロセスを終了する。
-
Shutdown
- WPF のライフサイクルに則り、Window.Close → Dispatcher 終了 → Exit イベント という「普通の終了手順」を踏む。
- 「続行しない」とユーザーが選択した場合や、非同期タスク例外の安全終了時に適している。
UX としての設計判断
- 「続行しますか?」というダイアログは UX 上のバランスが難しい。
- 開発中は「Yes で続行」を選べると便利。
- リリース版では「No(終了)」を推奨する場合もある。
- 「笑えない笑い話」にならないためには:
- PII を含む情報はダンプしない。
- ダイアログの文言はできるだけユーザーフレンドリーに。
- そもそも 例外をここまで漏らさない設計(ViewModel/Model 側での処理)を優先する。
今回のサンプル実装のまとめ
- すべてのハンドラが
ConfirmOrShutdownAsync
に流れ込み、通知と終了処理を統一 - FailFast と Shutdown の線引きを明確にしている
- UI 表示は
UIDispatcher
経由に限定しており、テスト時の差し替えも可能 - 実務では IDialogService などに差し替え、MessageBox 依存を排除するとさらに拡張性が高まる
次章では、この実装をどう実務にスケールさせるか――ロギング、PII マスキング、多言語化、DI などの拡張ポイントを解説していきます。
8. 実務に寄せた拡張ポイント
ここまで紹介した SimplePocApplication
の実装は、あくまで PoC(概念実証)レベル。
実務に組み込むには、さらにいくつかの拡張や調整が必要です。ここでは代表的なポイントをまとめます。
1. ロギングの導入
PoC では MessageBox
によるユーザー通知のみでしたが、実務では 必ずログ出力を組み合わせるべきです。
-
ILogger (Microsoft.Extensions.Logging)
- .NET 標準のロギング抽象化。ASP.NET Core と同様の仕組みをデスクトップでも利用可能。
-
NLog / Serilog
- 実績のある外部ライブラリ。ファイル、DB、Elasticsearch など柔軟に出力先を切り替え可能。
👉 実装例:
ConfirmOrShutdownAsync
の内部で ILogger.LogError(ex, "...")
を呼び出すようにすれば、ユーザー通知と同時にログも残せます。
2. PII マスキング
例外メッセージやスタックトレースには、しばしば 個人識別情報(PII)や機微情報 が混じります。
例えば以下のようなケースです:
- ユーザー名やメールアドレス
- 接続文字列や認証トークン
- 顧客情報を含む DB クエリ
これをそのままログやダイアログに出すのはリスク。
そこで専用の マスク処理ヘルパー を挟むのがおすすめです。
string MaskPII(Exception ex)
{
var msg = ex.Message;
// パスワードや接続文字列をマスクする例
msg = Regex.Replace(msg, @"password\s*=\s*[^;]+", "password=****", RegexOptions.IgnoreCase);
msg = Regex.Replace(msg, @"User\s*Id\s*=\s*[^;]+", "User Id=****", RegexOptions.IgnoreCase);
// メールアドレスをマスクする例
msg = Regex.Replace(msg, @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "****@****");
return msg;
}
3. 多言語化(国際化対応)
PoC ではハードコードされた日本語メッセージを使いましたが、実務では 多言語対応 が必須になることも多いです。
-
リソースファイル (.resx) を使い、
Resources.ExceptionMessage_UIThread
のように管理。 -
CultureInfo.CurrentUICulture
に基づき自動的に切り替え。
👉 「続行しますか?」が英語版だと「Do you want to continue?」になる、といった UX 改善。
4. DI との連携(IDialogService / IErrorPolicy)
本記事の実装は MessageBox
直呼びですが、MVVM パターンを徹底するなら UI 部品を直接叩くのは避けたいところ。
そこで:
-
IDialogService
- 「ダイアログを出す」という責務を抽象化。WPF 依存を切り離すことでテストやプラットフォーム移行が容易に。
-
IErrorPolicy
-
例外を受け取ったときの方針を外部化。
-
「この例外は継続可」「この例外は即終了」といったルールを注入できる。
-
👉 こうした抽象インターフェースを DI コンテナ(Prism, Microsoft.Extensions.DependencyInjection など)で注入することで、アプリ全体の例外処理ポリシを柔軟に差し替え可能になります。
まとめ
-
ログ出力は必須(ILogger/NLog/Serilog)
-
ログ出力、例外メッセージは PII マスクを忘れずに
-
多言語化でユーザー体験を改善
-
IDialogService / IErrorPolicy で責務を分離し、テストや保守性を高める
次章では、ここまでの実装を振り返りながら「設計上の判断軸」を整理します。
「どこで続行してよいのか」「どこで即終了すべきか」を明文化することで、チーム全体で共有できるルールに落とし込みましょう。
9. 設計上の判断軸まとめ
ここまで、UI スレッド → 非同期タスク → CLR 全体、という 3 つの窓口と共通処理を見てきました。
では最終的に「どこまで続行していいのか/どのタイミングで終了すべきか」をどう判断するのでしょうか。
1. 続行可能とみなせるケース
-
UI スレッド例外 (
DispatcherUnhandledException
)- ユーザー操作に直接ひもづく処理での例外。
- ViewModel 側で補足できなかったものを最後に拾って通知。
- この場合は「ユーザーに選択を委ねる」設計が比較的現実的。
- 例: ボタンクリックでの NullReferenceException → ダイアログを出して続行可否を問う。
-
未観測タスク例外 (
UnobservedTaskException
)-
await
忘れや fire-and-forget タスクの副作用。 - 例外のタイミングは不定であるため「続行してもアプリの整合性はすぐに壊れない」ケースが多い。
- ただしログと通知は必須。ユーザー選択による終了オプションも用意しておくのが無難。
-
2. 続行不能とみなすべきケース
-
CLR 全体の未処理例外 (
UnhandledException
)- バックグラウンドスレッドやタイマー、メインスレッド上の致命例外。
- 状態が破壊されている可能性が高く、原則として続行不能。
- ここではログや通知を済ませたら
Environment.FailFast
で即終了するのが正道。
3. デバッグフェーズとリリースフェーズの違い
-
デバッグ時
- 「続行しますか?」を Yes にして実行を継続できると解析に便利。
- スタックトレースや例外メッセージもそのまま出してよい(ただし社内開発環境に限る)。
-
リリース時
- 継続よりも 安全に終了させる 方がユーザー体験として正しい場合が多い。
- PII や内部実装詳細を隠し、ユーザーには「予期しないエラーが発生しました。再起動してください」といった汎用的メッセージを提示。
- ログには詳細を残しつつ、ユーザー通知はシンプルに。
4. チーム開発でのルール化
実務では「誰が見ても同じ判断になる」ことが重要です。そこで以下をルール化しておくと効果的です:
-
例外種別と対応方針の表を作る
- 例:
IOException
はユーザー通知のうえ継続可、OutOfMemoryException
は即終了 … など。
- 例:
-
終了コードの割り当てを明確にする
- UI 例外は 1、未観測タスク例外は 2、CLR 致命例外は 3 … といった具合に整理。
- CI/CD や運用監視で「どの経路で落ちたか」が識別可能になる。
-
拡張ポイントの責務分担
- ログ記録は ILogger、ユーザー通知は IDialogService、終了判定は IErrorPolicy … といった役割分担を決める。
まとめ
- 続行可能かどうかの線引きは、経路ごとに異なる。
- デバッグとリリースで通知ポリシを切り替えるのが実務的。
- チーム全体で「例外対応マトリクス」を共有し、終了コードや拡張ポイントを標準化することで、例外処理の迷子を防げる。
次章では、本記事の内容を振り返りつつ、読者が自分のプロジェクトに導入する際のチェックリストを提示します。
10. まとめと次のステップ
ここまで、WPF/MVVM アプリにおける グローバル例外ハンドリング設計を、実装コードと設計判断の背景を交えながら見てきました。
本記事で学んだこと(おさらい)
-
「try-catch で全部囲む」は現実的でない
- UI スレッド以外では拾えないケースが多い
- 設計段階から「どこで捕まえるか」を考えるべき
-
WPF における 3 つの例外経路
- UI スレッド(DispatcherUnhandledException)
- 非同期タスク(UnobservedTaskException)
- CLR 全体(UnhandledException)
-
共通処理の集約ポイント
-
ConfirmOrShutdownAsync
による通知・終了判断の一元化 - FailFast と Shutdown の明確な線引き
-
-
実務で必要な拡張
- ログ出力(ILogger/NLog/Serilog)
- PII マスキング
- 多言語化対応
- DI との統合(IDialogService/IErrorPolicy)
プロダクト導入時の TODO リスト
-
例外経路を整理
UI/Task/CLR それぞれで「続行可/不可」をチームで明文化する。 -
終了コードを設計
運用監視や CI/CD で識別できるよう、終了コードを割り当てる。 -
ログと通知の分離
- ログ:開発者・運用者向け、詳細・内部情報あり
- 通知:ユーザー向け、シンプル&PIIレス -
拡張ポイントの用意
-ILogger
/ Serilog 等でログ基盤を整備
-IDialogService
で UI 部品依存を排除
-IErrorPolicy
で例外ポリシを外部化 -
テスト戦略を立てる
- 意図的に例外を発生させ、UI 通知や FailFast が想定通り動くか確認する
- CI 環境でも終了コードを検証する
参考リンク集
- Microsoft Docs
- 今回のPoCソースコード
ちなみに今回のリポジトリには、動作確認用の View(XAML) や ViewModel、エントリポイントを含んでいますので、Clone して動作確認もしていただけるかと思います。
オチ by GPT
本記事もまた「例外処理」に守られている……かどうかは分かりません。
もしこの記事に矛盾や不備が含まれていれば、それは UnobservedTaskException です。
(その場合は UXで対話モードに入り、ぜひコメント欄でご指摘お願いします!)
この記事には生成AI(ChatGPT)による原稿執筆支援を含んでいます。最終的な責任は筆者にあります。
GPT君お疲れ様でした!w
ここまで書けるのはスゴイね、10年後には重役会議でプレゼンだね!笑
おしまい