WinFormsにおけるカスタム例外クラスの設計と業務エラーハンドリング:ユーザー向けメッセージと開発者向けスタックトレーサビリティの分離戦略
プログラミングスクールの課題を卒業し、エンタープライズ領域で業務システムを構築するフェーズに入ったエンジニアが真っ先にぶち当たる壁——それが「例外処理の設計」です。
現場でいまだに散見される最悪のコードパターン、それは画面のあらゆるイベントハンドラに `Try…Catch ex As Exception` を貼り付け、以下のようなダイアログをエンドユーザーに突きつける実装です。
[エラー]
Object reference not set to an instance of an object.
at System.Data.SqlClient.SqlConnection.Open()
at CoreSystem.OrderService.Execute(Int32 id) in C:\Build\…
現場のリーダーとして言わせてもらえば、これは「エラーハンドリング」ではなく「障害の垂れ流し」です。
エンドユーザーに必要なのは「今何が起きていて、次にどう対処すればよいか」という業務的解釈(ビジネスドメイン言語)であり、データベースの接続文字列やスタックトレースではありません。一方で、我々開発者・保守担当者が必要としているのは、バグの発生行、呼び出し履歴、内部状態、スタックトレースといった再現・修正のための一次情報(システム障害情報)です。
本記事では、Visual Basic (.NET) におけるWindows Forms(WinForms)アプリケーションにおいて、「業務例外(Business Exception)」と「システム例外(System Exception)」を極限まで分離し、保守性と堅牢性を劇的に跳ね上げる完全防御型のエラーハンドリングアーキテクチャを伝授します。
—
1. 概念設計:2つの例外を厳格に切り分ける
エラーハンドリングを正しく設計するための第一歩は、アプリケーション内で発生するすべての例外を2つの境界領域に大別することです。
┌─────────────────────────────────────────┐
│ 発生した例外 (Exception) │
└────────────────────┬────────────────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ 業務例外 (BusinessException) │ │ システム例外 (SystemException) │
├──────────────────────────────┤ ├──────────────────────────────┤
│ ・入力値のバリデーション違反 │ │ ・DB接続切れ、ネットワーク遮断│
│ ・在庫不足、限度額超過 │ │ ・NullReference, OutOfMemory │
│ ・業務ルール違反 │ │ ・バグ、未想定のシステムエラー│
├──────────────────────────────┤ ├──────────────────────────────┤
│ ユーザー:親切なガイダンス │ │ ユーザー:一律のエラー通知 │
│ ログ :INFO / WARN レベル │ │ ログ :ERROR / FATAL+Trace│
└──────────────────────────────┘ └──────────────────────────────┘
(1) 業務例外(Business Exception)
ユーザーの操作不備や業務ルールの逸脱(例:入力値エラー、在庫不足、重複登録など)によって生じる例外です。
- 想定範囲内の事象であり、システムとしては「正しく処理を中断した」状態。
- ユーザーに具体的な対処(「数値を1以上で入力してください」など)を促す。
- スタックトレースの保存は原則不要(必要に応じてログレベルはInfo/Warn)。
(2) システム例外(System Exception)
コードのバグ(`NullReferenceException`等)やインフラの破綻(DBダウン、ディスク満杯、API応答拒否)に起因する例外です。
- 想定外の障害。
- ユーザーには「システムエラーが発生しました。管理者にお問い合わせください(エラーコード: ERR-5001)」といった抽象的なメッセージのみを見せる。
- スタックトレース、内部例外(InnerException)、環境情報を漏れなくログファイルやイベントログに即座に記録する。
—
2. 実践コード:堅牢なカスタム業務例外クラスの作成
まずは、業務例外を定義するためのベースクラスとサブクラスを作成します。
VB.NETのオブジェクト指向機能をフルに活用し、エラーコードやユーザー向けメッセージをスマートに保持する構造にします。
Imports System.Runtime.Serialization
”’
”’ ユーザー向けメッセージとエラーコードを強制的に保持させます。
”’
Public Class BusinessException
Inherits Exception
”’
Public ReadOnly Property ErrorCode As String
”’
Public ReadOnly Property UserFacingMessage As String
”’
”’
”’ エラーコード
”’ ユーザー向け表示メッセージ
”’ 開発者向け内部メッセージ(省略時はuserFacingMessageと同値)
Public Sub New(errorCode As String, userFacingMessage As String, Optional internalMessage As String = Nothing)
MyBase.New(If(String.IsNullOrEmpty(internalMessage), userFacingMessage, internalMessage))
Me.ErrorCode = errorCode
Me.UserFacingMessage = userFacingMessage
End Sub
”’
”’
Public Sub New(errorCode As String, userFacingMessage As String, innerException As Exception)
MyBase.New(userFacingMessage, innerException)
Me.ErrorCode = errorCode
Me.UserFacingMessage = userFacingMessage
End Sub
Protected Sub New(info As SerializationInfo, context As StreamingContext)
MyBase.New(info, context)
Me.ErrorCode = info.GetString(NameOf(ErrorCode))
Me.UserFacingMessage = info.GetString(NameOf(UserFacingMessage))
End Sub
Public Overrides Sub GetObjectData(info As SerializationInfo, context As StreamingContext)
If info Is Nothing Then Throw New ArgumentNullException(NameOf(info))
info.AddValue(NameOf(ErrorCode), Me.ErrorCode)
info.AddValue(NameOf(UserFacingMessage), Me.UserFacingMessage)
MyBase.GetObjectData(info, context)
End Sub
End Class
”’
”’
Public Class ValidationException
Inherits BusinessException
Public ReadOnly Property PropertyName As String
Public Sub New(propertyName As String, userFacingMessage As String)
MyBase.New(“ERR-VAL-001″, userFacingMessage, $”Validation failed for property ‘{propertyName}’: {userFacingMessage}”)
Me.PropertyName = propertyName
End Sub
End Class
—
3. 集約型エラーハンドリング(Global Exception Handler)の実装
各Formのボタンイベントなどで毎回 `MessageBox.Show` を呼ぶ実装は即刻廃止してください。コードが重複し、ログ漏れの原因になります。
WinFormsでは、「Unhandled Exception(捕捉されなかった例外)」を一箇所で集約キャッチする仕組み(セーフティネット)を構築するのが王道です。
捕捉すべき3つのレイヤー
1. `Application.ThreadException`:UIスレッドで発生した未捕捉例外。
2. `AppDomain.CurrentDomain.UnhandledException`:ワーカースレッド(バックグラウンド処理)で発生した未捕捉例外。
3. `TaskScheduler.UnobservedTaskException`:`Task`(Async/Await)内で発生した未捕捉例外。
以下に、アプリケーションエントリーポイント(`Program.vb` または `ApplicationEvents.vb`)に組み込む完全なロジックを示します。
エントリーポイントの実装 (`Program.vb`)
Imports System.Threading
Imports System.Threading.Tasks
Imports System.Windows.Forms
Public Module Program
Public Sub Main()
‘ アプリケーションの初期化
Application.EnableVisualStyles()
Application.SetCompatibleTextRenderingDefault(False)
‘ 未捕捉例外ハンドラの設定 (セーフティネットの構築)
AddHandler Application.ThreadException, AddressOf Application_ThreadException
AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf CurrentDomain_UnhandledException
AddHandler TaskScheduler.UnobservedTaskException, AddressOf TaskScheduler_UnobservedTaskException
‘ すべてのUIスレッド例外を Application.ThreadException に強制ルーティングする設定
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException)
‘ メインフォームの起動
Application.Run(New MainForm())
End Sub
”’
”’
Private Sub Application_ThreadException(sender As Object, e As ThreadExceptionEventArgs)
AppExceptionHandler.HandleException(e.Exception)
End Sub
”’
”’
Private Sub CurrentDomain_UnhandledException(sender As Object, e As UnhandledExceptionEventArgs)
Dim ex As Exception = TryCast(e.ExceptionObject, Exception)
If ex Is Nothing Then
ex = New Exception($”Unknown Fatal Error: {e.ExceptionObject}”)
End If
AppExceptionHandler.HandleException(ex, isFatal:=True)
End Sub
”’
”’
Private Sub TaskScheduler_UnobservedTaskException(sender As Object, e As UnobservedTaskExceptionEventArgs)
AppExceptionHandler.HandleException(e.Exception.InnerException)
e.SetObserved() ‘ 例外を認識したとして扱い、プロセス終了を防止
End Sub
End Module
—
4. 振り分けコアロジックとプロフェッショナルなUIダイアログ
集約ハンドラから呼び出される `AppExceptionHandler` クラスを実装します。ここでは、発生した例外が 「業務例外」か「システム例外」かを判定し、表示とログを完全に制御 します。
例外振り分けコントローラー (`AppExceptionHandler.vb`)
Imports System.IO
Imports System.Text
Imports System.Windows.Forms
Public Class AppExceptionHandler
”’
”’
Public Shared Sub HandleException(ex As Exception, Optional isFatal As Boolean = False)
If ex Is Nothing Then Return
‘ 1. 業務例外 (BusinessException) の処理
Dim busEx As BusinessException = TryCast(ex, BusinessException)
If busEx IsNot Nothing Then
‘ ユーザーには親切な業務ダイアログを表示
ShowBusinessErrorDialog(busEx)
‘ 業務例外は警告(WARN)ログとして静かに記録(スタックトレースは不要)
WriteLog($”[WARN] [{busEx.ErrorCode}] {busEx.Message}”)
Return
End If
‘ 2. システム例外 (System Exception) の処理
‘ 開発者向けスタックトレースとシステム情報を構築
Dim logDetail As String = BuildSystemErrorLog(ex)
‘ ディスク等へのログ書き込み (失敗しても落ちないように保護)
WriteLog(logDetail)
‘ ユーザー向けには内部詳細を「完全に伏せた」システムエラーダイアログを表示
ShowSystemErrorDialog(ex, isFatal)
End Sub
Private Shared Sub ShowBusinessErrorDialog(ex As BusinessException)
MessageBox.Show(
ex.UserFacingMessage,
$”入力確認 ({ex.ErrorCode})”,
MessageBoxButtons.OK,
MessageBoxIcon.Warning
)
End Sub
Private Shared Sub ShowSystemErrorDialog(ex As Exception, isFatal As Boolean)
Dim userMessage As String = “システム内部で予期せぬエラーが発生しました。” & vbCrLf &
“大変お手数ですが、サポート窓口へお問い合わせください。” & vbCrLf & vbCrLf &
$”エラー種別: {ex.GetType().Name}”
If isFatal Then
userMessage &= vbCrLf & “※アプリケーションを終了する必要があります。”
End If
‘ 本番運用では、開発者モード(設定ファイル等)の時のみ「詳細情報ボタン」等を出すダイアログフォームに拡張するのが望ましい
MessageBox.Show(
userMessage,
“システムエラー”,
MessageBoxButtons.OK,
MessageBoxIcon.Error
)
If isFatal Then
Environment.Exit(1)
End If
End Sub
”’
”’
Private Shared Function BuildSystemErrorLog(ex As Exception) As String
Dim sb As New StringBuilder()
sb.AppendLine(“==================================================”)
sb.AppendLine($”[FATAL/ERROR] 発生日時: {DateTime.Now:yyyy/MM/dd HH:mm:ss.fff}”)
sb.AppendLine($”PC名: {Environment.MachineName} / ユーザー: {Environment.UserName}”)
sb.AppendLine($”例外型: {ex.GetType().FullName}”)
sb.AppendLine($”メッセージ: {ex.Message}”)
sb.AppendLine(“————————————————–“)
sb.AppendLine(“[スタックトレース]”)
sb.AppendLine(ex.StackTrace)
‘ InnerException を再帰的に展開
Dim inner As Exception = ex.InnerException
Dim depth As Integer = 1
While inner IsNot Nothing
sb.AppendLine($”————————————————–“)
sb.AppendLine($”[内部例外 Level {depth}] {inner.GetType().FullName}”)
sb.AppendLine($”メッセージ: {inner.Message}”)
sb.AppendLine(inner.StackTrace)
inner = inner.InnerException
depth += 1
End While
sb.AppendLine(“==================================================”)
Return sb.ToString()
End Function
Private Shared Sub WriteLog(message As String)
Try
‘ 簡易的なファイルログ(実業務では Serilog や NLog などのライブラリ推奨)
Dim logDir As String = Path.Combine(Application.StartupPath, “Logs”)
If Not Directory.Exists(logDir) Then Directory.CreateDirectory(logDir)
Dim logPath As String = Path.Combine(logDir, $”App_{DateTime.Now:yyyyMMdd}.log”)
File.AppendAllText(logPath, message & vbCrLf, Encoding.UTF8)
Catch
‘ ログ書き込みの失敗でメイン処理を落とさないための空キャッチ
End Try
End Sub
End Class
—
5. UIレイヤー(Form)における実際のコード記述法
中央集約型エラーハンドリングが完成したら、画面(Form)側のコードは劇的にシンプルになります。
Formでは「業務ルール違反が発生したら、カスタム業務例外を `Throw` するだけ」、あるいは「システム例外はあえて `Catch` せずに集約ハンドラへスルーする」という戦略を取ります。
業務画面のコード (`MainForm.vb`)
Public Class MainForm
”’
”’
Private Async Sub btnSubmit_Click(sender As Object, e As EventArgs) Handles btnSubmit.Click
‘ Try…Catch は画面独自の復旧処理(例:フォーカスを戻す、コントロールを再有効化等)が必要な場合のみ書く。
‘ それ以外はCatchせず、グローバルハンドラに任せてよい。
Try
btnSubmit.Enabled = False
‘ 入力チェック(業務例外の送出)
ValidateInputs()
‘ 業務ロジック呼び出し
Await ProcessOrderAsync()
MessageBox.Show(“処理が正常に完了しました。”, “成功”, MessageBoxButtons.OK, MessageBoxIcon.Information)
Catch busEx As BusinessException
‘ 業務例外の場合のみ、特定コントロールへフォーカスを当てる等のUIケアを行う
AppExceptionHandler.HandleException(busEx)
FocusInvalidControl(busEx)
Finally
btnSubmit.Enabled = True
End Try
End Sub
Private Sub ValidateInputs()
‘ 数値検証
Dim quantity As Integer
If Not Integer.TryParse(txtQuantity.Text, quantity) OrElse quantity <= 0 Then
' 業務例外を発行! スタックトレースを汚さず、業務メッセージのみをセット
Throw New ValidationException(NameOf(txtQuantity), "数量には1以上の整数を入力してください。")
End If
End Sub
Private Async Function ProcessOrderAsync() As Task
' DBアクセス等の処理(ここで DB接続不可やSQLエラーが発生すると SqlException がスローされる)
' これらは System Exception なので、このメソッド内では Catch せず放置する。
' 最終的に Application.ThreadException / TaskScheduler に捕捉され、自動でログ化&抽象ダイアログ表示される。
Await Task.Delay(500) ' 擬似非同期処理
' バグのテスト用(コメントアウトを外すと NullReferenceException が飛ぶ)
' Dim obj As Object = Nothing
' Dim str As String = obj.ToString()
End Function
Private Sub FocusInvalidControl(ex As BusinessException)
Dim valEx = TryCast(ex, ValidationException)
If valEx IsNot Nothing Then
If valEx.PropertyName = NameOf(txtQuantity) Then
txtQuantity.Focus()
txtQuantity.SelectAll()
End If
End If
End Sub
End Class
---
6. エンジニアが陥る「例外処理の3大アンチパターン」
最後になぜあなたのプロジェクトのバグ調査が難航するのか、その根源的な理由となる絶対NGコードを指摘しておきます。リーダーとしてチームのコードレビューで以下を見つけたら即座にリジェクトしてください。
① スタックトレースの抹殺:`Throw ex`
‘ 【絶対NG】
Catch ex As Exception
‘ …何かログを書く…
Throw ex ‘ ← コレ!スタックトレースがこの行で上書きされ、発生元の行番号が消滅する!
End Catch
‘ 【正解】
Catch ex As Exception
‘ …何かログを書く…
Throw ‘ ← 元のスタックトレースを保持したまま上方に再スローされる
End Catch
② 例外の握りつぶし(Pokemon Catching)
‘ 【絶対NG】
Try
ExecuteDatabaseOperation()
Catch ex As Exception
‘ 何もしない(または Console.WriteLine だけ)
End Try
システム障害を隠蔽し、後続処理で謎の二次災害(データ不整合など)を引き起こす悪魔の実装です。処理を続行できないなら、絶対に握りつぶしてはいけません。
③ 業務ロジックのフロー制御に `Exception` を使う
‘ 【非効率なコード】
Try
Dim user = GetUser(id)
Catch ex As Exception
‘ ユーザーが存在しない場合を例外で判定
CreateNewUser(id)
End Try
.NETにおいて例外オブジェクトの生成(スタックフレームのキャプチャ)は非常にコストの重い処理です。検索結果が存在しないなどの「正常な業務バリエーション」には例外を使わず、`Nullable(Of T)` や `TryGetUser` パターンを使用してください。
—
まとめ:堅牢なアプリケーションがもたらす価値
今回構築したアーキテクチャの利点は以下の通りです。
1. エンドユーザーのストレス軽減とセキュリティ向上:
意味不明な英単語や内部構造(テーブル名、接続文字列など)を隠蔽し、安心感のある明確なガイダンスを提供できる。
2. 保守コストの劇的削減:
障害発生時、ログファイルを開けば「発生日時・PC・実行ユーザー・エラーの起因行・内部例外」が1秒で特定できる。
3. Form側コードのクリーン化:
画面側のコードから不必要な `Try…Catch` と `MessageBox.Show` が一掃され、本質的なUI制御と業務ロジックに集中できる。
エラーハンドリングを制する者は、システム開発を制します。
ただ動くコードを書くフェーズを脱し、「障害発生時にどう振る舞うか」まで完璧に設計されたプロフェッショナルなVB.NETアプリケーションを構築してください。
