GoのError Handlingを極める:Wrap Error、Custom Error、そして実践プロジェクトのBest Practices

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Goには独自のエラーハンドリング方式があります——JavaのようなtryとcatchもなければPythonのようなexceptionもありません。関数はerrorを通常のreturn valueとして返し、callerが自分で処理方法を決めます。シンプルに聞こえますが、プロジェクトが大きくなるにつれ、明確な戦略がないとデバッグが悪夢と化します。

直近で関わったWebアプリのプロジェクトには5人のdeveloperがいました。最初の週はそれぞれがバラバラな書き方をしていました——errors.Newを使う人、fmt.Errorfでwrapする人、独自のstructを定義する人。結果は?「something went wrong」というメッセージしか残っていないproductionエラーを追うのに午前中まるごとかかりました。チームで話し合ってルールを統一したら、同じ種類のエラーは10分ほどで特定できるようになりました。

Goでよく使われる3つのエラーハンドリングアプローチ

アプローチ1:errors.Newとfmt.Errorfのシンプルな使い方

最もシンプルな方法で、スクリプトや小規模な内部ヘルパーに適しています:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("record not found")

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", fmt.Errorf("invalid user id: %d", id)
    }
    if id == 999 {
        return "", ErrNotFound
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(0)
    if err != nil {
        fmt.Println(err) // invalid user id: 0
    }

    _, err = getUserByID(999)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("ユーザーが存在しません")
    }
}

Sentinel errorsErrNotFoundのような)はパッケージレベルで定義されます。callerはerrors.Is(err, ErrNotFound)でチェックします——errorsがwrapされると==での直接比較は失敗するため、直接比較は避けるべきです。

アプローチ2:Custom Error Type

エラーにメタデータを付与する必要がある場合——HTTPステータスコード、エラーコード、操作名など——custom structが適切な選択です:

type AppError struct {
    Code    int
    Message string
    Op      string // 操作名
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%s] %d: %s", e.Op, e.Code, e.Message)
}

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", &AppError{
            Code:    400,
            Message: "invalid user id",
            Op:      "getUserByID",
        }
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(-1)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            fmt.Printf("HTTP %d: %s\n", appErr.Code, appErr.Message)
        }
    }
}

errors.Asはerror chain全体をtraverseして一致するtypeを探します——直接的なtype assertionが表面しか見ないのとは異なります。

アプローチ3:%wによるWrap Error

Go 1.13以降、fmt.Errorf%w verbをサポートしており、元のerrorをwrapしてcall stackのコンテキストをすべて保持できます:

func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)
    }
    _ = data
    return nil
}

func initApp() error {
    if err := readConfig("/etc/app/config.yaml"); err != nil {
        return fmt.Errorf("initApp: %w", err)
    }
    return nil
}

func main() {
    err := initApp()
    if err != nil {
        fmt.Println(err)
        // initApp: readConfig: open /etc/app/config.yaml: no such file or directory

        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Configファイルが見つかりません")
        }
    }
}

エラーメッセージにはcall pathの全体が出力されます——1行見るだけで、エラーがどの関数を通ってきたかがすぐわかります。

メリットとデメリットの分析

  • errors.New / fmt.Errorf のシンプルな使い方——記述が速く、ボイラープレートが少ない。しかし、callerはエラーの種類を区別できません:404なのか、401なのか、database timeoutなのかわかりません。条件付きの処理はほぼ不可能です。小さなスクリプトや内部ヘルパーには適しています。
  • Custom Error Type——エラーにメタデータ(HTTPステータス、エラーコード、操作名)を付与できます。callerはerrors.Asで正確なtypeを捕捉して各ケースを処理します。コストはerror typeごとに約10〜15行のボイラープレートですが、API layerとbusiness logicには投資する価値があります。
  • %wによるWrap Error——initApp: readConfig: open /etc/app/config.yaml: no such fileのようなコンテキストのチェーンを自動的に構築します。1行のログでcall path全体をtraceできます。多層wrapではchainが長くなることがありますが、それは許容できるトレードオフです。

これら3つの方法は相互に排他的ではありません——実際のプロジェクトでは通常3つを組み合わせて使い、それぞれが適切な役割を果たします。

状況に合ったアプローチの選び方

チーム内で使っている階層化のルールを以下に紹介します:

  • Repository / Database layer:%wで元のerrorをwrapし、関数名をprefixとして付けます。例:fmt.Errorf("UserRepo.GetByID: %w", err)。databaseドライバーからのエラーはそのまま保持されますが、コンテキストが追加されます。
  • Service / Business logic layer:特定のビジネスロジックエラー用にCustom Error Typeを定義します。例:ErrUserSuspendedErrQuotaExceeded。上位のcallerはerrors.Asで各タイプを処理します。
  • Handler / API layer:errors.AsでCustom Errorを捕捉し、適切なHTTP responseに変換します。不明なエラーは500を返し、error chainを完全にログ出力します。

Sentinel errorsは、callerが本当にerrors.Isで比較する必要がある場合にのみ作成します。使われないErr*変数を大量に作るのは避けましょう——パッケージレベルの汚染につながります。

実践プロジェクトでの実装ガイド

ステップ1:専用のapperrorsパッケージを作成する

// internal/apperrors/errors.go
package apperrors

import "fmt"

type ErrorCode int

const (
    ErrCodeNotFound     ErrorCode = 404
    ErrCodeUnauthorized ErrorCode = 401
    ErrCodeInternal     ErrorCode = 500
)

type AppError struct {
    Code    ErrorCode
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

// Unwrapにより errors.Is と errors.As がerror chainをtraverseできるようになる
func (e *AppError) Unwrap() error {
    return e.Err
}

func NotFound(msg string) *AppError {
    return &AppError{Code: ErrCodeNotFound, Message: msg}
}

func Unauthorized(msg string) *AppError {
    return &AppError{Code: ErrCodeUnauthorized, Message: msg}
}

func Wrap(err error, msg string) *AppError {
    return &AppError{Code: ErrCodeInternal, Message: msg, Err: err}
}

ステップ2:service layerで使用する

// internal/service/user.go
package service

import (
    "errors"
    "fmt"
    "myapp/internal/apperrors"
    "myapp/internal/repository"
)

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            return nil, apperrors.NotFound(fmt.Sprintf("user %d not found", id))
        }
        return nil, apperrors.Wrap(err, "UserService.GetUser")
    }
    return user, nil
}

ステップ3:HTTPハンドラーで処理する

// internal/handler/user.go
package handler

import (
    "errors"
    "net/http"
    "myapp/internal/apperrors"
)

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := parseID(r)
    user, err := h.service.GetUser(id)
    if err != nil {
        var appErr *apperrors.AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, int(appErr.Code))
            return
        }
        http.Error(w, "Internal Server Error", 500)
        return
    }
    writeJSON(w, user)
}

覚えておくべきBest Practices

  • 最上位の層でのみログを出力する——すべての関数でエラーをログに記録すると、同じエラーがログに5回現れて読みにくくなります。handlerまたはmainで1回だけログを出力しましょう。
  • wrapする際にコンテキストを省略しない——return fmt.Errorf("something went wrong: %w", err)は意味がありません。常に関数名や具体的な情報を記述します:fmt.Errorf("UserService.Create: %w", err)
  • error chainをサポートするにはUnwrap()を実装する——Unwrap()がないと、errors.Iserrors.Asはcustom structをtraverseしません。
  • Sentinel errorsはエクスポートされErrプレフィックスを持つ必要がある——Go conventionに従って:var ErrNotFound = errors.New(...)notFoundErrorのような名前はcallerがimportできないため使わないこと。
  • エラーを無視しない——_ = someFunc()やエラーを捕捉してログなしで即returnするのは危険なパターンです。少なくともwrapしてreturnし、上位の層が判断できるようにしましょう。

チームで非常に効果的だと感じた小さな規約があります:各エラーメッセージをPackageName.FunctionName:で始めることです。productionログを読むだけで、どのファイルのどの関数をデバッグすればいいかが即座にわかります。特別なツールやframeworkは不要——チームで合意して規律を守るだけです。

Goのエラーハンドリングは構文的には難しくありません。難しいのは、codebaseが大きくなるにつれて一貫性を保つことです。よくお勧めするアプローチ:最初はfmt.Errorf + %wのシンプルな方法から始め、処理の分岐が必要になったらCustom Error Typeを追加し、チームが3人を超えたらapperrorsパッケージを独立させます。最終的な判断基準——新しいメンバーが5分見るだけで「エラーが発生したらどうするか、どこでログを出力するか、エラーの根本原因をどうやって追跡するか」が理解できることです。

Share: