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

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

目次

Goのエラー処理は文句を言うのが簡単です。 すべてのGo開発者は、以下のコードを何百回も書いたことがあるでしょう:

if err != nil {
	return err
}

しかし、それこそが面白い部分ではありません。面白いのは、エラーが何を意味するのか、どこで処理すべきか、どこでラップすべきか、どこで翻訳すべきか、どこでログに記録すべきか、そして呼び出し元に何を表示すべきかという点です。これがアーキテクチャの問いなのです。

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

Go error handling architecture: errors flowing between layers

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

Goのエラー処理アーキテクチャに関する実用的なガイドです:ラッピング、センチネル、カスタムエラータイプ、errors.Iserrors.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

%w付きのfmt.Errorfを使用して、エラーをラップします:

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が失敗した場合、呼び出し元は以下を得ます:

  • 高レベルな操作:configの読み込み
  • 低レベルな原因:アクセス拒否、ファイルが見つからないなど

両方がエラーチェーンを通じて利用可能であり、それが%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)
}

「失敗した」という言葉は、通常、エラーが存在する事実によって暗示されます。

翻訳している場合はラップしないでください

時には、1つのエラーを別のドメインエラーに翻訳する必要があります。

例:

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レスポンスに翻訳します。

これは内部詳細がユーザーに安全なレスポンスになるべき境界です。

例:

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でのハンドラパターンとミドルウェアの完全な概要については、Building REST APIs in Goが、標準ライブラリ、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))
	}
}

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

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 Project Structure: Practices & Patternsが、これらのレイヤーをクリーンに分離する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つの失敗に対して重複したログを作成します。

より良い方法:

  • エラーが上へ移動するにつれてラップする
  • エラーが処理される境界で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つのログイベントが得られます。プロダクション向けの構造化ログセットアップについては、Structured Logging in Go with sloglog/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")
}

この分離はエラー処理を理解しやすくし、各レイヤーが独立して進化することを可能にします — サービスロジックまたはトランスポートマッピングに触れずにストレージ技術を変更できます。レイヤード設計は、依存関係がハードコードされるのではなく注入される場合に最も効果的です;Dependency Injection in Go: Patterns & Best Practicesが、各境界を孤立してテストしやすくするコンストラクタおよびインターフェースパターンをカバーしています。

完全な例

以下は、エンドツーエンドの小さな例です。

ドメインエラー:

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 Unit Testing: Structure & Best Practicesを参照してください。

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

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

各レベルで。エラーが最終的に処理される場所で1回だけログに記録し、単にそれを上へ渡す中間レイヤーではログに記録しないでください。

アンチパターン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であった場合。呼び出し元が真の操作失敗とキャンセルまたはタイムアウトしたリクエストを区別し、それぞれに適切に応答できるように、コンテキストレラーを保持してください。

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

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

エラー作成

  • これは既知の条件ですか?
  • それはセンチネルであるべきですか?
  • 構造化されたデータを必要としますか?
  • カスタムタイプであるべきですか?
  • エラーメッセージは明確ですか?

エラーラッピング

  • ラップは有用な操作文脈を追加しますか?
  • %wは必要な場所で原因を保持しますか?
  • コードは偶然に実装の詳細を公開していますか?
  • チェーンはノイジーすぎますか?

エラー翻訳

  • 低レベルエラーは適切な境界で翻訳されていますか?
  • データベース固有の動作はサービスコードから隠されていますか?
  • ドメインエラーはHTTPまたはCLI懸念から独立していますか?

エラー処理

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

ログ出力

  • エラーは処理境界で1回だけログに記録されていますか?
  • ログは構造化されていますか?
  • 機密詳細はユーザーレスポンスから除外されていますか?
  • 運用者にとって十分な文脈がありますか?

テスト

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

私の意見のあるルール

ルール1:エラーは意味を持って境界を横断すべきです

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

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

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

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

sql.ErrNoRowsがあなたのビジネスロジックの一部になるのを許可しないでください。ストレージ境界で実装エラーをドメインエラーに翻訳し、アプリケーションの残りがどのデータベースまたはORMが下にあるかを知る必要がないようにしてください。

ルール4:エラー文字列を解析しないでください

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

ルール5:1回だけログに記録する

エラーが上へ移動するにつれてラップします。エラーが最終的に処理される場所でログに記録します。

ルール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パターンのより広いビューについては、App Architecture in Productionを参照してください。

出典

購読する

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