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 errors(ErrNotFoundのような)はパッケージレベルで定義されます。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を定義します。例:
ErrUserSuspended、ErrQuotaExceeded。上位の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.Isとerrors.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分見るだけで「エラーが発生したらどうするか、どこでログを出力するか、エラーの根本原因をどうやって追跡するか」が理解できることです。

