Featured image of post 独自例外設計のアンチパターン(Exception.message はログ用)

独自例外設計のアンチパターン(Exception.message はログ用)

例外の message に UI テキストを入れてはいけない。エラー情報はレスポンスとして設計する

要点

Exception.messageログ用 である。エラーの原因を特定するための技術的な情報を入れる場所であり、ユーザーに表示するテキストを運ぶ場所ではない。

UI に表示するエラー情報は、例外ではなく レスポンス として設計すべきである。

アンチパターン:例外が UI を運ぶ

以下は、実際のプロジェクトで見かけたコードを簡略化したものである。

1
2
3
4
5
class ApiCommonException(
    val title: String? = null,
    message: String,
    val imageUrl: String? = null,
) : Exception(message)

例外クラスに titleimageUrl — UI の表示仕様がそのまま入っている。

この例外は、サーバーからのエラーレスポンスを変換する過程で生成される。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun handleApiException(exception: ApiException): Throwable {
    val details = exception.details as? Map<*, *>
        ?: return ApiCommonException(message = exception.message ?: "")

    return ApiCommonException(
        title = details["title"] as? String,
        message = requireNotNull(exception.message),
        imageUrl = details["imageUrl"] as? String,
    )
}

なぜこうなるのか

サーバー側が、エラー時に「例外の details フィールド」に UI 表示用の情報を詰めて返す設計をしている。クライアントはそれを受け取り、例外クラスに変換する。

つまり、サーバーの設計が「エラー = 例外」という前提で作られている ことが根本原因である。

何が問題か

問題 影響
責務の混在 例外がロギングと UI 表示の両方を担う。message がどちら用なのか曖昧になる
サーバー・クライアント間の密結合 例外の構造が UI の表示仕様に依存する。UI を変えるとサーバー側も変更が必要
テストの困難さ エラー表示のテストに例外の throw/catch が必要になる
拡張性の欠如 多言語対応、エラーの種別ごとの表示分岐などが例外クラスに集中する

正しい設計:エラーはレスポンスとして返す

サーバーは、エラー時にも 構造化されたレスポンス を返すべきである。RFC 7807(Problem Details for HTTP APIs)はその標準仕様である。

1
2
3
4
5
6
{
  "type": "https://example.com/errors/insufficient-balance",
  "title": "残高が不足しています",
  "status": 400,
  "detail": "現在の残高は 120 円です。必要額は 500 円です。"
}

クライアント側では、このレスポンスを データクラス として扱う。例外として扱う必要はない。

1
2
3
4
5
6
data class ErrorResponse(
    val type: String,
    val title: String,
    val status: Int,
    val detail: String? = null,
)

エラーは「異常な状態」ではなく「想定されたレスポンスの一種」である。ユーザーへの通知が必要なエラーであればなおさら、設計されたレスポンスとして扱うのが自然である。

Exception.message の正しい使い方

Exception.message には、開発者がログを見て原因を特定できる情報 を入れる。

1
2
3
4
5
6
7
// OK: ログ用の技術情報
throw IllegalStateException("User ID is null after authentication")
throw IOException("Connection timed out after 30s: host=api.example.com")

// NG: UI 表示用のテキスト
throw AppException("残高が不足しています")
throw AppException("ネットワークに接続できません。再試行してください。")

ユーザー向けのメッセージは、前述のようにレスポンスとして受け取るか、クライアント側で例外の種別に応じてマッピングする 1

まとめ

Exception.message はログ用である。UI テキストを入れる場所ではない。

エラー情報をユーザーに伝える必要がある場合は、サーバーが構造化されたレスポンス(RFC 7807 等)を返し、クライアントがそれをデータとして扱う。例外の中に UI の仕様を詰め込む設計は、責務の混在と密結合を招く。

そもそもクライアント開発において、独自例外を定義する場面自体が少ない。次の記事ではこの点を掘り下げる。

エラーは例外ではなく Result で返す。独自例外を定義する前に、本当に throw が必要か考える
cover.webp

  1. マッピングの際、リソース ID と、リテラル文字列の混在で悩むことがある。AdaptiveString を使えばこれを解消できる。

    getString のために Context を渡すのをやめる。文字列リソースの解決を View まで遅延させる設計
    cover.webp
     ↩︎

Hugo で構築されています。
テーマ StackJimmy によって設計されています。