Kuboyage

・ Go / 設計

エラーもドメインの一部 - Go言語における戦略的エラーハンドリング設計 🎯

はじめに 👋

こんにちは!H×Hのセンリツ大好きエンジニアです!✌️

Go言語でアプリケーションを開発していると、必ず直面するのがエラーハンドリングの設計問題ですよね。if err != nil の嵐になったり、エラーメッセージが不親切だったり、デバッグ時に必要な情報が足りなかったり...そんな経験はありませんか?😅

この記事はおよそ6800字なので、10分で読めます!

※この記事は7割程度がAIによって執筆されています。

目次 📚

  1. エラーハンドリングの3つの観点
  2. 従来のエラーハンドリングの課題
  3. Failure is Your Domainアプローチ
  4. エラー構造体の設計
  5. エラーコードの定義
  6. 実践的な実装例
  7. エラー情報の取得
  8. HTTPレスポンスへの適用
  9. まとめ

エラーハンドリングの3つの観点 🎭

まずは、エラー情報を必要とする3つの異なる消費者を理解することから始めましょう!

エラー情報には、以下の3つの異なる消費者が存在します:

1. アプリケーション(Application)💻

アプリケーションコードは、機械可読なエラーコードを必要とします。例えば、ユーザーが存在しない場合はENOTFOUND、バリデーションエラーの場合はEINVALIDといった具合です。

// アプリケーションはエラーコードで判断
if ErrorCode(err) == ENOTFOUND {
    // ユーザーが見つからない場合の処理
    return createNewUser()
}

2. エンドユーザー(End User)👥

エンドユーザーは、人間が読める分かりやすいエラーメッセージを必要とします。技術的な詳細は不要で、「何が問題で、どうすればいいか」が分かる情報が求められます。

// ユーザーに表示するメッセージ
"ユーザー名が既に使用されています。別の名前を選択してください。"

3. オペレーター(Operator)🔧

開発者や運用担当者は、詳細なコンテキスト情報とスタックトレースを必要とします。問題の根本原因を特定し、修正するための情報が必要です。

// ログに出力される詳細情報
user.Create: user validation failed: username already exists: conflict

従来のエラーハンドリングの課題 ❌

従来のGo言語のエラーハンドリングには、いくつかの課題がありました。

課題1: 情報の不足

// 従来の方法
func GetUser(id int) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, err  // どこでエラーが発生したかわからない
    }
    return user, nil
}

このコードの問題点:

  • エラーが発生した場所(論理的な操作)が不明
  • エラーの種類が判別できない
  • ユーザーに表示すべきメッセージがない

課題2: エラーの種類が判別できない

// エラーの種類を判別できない
err := GetUser(123)
if err != nil {
    // これは「ユーザーが見つからない」エラー?
    // それとも「データベース接続エラー」?
    // 判別できない!
}

Failure is Your Domainアプローチ 🌟

「エラーは、CustomerやOrderと同じくらい重要なドメインの一部である」という考え方です!

このアプローチの核心は、エラーをドメインモデルの一級市民として扱うことです。つまり、エラー型も他のビジネスロジックの型と同じように、慎重に設計する必要があるということです。

主な原則

  1. エラーは構造化されたデータ - 文字列だけではなく、コード、メッセージ、操作情報を含む
  2. エラーは階層的 - ネストされたエラーで完全なコンテキストを提供
  3. 複数の消費者に対応 - アプリケーション、ユーザー、オペレーターそれぞれのニーズを満たす

エラー構造体の設計 🏗️

3つの観点をすべて満たすエラー構造体を設計しましょう!

基本的なエラー構造体

// Error は、アプリケーション固有のエラーを表します。
type Error struct {
    // 機械可読なエラーコード
    Code string

    // 人間が読めるエラーメッセージ
    Message string

    // 論理的な操作名(デバッグ用)
    Op string

    // ネストされた元のエラー
    Err error
}

各フィールドの役割:

  • Code: アプリケーションコードが判断に使用(例: ENOTFOUND, EINVALID)
  • Message: エンドユーザーに表示するメッセージ
  • Op: デバッグ時に操作の流れを追跡するため(例: "user.Create", "db.Query")
  • Err: 元のエラーをラップして、完全なコンテキストを保持

Error()メソッドの実装

// Error は、error インターフェースを実装します。
// オペレーター向けに、論理的なスタックトレースを生成します。
func (e *Error) Error() string {
    var buf bytes.Buffer

    // 現在の操作を出力
    if e.Op != "" {
        fmt.Fprintf(&buf, "%s: ", e.Op)
    }

    // ネストされたエラーがある場合
    if e.Err != nil {
        buf.WriteString(e.Err.Error())
    } else {
        // エラーメッセージまたはコードを出力
        if e.Message != "" {
            buf.WriteString(e.Message)
        } else {
            buf.WriteString(e.Code)
        }
    }

    return buf.String()
}

このメソッドにより、以下のような階層的なエラーメッセージが生成されます:

user.Create: user.Validate: username already exists: conflict

エラーコードの定義 📋

アプリケーション全体で統一されたエラーコードを定義します!

標準的なエラーコード

// アプリケーション固有のエラーコード
const (
    // ECONFLICT は、アクションを実行できない(競合)ことを示します。
    // 例: ユーザー名の重複、楽観的ロックの失敗
    ECONFLICT = "conflict"

    // EINTERNAL は、内部エラーを示します。
    // これは通常、予期しないエラーで、オペレーターによる調査が必要です。
    EINTERNAL = "internal"

    // EINVALID は、バリデーションエラーを示します。
    // 例: 必須フィールドの欠落、形式が正しくないデータ
    EINVALID = "invalid"

    // ENOTFOUND は、エンティティが見つからないことを示します。
    // 例: ユーザーID、商品IDが存在しない
    ENOTFOUND = "not_found"

    // EUNAUTHORIZED は、認証が必要なことを示します。
    EUNAUTHORIZED = "unauthorized"

    // EFORBIDDEN は、権限がないことを示します。
    EFORBIDDEN = "forbidden"
)

エラーコードとHTTPステータスコードのマッピング

// ErrorStatusCode は、エラーコードをHTTPステータスコードに変換します。
func ErrorStatusCode(code string) int {
    switch code {
    case ECONFLICT:
        return http.StatusConflict // 409
    case EINVALID:
        return http.StatusBadRequest // 400
    case ENOTFOUND:
        return http.StatusNotFound // 404
    case EUNAUTHORIZED:
        return http.StatusUnauthorized // 401
    case EFORBIDDEN:
        return http.StatusForbidden // 403
    case EINTERNAL:
        return http.StatusInternalServerError // 500
    default:
        return http.StatusInternalServerError // 500
    }
}

実践的な実装例 💻

実際のアプリケーションでどのように使用するか見ていきましょう!

ユーザー作成処理の例

完全なユーザー作成処理の実装コード(80行)
package user

import (
    "database/sql"
    "fmt"
)

// Service は、ユーザー関連のビジネスロジックを提供します。
type Service struct {
    db *sql.DB
}

// Create は、新しいユーザーを作成します。
func (s *Service) Create(username, email string) (*User, error) {
    const op = "user.Create"

    // バリデーション
    if err := s.validate(username, email); err != nil {
        return nil, &Error{
            Op:  op,
            Err: err,
        }
    }

    // 重複チェック
    exists, err := s.usernameExists(username)
    if err != nil {
        return nil, &Error{
            Op:  op,
            Err: err,
        }
    }
    if exists {
        return nil, &Error{
            Op:      op,
            Code:    ECONFLICT,
            Message: "このユーザー名は既に使用されています",
        }
    }

    // データベースへの保存
    user, err := s.insert(username, email)
    if err != nil {
        return nil, &Error{
            Op:  op,
            Err: err,
        }
    }

    return user, nil
}

// validate は、ユーザー情報のバリデーションを行います。
func (s *Service) validate(username, email string) error {
    const op = "user.Validate"

    if username == "" {
        return &Error{
            Op:      op,
            Code:    EINVALID,
            Message: "ユーザー名は必須です",
        }
    }

    if len(username) < 3 {
        return &Error{
            Op:      op,
            Code:    EINVALID,
            Message: "ユーザー名は3文字以上である必要があります",
        }
    }

    if email == "" {
        return &Error{
            Op:      op,
            Code:    EINVALID,
            Message: "メールアドレスは必須です",
        }
    }

    return nil
}

// usernameExists は、ユーザー名が既に存在するかチェックします。
func (s *Service) usernameExists(username string) (bool, error) {
    const op = "user.UsernameExists"

    var count int
    err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count)
    if err != nil {
        return false, &Error{
            Op:      op,
            Code:    EINTERNAL,
            Message: "データベースエラーが発生しました",
            Err:     err,
        }
    }

    return count > 0, nil
}

// insert は、ユーザーをデータベースに挿入します。
func (s *Service) insert(username, email string) (*User, error) {
    const op = "user.Insert"

    result, err := s.db.Exec(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        username, email,
    )
    if err != nil {
        return nil, &Error{
            Op:      op,
            Code:    EINTERNAL,
            Message: "ユーザーの作成に失敗しました",
            Err:     err,
        }
    }

    id, _ := result.LastInsertId()

    return &User{
        ID:       id,
        Username: username,
        Email:    email,
    }, nil
}

type User struct {
    ID       int64
    Username string
    Email    string
}

このコードの優れた点:

  1. 階層的なエラー情報: 各関数でOpを設定し、エラーの発生箇所を追跡可能
  2. 明確なエラーコード: ECONFLICT, EINVALID, EINTERNALで処理を分岐可能
  3. ユーザーフレンドリーなメッセージ: エンドユーザーに分かりやすいメッセージを提供

エラー発生時の出力例

user.Create: user.Validate: ユーザー名は3文字以上である必要があります: invalid

このエラーメッセージから分かること:

  • user.Create → ユーザー作成処理で発生
  • user.Validate → バリデーション処理が原因
  • ユーザーに表示するメッセージ: 「ユーザー名は3文字以上である必要があります」
  • エラーコード: invalid (バリデーションエラー)

エラー情報の取得 🔍

エラーから必要な情報を取り出すユーティリティ関数を実装しましょう!

エラーコードの取得

// ErrorCode は、エラーから最も具体的なエラーコードを取得します。
// ネストされたエラーを再帰的に探索し、最初に見つかったコードを返します。
func ErrorCode(err error) string {
    if err == nil {
        return ""
    }

    // カスタムErrorの場合
    if e, ok := err.(*Error); ok {
        if e.Code != "" {
            return e.Code
        }
        // ネストされたエラーを探索
        return ErrorCode(e.Err)
    }

    // デフォルトは内部エラー
    return EINTERNAL
}

使用例:

err := userService.Create("ab", "[email protected]")
if err != nil {
    code := ErrorCode(err)

    switch code {
    case EINVALID:
        // バリデーションエラーの処理
        log.Printf("バリデーションエラー: %v", err)
    case ECONFLICT:
        // 競合エラーの処理
        log.Printf("ユーザー名が重複しています: %v", err)
    case EINTERNAL:
        // 内部エラーの処理
        log.Printf("予期しないエラー: %v", err)
    }
}

エラーメッセージの取得

// ErrorMessage は、エンドユーザー向けのメッセージを取得します。
// ネストされたエラーから、人間が読めるメッセージを探索します。
func ErrorMessage(err error) string {
    if err == nil {
        return ""
    }

    // カスタムErrorの場合
    if e, ok := err.(*Error); ok {
        if e.Message != "" {
            return e.Message
        }
        // ネストされたエラーを探索
        if e.Err != nil {
            return ErrorMessage(e.Err)
        }
    }

    // デフォルトメッセージ
    return "エラーが発生しました。しばらくしてから再度お試しください。"
}

使用例:

err := userService.Create("ab", "[email protected]")
if err != nil {
    // ユーザーに表示するメッセージを取得
    message := ErrorMessage(err)

    // UIに表示
    renderError(message) // "ユーザー名は3文字以上である必要があります"
}

HTTPレスポンスへの適用 🌐

Web APIでエラーを返す際の実践的なパターンです!

エラーレスポンスの構造体

// ErrorResponse は、API のエラーレスポンス形式です。
type ErrorResponse struct {
    Error string `json:"error"`           // エンドユーザー向けメッセージ
    Code  string `json:"code,omitempty"`  // エラーコード(オプション)
}

HTTPハンドラーでの使用例

完全なHTTPハンドラー実装例(60行)
package api

import (
    "encoding/json"
    "log"
    "net/http"
)

// Handler は、HTTP リクエストを処理します。
type Handler struct {
    userService *user.Service
}

// CreateUser は、ユーザー作成 API のハンドラーです。
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    // リクエストのパース
    var req struct {
        Username string `json:"username"`
        Email    string `json:"email"`
    }

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.writeError(w, &Error{
            Code:    EINVALID,
            Message: "リクエストが不正です",
            Err:     err,
        })
        return
    }

    // ユーザー作成
    user, err := h.userService.Create(req.Username, req.Email)
    if err != nil {
        h.writeError(w, err)
        return
    }

    // 成功レスポンス
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

// writeError は、エラーレスポンスを書き込みます。
func (h *Handler) writeError(w http.ResponseWriter, err error) {
    // エラーコードとメッセージを取得
    code := ErrorCode(err)
    message := ErrorMessage(err)

    // オペレーター向けにログ出力(詳細情報)
    log.Printf("API Error: %v", err)

    // HTTPステータスコードを決定
    statusCode := ErrorStatusCode(code)

    // エラーレスポンスを作成
    response := ErrorResponse{
        Error: message,
        Code:  code,
    }

    // レスポンスを書き込み
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(response)
}

レスポンス例

成功時(201 Created):

{
  "id": 123,
  "username": "john_doe",
  "email": "[email protected]"
}

バリデーションエラー時(400 Bad Request):

{
  "error": "ユーザー名は3文字以上である必要があります",
  "code": "invalid"
}

ユーザー名重複時(409 Conflict):

{
  "error": "このユーザー名は既に使用されています",
  "code": "conflict"
}

内部エラー時(500 Internal Server Error):

{
  "error": "エラーが発生しました。しばらくしてから再度お試しください。",
  "code": "internal"
}

サーバーログ(オペレーター向け):

API Error: user.Create: user.Validate: ユーザー名は3文字以上である必要があります: invalid

まとめ 🎉

「Failure is Your Domain」アプローチは、エラーハンドリングを次のレベルに引き上げます!

このアプローチの利点

  1. 保守性の向上 🔧

    • エラーの発生箇所が明確
    • 論理的なスタックトレースで問題の特定が容易
    • 一貫したエラー処理パターン
  2. 優れたユーザー体験 💕

    • 分かりやすいエラーメッセージ
    • 適切なHTTPステータスコード
    • 次のアクションが明確
  3. 効率的なデバッグ 🐛

    • 詳細なコンテキスト情報
    • エラーの階層構造
    • ログから問題を迅速に特定可能
  4. 柔軟な拡張性 🚀

    • 新しいエラーコードの追加が容易
    • プロジェクト固有のニーズに対応可能
    • 既存コードへの影響が最小限

実装時のベストプラクティス

✅ DO(推奨):

  • 各関数でOp(操作名)を設定する
  • ユーザーフレンドリーなMessageを提供する
  • 適切なCodeを選択する
  • エラーをラップして完全なコンテキストを保持する

❌ DON'T(非推奨):

  • エラーを単純な文字列として扱う
  • エラーコードを動的に生成する
  • 技術的な詳細をユーザーに見せる
  • エラー情報を途中で失う

参考リンク 🔗

次のステップ 🎯

このアプローチを自分のプロジェクトに適用する際は:

  1. まず、アプリケーション固有のエラーコードを定義
  2. Error構造体を実装
  3. ユーティリティ関数(ErrorCode, ErrorMessage)を実装
  4. 既存のエラー処理を段階的に移行

エラーハンドリングは地味ですが、アプリケーションの品質を大きく左右する重要な要素です。「Failure is Your Domain」アプローチで、より堅牢で保守性の高いコードを書いていきましょう!💪✨

ブログの記事一覧へ