Goのエラー処理アーキテクチャ:境界とパターン

適切な境界でエラーを処理する。

目次

Goのエラーハンドリングは、批判されやすいものです。 Go開発者は誰でも、このコードを数百回書いてきたでしょう:

if err != nil {
	return err
}

これが興味深い部分ではありません。興味深いのは、そのエラーが何を意味しているのか、どこで処理すべきか、どこでラップすべきか、どこで変換すべきか、どこでログに出すべきか、呼び出し元に何を公開すべきか——つまりアーキテクチャ上の問題です。

Goはエラーを値として扱います。これにより失敗が明示的になります。同時に、コードベースには明確なエラーハンドリング設計が必要です。設計がなければ、エラーはランダムな文字列となり、HTTPハンドラーがデータベースの詳細を漏らし、ログが同じ失敗を5回重複して記録し、誤った理由でリトライが発生し、呼び出し元が行動ではなくテキストを検査することになります。

Go エラーハンドリングアーキテクチャ: 各レイヤー間を流れるエラー

この記事は、if err != nil 向けの初心者向け入門ではありません。

これは、Goのエラーハンドリングアーキテクティカルな実践ガイドです:ラップ、センチネルエラー、カスタムエラー型、errors.Is、errors.As、エラーバウンダリー、APIマッピング、ログ出力、リトライ、セキュリティ、本番環境向けのパターンについてです。

少し主観的ではあるものの、Goのエラーを消そうとはしないでください。適切なバウンダリーで意味のあるものにするのです。

Goのエラーとは

Goにおいて、エラーはこのインターフェースを実装する単なる値です:

type error interface {
	Error() string
}

この小さなインターフェースが、Goのエラーハンドリングが直感的に感じられる理由です。

関数は明示的にエラーを返します:

func LoadUser(id string) (*User, error) {
	// ...
}

呼び出し元がどうするかを決定します:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

例外も隠れたスタックアンワインドもありません。失敗は関数シグネチャの一部です。

それは良いことですが、同時にエラーには設計が必要です。すべてのパッケージが任意のメッセージを返せば、呼び出し元は信頼できる決定を下せなくなります。すべてのレイヤーが無規律にすべてのエラーをラップすれば、運用者はノイジーなメッセージを受け取り、開発者は混乱したチェーンに直面します。逆に、どのレイヤーもエラーをラップしなければ、失敗は文脈を失います。

目標は、より少ないエラーハンドリングではなく、より良いエラーの「意味」です。

エラーの3つの役割

有用なエラーには、通常、1つ以上の役割があります。

役割1:何が失敗したかを説明する

人間にとって、エラーはどの操作が失敗したかを説明すべきです。

例:

return fmt.Errorf("load user %s: %w", id, err)

これにより文脈が与えられます。この失敗はユーザーを読み込んでいる間に発生したことがわかります。

役割2:原因を保持する

コードにとって、エラーは原因が重要な場合、根本原因を保持すべきです。

例:

return fmt.Errorf("load user %s: %w", id, err)

%w はオリジナルのエラーをラップし、呼び出し元が errors.Is または errors.As を使って検査できるようにします。

役割3:バウンダリーで決定を下させる

どこかのバウンダリーで、プログラムは次に何をすべきか決定する必要があります。

例:

  • HTTP 404 を返す
  • HTTP 409 を返す
  • 操作をリトライする
  • ワーニングレベルでログを出力する
  • ユーザー向けの安全なメッセージを表示する
  • トランザクションを中止する
  • エラーを監視に送信する
  • キャンセルを無視する

その決定は、通常、エラーの同一性や型に基づいて行うべきであり、文字列のマッチングではありません。

現代的なGoにおける主要なエラーツール

現代的なGoは、小さくても強力なツールのセットを提供します。

errors.New

errors.New は、シンプルなエラー値を作成するために使用します:

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

これはセンチネルエラーに有用です。

fmt.Errorf with %w

fmt.Errorf で %w を使ってエラーをラップします:

return fmt.Errorf("query user: %w", err)

ラップは、文脈を追加しながら、検査のためにオリジナルのエラーを保持します。

errors.Is

errors.Is は、エラーがそのチェーンのどこかで特定のターゲットに一致するかをチェックするために使用します:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

これはセンチネルエラーや既知の条件に使用します。

errors.As

errors.As は、チェーンから特定のエラー型を取り出すために使用します:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// use validationErr.Field or validationErr.Reason
}

これは、エラーが構造化されたデータを保持している場合に使用します。

errors.Join

複数のエラーが発生し、すべてを保持する必要がある場合は errors.Join を使用します:

return errors.Join(closeErr, flushErr)

結合されたエラーも、errors.Is や errors.As で検査できます。

慎重に使用してください。結合されたエラーは、複数の失敗が1つの結果の一部であることを意味します。

センチネルエラー

センチネルエラーは、既知の条件を表すパッケージレベルのエラー値です。

例:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

呼び出し元が失敗の分類だけを知ればよい場合に、センチネルエラーは有用です。

例:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

その後、サービスやハンドラーは以下のように確認できます:

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

センチネルエラーを使うとき

センチネルエラーは、以下の場合に使います:

  • 条件が安定している。
  • 呼び出し元がそれに基づいて分岐する必要がある。
  • 追加の構造化データは不要である。
  • エラーがあなたのパッケージやドメインに属する。

良い例:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

センチネルエラーを使わないとき

考えられるあらゆる失敗にセンチネルを作らないでください。

悪い例:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

呼び出し元がこれらに基づいて分岐しない場合、それらは単なるメッセージである可能性があります。

また、多くのセンチネルをエクスポートすることにも注意してください。エクスポートされたセンチネルエラーは、あなたのパッケージAPIの一部になります。

カスタムエラー型

エラーが構造化された情報を保持する場合、カスタムエラー型は有用です。

例:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

呼び出し元:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

これは、エラー文字列を解析するよりも良いです。

カスタムエラー型を使うとき

カスタムエラー型は、以下の場合に使います:

  • 呼び出し元に構造化データが必要である。
  • エラーに意味のあるフィールドがある。
  • その型があなたのパッケージの契約の一部である。
  • 呼び出し元が複数の値を異なる方法で処理する必要がある可能性がある。

例:

  • フィールド名を持つ検証エラー
  • リトライ時間を伴うレートリミットエラー
  • ステータスコードを伴うHTTPエラー
  • 行と列を伴うパースエラー
  • リソースIDを伴うドメインエラー

カスタムエラー型を使わないとき

errors.New を避けるだけでカスタム型を作らないでください。

これは不要です:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

有用なデータがない場合、センチネルで十分ことが多いです。

エラーのラップ

ラップは、オリジナルのエラーを保持しながらエラーに文脈を追加します。

例:

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

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

os.ReadFile が失敗した場合、呼び出し元は以下の両方を入手できます:

  • 上位の操作: read config
  • 低レベルの原因: permission denied, file not found など。

これら両方がエラーチェーンを介して利用可能であり、それが %w によるラップを一貫して実行する価値がある理由です。

有用な文脈でラップする

良いラップは、どの操作が失敗したかと言います:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

悪いラップはノイズを追加します:

return fmt.Errorf("error: %w", err)

これは呼び出し元に何も伝えていません。

また、すべてのレイヤーで同じ名詞を繰り返すのも避けてください:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

そのようなチェーンは技術的には正しいですが、実用的には厄介です。

ラップする場所は、文脈が意味を変えるところです。もし1つのフレーズで失敗した操作を説明できないなら、おそらくラップしすぎか、逆にラップし足りないかのどちらかです。

何をラップすべきで、何をすべきでないか

これは、最も重要なアーキテクチャ決定の一つです。

意味のあるバウンダリーを跨ぐ場合にラップする

エラーが1つの操作から上位の操作に移動する際にラップします。

例:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

リポジトリのエラーは現在、サービス操作の一部となり、運用者がログをたどって失敗の原因を特定する際に、その追加の文脈は有用です。

単に「失敗した」と言うためにラップしないこと

悪い例:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

「failed」という言葉は、エラーが存在することから通常暗示されています。

変換している場合はラップしないこと

時として、あるエラーを別のドメインエラーに変換すべきです。

例:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

これは意図的にデータベースの詳細を隠し、ドメインの条件を公開しています。

有用な場合は原因を保持しても構いませんが、それは意図的に行ってください。

偶然に実装詳細を公開しないこと

%w で低レベルのエラーをラップすると、呼び出し元がそれを検査できます。

アプリケーション内部では通常、これが望ましいです。

しかし、パブリックなパッケージAPIにおいて、ラップは実装詳細をあなたの契約の一部として公開する可能性があります。

例えば、あなたのパッケージが sql.ErrNoRows をラップする場合、呼び出し元がそれに依存し始める可能性があります:

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

後でストレージを変更する可能性がある場合、ドメインセンチネルを優先してください:

var ErrUserNotFound = errors.New("user not found")

その後、パッケージのバウンダリーからそれを返してください。

エラーバウンダリー

Goのエラーハンドリングを最も有用に考える方法は、バウンダリーを通じてです。

バウンダリーとは、エラーが意味や対象者を変える場所です。

一般的なバウンダリーには以下が含まれます:

  • データベースからリポジトリへ
  • リポジトリからサービスへ
  • サービスからHTTPハンドラーへ
  • サービスからCLIコマンドへ
  • 内部エラーからユーザー向けのメッセージへ
  • 一時的な失敗からリトライ決定へ
  • 操作の失敗からログイベントへ
  • ドメインエラーからAPIレスポンスへ

エラーアーキテクチャの大部分はバウンダリー設計です。各バウンダリーは、エラーが文脈を獲得し、実装詳細を失い、または次のレイヤーが対応できる形に変換される決定点です。

リポジトリバウンダリー

リポジトリはストレージと対話します。

通常、データベース固有のエラーをドメインエラーに変換すべきです。

例:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

リポジトリは sql.ErrNoRows を隠蔽し ErrUserNotFound を公開します——これがクリーンなバウンダリーであり、サービスがストレージがどのように「見つからない」かを表現する必要があることを知らなくてよいことを意味します。

サービスバウンダリー

サービスはビジネスの意味を所有します。

通常、操作の文脈を追加し、ドメインエラーを保持すべきです。

例:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

これは、予期しないエラーに文脈を追加しながら、ドメインの条件を保持しています。

より複雑なビジネスルールでは、サービスは直接ドメインエラーを作成できます:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

サービスはビジネスレベルのエラーにとって適した場所です——インフラの条件から変換されるのではなく、ドメインロジックから直接作成されます。

HTTPハンドラーバウンダリー

HTTPハンドラーは、アプリケーションのエラーをHTTPレスポンスに変換します。

これは、内部の詳細がユーザー向けの安全なレスポンスになるべきバウンダリーです。

Webフレームワークは、この変換を構造化する方法が異なります。Echoはハンドラーのエラーを単一の HTTPErrorHandler 経由で処理しますが、Ginのハンドラーはリクエストコンテキスト上で直接エラーをレンダリングします——これら2つのスタイル間のトレードオフについては、2026年のGo Webフレームワーク比較: net/http, chi, Gin, Echo, Fiber で比較されています。

例:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

エラーマッピング:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

ハンドラーは、生きたデータベースや内部エラーの詳細を公開するのではなく、ドメインエラーをHTTPセマンティクスにマッピングします。多くのGoアプリケーションがここで誤りを犯す箇所であり、内部の詳細をやりすぎるか、すべてのエラーをHTTP 500にまとめるかのどちらかです。Go APIにおけるハンドラーパターンとミドルウェアの完全な概要については、GoでのREST API構築 が、標準ライブラリ、Gin、Echo、Fiberにおける認証、ルーティング、エラーハンドリングを網羅しています。

CLIバウンダリー

CLIはHTTP APIとは異なるバウンダリーを持ちます。

CLIでは、エラーはコマンドを実行している人物にとって有用でなければなりません。

例:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

コマンドのバウンダリーでは:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

既知のエラーを終了コードにマッピングします:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

CLIはパブリックAPIよりも詳細を表示できますが、シークレットを漏らさないようにすべきです。

APIエラー型パターン

HTTP APIでは、小さなアプリケーションレベルのエラー型が有用なことがあります。

例:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

func (e *APIError) Unwrap() error {
	return e.Err
}

コンストラクタ:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

使用例:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

ハンドラー:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

このパターンは、安定したコードを持つ構造化されたAPIエラーを望む場合に有用です。

これをAPIバウンダリーで使用してください。すべての内部パッケージにAPI固有のエラーを返すことを強制しないでください。

ドメインエラーとトランスポートエラー

ドメインエラーとトランスポートエラーを分離してください。

ドメインエラー:

var ErrInsufficientBalance = errors.New("insufficient balance")

トランスポートマッピング:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

ドメインレイヤーからHTTPステータスコードを返さないでください:

return &APIError{Status: http.StatusConflict}

これはビジネスロジックをHTTPに結合させ、サービスレイヤーがHTTP、CLI、ワーカー、テスト、将来のgRPCアダプター間でクリーンに動作することを妨げます。トランスポートマッピングはドメインコードではなく、トランスポートバウンダリーに属します。プロジェクトレイアウト内でどこにドメインエラー、センチネル、トランスポートアダプターを定義すべきかについてのガイダンスについては、Goプロジェクト構造: 実践とパターン が、これらのレイヤーをクリーンに分離する internal/、pkg/、およびアダプター慣習をカバーしています。

リトライ可能なエラー

リトライをトリガーすべきエラーがあります。そうでないものもあります。

これを文字列マッチングで決定しないでください。

マーカーインターフェースまたは明示的な関数を使用します。

例:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

func (e *RetryableError) Unwrap() error {
	return e.Err
}

ヘルパー:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

使用例:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

リトライループ:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

これは、エラー文字列が “timeout” を含むかどうかをチェックするよりもはるかに優れています——文字列マッチングはメッセージが変わると静かに壊れ、プロデューサーとコンシューマー間の見えない結合を生み出します。

検証エラー

検証エラーは、しばしば構造化データが必要です。

例:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

使用例:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

ハンドラー:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

呼び出し元に不透明なエラー文字列ではなく、フィールド名や検証メッセージといった構造化情報が必要であるため、これは errors.As の良い使用例です。

複数のエラー

時として、複数のことが失敗します。

例:

  • 複数のリソースのクローズ
  • 多くのフィールドの検証
  • 複数のワーカーのシャットダウン
  • 独立したチェックの実行
  • 出力のフラッシュとクローズ

すべてのエラーを保持する必要がある場合は errors.Join を使用します。

例:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

呼び出し元:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

errors.Is も errors.As も結合されたエラーを検査でき、これは結合されたエラー値が標準のエラーチェックパターンと完全に互換性を保つことを意味します。

errors.Joinを使わないとき

プライマリエラーが1つあり、ログコンテキストがいくつかある場合に errors.Join を使用しないでください。

どのエラーが重要かを決定することを回避するために使用しないでください。

ユーザーに巨大な結合エラーを返さないでください。

結合されたエラーは有用ですが、すぐにノイジーになり得ます。

パニックはエラーハンドリングではない

通常のアプリケーションコードでは、予期されたエラーのためにパニックを使用しないでください。

悪い例:

if err != nil {
	panic(err)
}

パニックはプログラマーエラーや、本当に回復不能な状況に使用します。

例:

  • 不可能な内部インバリアントの違反
  • 無効なパッケージ初期化
  • t.Fatal や制限されたケースでのパニックを含むテストヘルパーの失敗
  • スタイルによって異なるが、回復不能な起動時の設定エラー

データベースクエリが失敗したり、ユーザーが無効な入力を送信したりしたからといって、パニックしないでください。

それらは正常なエラーです。

エラーのログ出力

Goでよくあるミスは、同じエラーをすべてのレイヤーでログ出力することです。

悪い例:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

これにより、1つの失敗に対して重複したログが作成されます。

より良い方法:

  • エラーが上に移動する際にラップする
  • エラーが処理されるバウンダリーで一度だけログ出力する
  • ログに構造化されたコンテキストを含める

例:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

これにより、完全なエラーチェーンを持つ1つのログイベントが得られます。本番環境向けの構造化ログ設定については、slogによるGoの構造化ログ が、log/slog レコード、JSONハンドラー、コンテキスト相関、マスクをカバーしており、これらはすべてバウンダリーレベルのエラーログ出力と自然に組み合わせられます。

下位レイヤーでログ出力すべきとき

下位レイヤーでログ出力するのは、そのレイヤーが実際にエラーを処理している場合、または他の場所で目に見えない重要な運用コンテキストを追加している場合だけです。

例えば、リトライループは各リトライ試行をデバッグまたはワーニングレベルでログ出力できます。

しかし、ハンドラーが最終的なリクエスト失敗をログ出力するならば、リポジトリはすべてのクエリエラーをログ出力すべきではありません。

ユーザー向けエラーとオペレーター向けエラー

内部エラーをユーザーに直接表示しないでください。

内部エラー:

query user by id: dial tcp 10.0.4.12:5432: connection refused

ユーザー向けメッセージ:

internal server error

オペレーターログ:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

これらは異なる対象者であり、良いエラーアーキテクチャはそれらを分離します:

  • 内部診断エラー
  • ユーザー向けの安全なレスポンス
  • 安定したAPIエラーコード
  • オペレーターログコンテキスト

1つのエラー文字列にこれらのすべての対象者にサービスを提供させることは、公開リスクかデバッグの悪夢のどちらかを生み出します。異なるコンシューマーのための異なる値围绕ってエラーアーキテクチャを設計してください。

安全なエラーハンドリング

エラーは機密情報を漏らす可能性があります。

公開を避けるべきもの:

  • データベース接続文字列
  • シークレットを含むSQLクエリ
  • 内部ホスト名
  • ファイルパス
  • アクセストークン
  • APIキー
  • スタックトレース
  • 顧客のプライベートデータ
  • 認可ポリシーの詳細

これは特にHTTP APIで重要です。

悪い例:

http.Error(w, err.Error(), http.StatusInternalServerError)

良い例:

http.Error(w, "internal server error", http.StatusInternalServerError)

内部エラーはオペレーターのために安全にログ出力してください。ユーザーには安全なメッセージを返してください。

エラーコード

パブリックAPIでは、メッセージだけに頼るよりも、安定したエラーコードの方が良いことが多いです。

レスポンス例:

{
  "code": "user_not_found",
  "message": "user not found"
}

メッセージは変更できます。コードは安定させるべきです。

エラーコードを使用すべきもの:

  • クライアントの動作
  • ドキュメント
  • SDK
  • ローカライズ
  • サポート診断

クライアントに英語のエラーメッセージを解析させないでください。

実用的なレイヤードエラー設計

多くのGoバックエンドサービスのためのクリーンなパターンです。

リポジトリレイヤー

  • データベースや外部ストレージと対話する。
  • ストレージ固有の「見つからない」エラーをドメインエラーに変換する。
  • 予期しないストレージエラーに操作の文脈をラップする。
  • HTTPエラーを返さない。
  • 通常はログ出力しない。

例:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

サービスレイヤー

  • ビジネスルールを所有する。
  • ドメインエラーを作成する。
  • 既知のドメインエラーを保持する。
  • 予期しない低レベルのエラーをラップする。
  • HTTPステータスコードを返さない。
  • 通常はログ出力しない。

例:

if user.Disabled {
	return nil, ErrAccountDisabled
}

トランスポートレイヤー

  • ドメインエラーをHTTP、gRPC、またはCLIレスポンスにマッピングする。
  • 未処理または予期しないエラーをログ出力する。
  • 内部詳細をユーザーから隠す。
  • ステータスコードとAPIエラーコードを設定する。

例:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

この分離により、エラーハンドリングが理解しやすくなり、各レイヤーが独立して進化できます——サービスロジックやトランスポートマッピングに触れずにストレージ技術を変更できます。レイヤード設計は、依存関係がハードコードされるのではなく注入されるときに最もよく機能します;Goにおける依存性注入: パターンとベストプラクティス が、各バウンダリーを隔離してテストしやすくするコンストラクターとインターフェースのパターンをカバーしています。

完全な例

ここでは、小さなエンドツーエンドの例を示します。

ドメインエラー:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

リポジトリ:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

サービス:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

HTTPハンドラー:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

この構造は以下を与えます:

  • ドメインエラー
  • ストレージ変換
  • サービスの文脈
  • 安全なHTTPマッピング
  • 検査可能なエラーチェーン
  • 文字列マッチングなし
  • ドメインコードへのトランスポート漏れなし

これはスケールするタイプのエラーアーキテクチャです——新しいコントリビューターが理解するには十分に直観的であり、ドメインロジックがトランスポートレスポンスに漏れないように構造化されています。

エラー動作のテスト

エラー動作は、ハッピーパスと同じくらい徹底的にテストすべきです。なぜなら、バウンダリーでの決定——センチネルマッピング、型抽出、HTTPコード——がしばしば最も長くバグを隠す場所だからです。Goのテスト構造、モック、カバレッジパターンに関する完全なガイドについては、Goユニットテスト: 構造とベストプラクティス を参照してください。

センチネルマッピングのテスト

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

カスタムエラー抽出のテスト

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

HTTPマッピングのテスト

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

テストは、既知のエラーが各バウンダリーで正しい動作を生み出すことを証明すべきであり、そうすることでストレージやトランスポートレイヤーのリファクタリングが失敗契約を静かに変更することを防ぐことができます。

共通のアンチパターン

アンチパターン1: 文字列マッチング

悪い例:

if strings.Contains(err.Error(), "not found") {
	// ...
}

代わりに errors.Is または errors.As を使用してください——両方ともラップされたエラーチェーンを自動的に処理し、メッセージがリフォーマットされたりローカライズされたりしても壊れません。

アンチパターン2: 原因の喪失

悪い例:

return errors.New("query failed")

より良い例:

return fmt.Errorf("query user: %w", err)

アンチパターン3: 意味のないラップ

悪い例:

return fmt.Errorf("error happened: %w", err)

診断価値を全く加えない曖昧なプレフィックスではなく、何が試行されたかを説明する操作コンテキストでラップしてください。例えば "create invoice %s: %w" など。

アンチパターン4: 各レイヤーでのログ出力

悪い例:

log.Println(err)
return err

をすべてのレベルで行うこと。エラーが最終的に処理される場所で一度だけログ出力し、単にそれを上位へ渡すだけの中間レイヤーのすべてでログ出力しないでください。

アンチパターン5: ドメインコードからのHTTPエラー返却

悪い例:

return &APIError{Status: http.StatusNotFound}

をドメインサービスから返すこと。ドメインエラーをHTTPステータスコードとレスポンスボディにマッピングするのはハンドラーバウンダリーで行い、サービスレイヤーをトランスポート関心から独立させます。

アンチパターン6: ユーザーへの内部エラー公開

悪い例:

http.Error(w, err.Error(), http.StatusInternalServerError)

ユーザーには安全な一般的なメッセージを返し、オペレーターのために構造化されたコンテキスト付きの完全な内部エラーをログ出力してください。APIレスポンスにデータベース接続文字列、ファイルパス、または生のスタックトレースを公開しないでください。

アンチパターン7: 過剰なエクスポートされたセンチネル

エクスポートされたエラーはあなたのパッケージAPIの一部であり、それらを追加することはそれらのメンテナンスを約束するものです。外部の呼び出し元が実際にそれに基づいて分岐する必要がある場合に限り、すべての内部条件をエクスポートしないでください——明確な必要性があるまでセンチネルを非公開にしておくことを優先してください。

アンチパターン8: 予期された失敗へのパニック使用

悪い例:

panic(err)

を通常のランタイム失敗に使用すること。パニックは、本当に回復不能な条件やプログラマーエラーに限定し、記録の欠如や無効なユーザー入力には使用しないでください——それらの場合は常にエラーを返してください。

アンチパターン9: コンテキストエラーの無視

悪い例:

return fmt.Errorf("request failed")

が、実際の原因が context.Canceled である場合。コンテキストエラーを保持し、呼び出し元が真の操作の失敗とキャンセルまたはタイムアウトしたリクエストを区別できるようにし、それぞれに適応して応答してください。サービスレイヤー間でのコンテキストキャンセルとタイムアウト伝播の動作の詳細な解説については、Go context.Context 正解 を参照してください。

エラーレビューチェックリスト

コードレビューでこのチェックリストを使用してください。

エラー作成

  • これは既知の条件か?
  • センチネルであるべきか?
  • 構造化データが必要か?
  • カスタム型であるべきか?
  • エラーメッセージは明確か?

エラーラップ

  • ラップは有用な操作コンテキストを追加しているか?
  • %w は必要な場所で原因を保持しているか?
  • コードは誤って実装詳細を公開していないか?
  • チェーンはノイジーすぎないか?

エラー変換

  • 低レベルのエラーが正しいバウンダリーで変換されているか?
  • データベース固有の動作がサービスコードから隠されているか?
  • ドメインエラーはHTTPやCLIの関心から独立しているか?

エラーハンドリング

  • 呼び出し元は errors.Is または errors.As で分岐しているか?
  • コンテキストのキャンセルとデッドラインが正しく処理されているか?
  • リトライ可能なエラーが明示的に識別されているか?
  • 検証エラーが構造化されているか?

ログ出力

  • エラーは処理バウンダリーで一度だけログ出力されているか?
  • ログは構造化されているか?
  • ユーザーレスポンスから機密詳細が除外されているか?
  • オペレーターのために十分なコンテキストがあるか?

テスト

  • 既知のエラーケースがテストされているか?
  • HTTPまたはCLIマッピングがテストされているか?
  • 検証の詳細がテストされているか?
  • リトライ決定がテストされているか?

私の主観的なルール

ルール1: エラーは意味を持ってバウンダリーを跨ぐべき

単にエラーを周回させるだけにしてはいけません。各レイヤーでそれが何を意味するかを決定してください。

ルール2: 装飾ではなく文脈のためにラップする

ラップが失敗した操作について有用な情報を追加しない場合、ラップしないでください。意味のない追加の文脈レイヤーは、エラーチェーンを読みにくくし、診断価値を加えません。

ルール3: 実装エラーをドメインエラーに変換する

sql.ErrNoRows がビジネスロジックの一部になることを許可しないでください。ストレージバウンダリーで実装エラーをドメインエラーに変換し、アプリケーションの残りがどのデータベースやORMが下にあるかを知ることなく済むようにしてください。

ルール4: エラー文字列を解析しない

コードが失敗の型に基づいて分岐する必要がある場合、センチネル、カスタム型、errors.Is、または errors.As を使用してください。文字列検査は、エラーメッセージが変わると静かに壊れる見えない結合を生み出します。

ルール5: 一度だけログ出力する

エラーが上に移動する際にラップします。エラーが最終的に処理される場所でログ出力します。

ルール6: ユーザーメッセージを安全に保つ

内部診断エラーはログのためにあります。ユーザー向けメッセージはユーザーのためにあります。

ルール7: トランスポートエラーをトランスポートバウンダリーに保つ

HTTPステータスコードはドメインサービスではなく、ハンドラーやAPIアダプターに属します。ドメインコードはトランスポート間で再利用可能であるべきです——今日はHTTP、明日はCLI、gRPC、またはイベント駆動のワーカー。

最終的な思考

Goのエラーハンドリングは、永遠に if err != nil を書くことについてではありません——それは、すべてのバウンダリーで失敗を明示的かつ理解可能にすることです。

メカニクスは単純です:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

アーキテクチャの方が難しい部分です:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

それがGoのエラーハンドリングの良し悪しです——賢くもなければ魔法でもありませんが、次の開発者、オペレーター、APIクライアント、そして未来のあなたが、何が失敗し、次に何がすべきかを理解するのに十分明確です。統合、テスト、データアクセスにわたる本番Goパターンのより広いビューについては、本番アプリアーキテクチャ を参照してください。

情報源

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。