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 で検査できます。ラッピングには Go 1.27 で新しい罠が 1 つあります:go test 裏の vet チェックは、p がそれ自体で error を実装する型のポインタである場合に fmt.Errorf("...: %w", p) について警告を出します。なぜなら、余分な間接参照が型アサーションや errors.Is のマッチングを壊すからです。Go 1.27 アップグレードガイド は、これと、ツールチェーンのバージョンアップ後に表面化するその他の vet 検出事項をカバーしています。

役割 3: 境界で決定させる

ある境界で、プログラムはどのようにする必要があるかを決定しなければなりません。

例:

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

その決定は通常、エラーの ID または型に基づいて行われるべきで、文字列マッチングではありません。

現代 Go の主なエラーツール

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

errors.New

errors.New を使用して、単純なエラー値を作成します:

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

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

fmt.Errorf と %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」という単語は、エラーが存在することによって通常暗黙に示されます。

翻訳(変換)している場合はラップしない

時には、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 レスポンスに変換します。

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

Web フレームワークは、この変換をどのように構築するかで異なります。Echo はハンドラのエラーを単一の HTTPErrorHandler 経由でルーティングし、Gin ハンドラはリクエストコンテキスト上で直接エラーをレンダリングします — 2 つのスタイル間のトレードオフは Go Web Frameworks in 2026: net/http, chi, Gin, Echo, Fiber Compared で比較されています。

例:

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))
	}
}

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

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 を使用しないでください。

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

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

結合エラーは有用ですが、すぐにノイジーになる可能性があります。

panic はエラー処理ではない

通常的应用コードで、予期されたエラーのために panic を使用しないでください。

悪い例:

if err != nil {
	panic(err)
}

プログラムエラーや本当に回復不能な状況のために panic を使用してください。

例:

  • 不可能な内部不変条件の違反
  • 無効なパッケージ初期化
  • t.Fatal または限定されたケースでの panic を伴うテストヘルパーの失敗
  • スタイルによりますが、回復不能な起動設定エラー

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

それらは通常のエラーです。

エラーのログ出力

一般的な 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 slog は、log/slog レコード、JSON ハンドラ、コンテキスト相関、マスキングをカバーし、これらはすべて境界レベルのエラーログ出力と自然にペアになります。

下位レイヤーでログ出力する場合

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

例えば、リトライループはデバッグまたは warning レベルで各リトライ試行をログ出力する場合があります。

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

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

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

内部エラー:

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 バックエンドサービスのためのクリーンなパターンを紹介します。

リポジトリレイヤー

  • データベースまたは外部ストレージと対話する。
  • ストレージ固有の not found エラーをドメインエラーに変換する。
  • 操作コンテキストで予期しないストレージエラーをラップする。
  • 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 の使用

悪い例:

panic(err)

通常のランタイム失敗に対して。panic は本当に回復不能な条件またはプログラムエラーのためにだけ予約し、レコードの欠如や無効なユーザー入力には使用しないでください — それらの場合は常にエラーを返してください。

アンチパターン 9: context エラーの無視

悪い例:

return fmt.Errorf("request failed")

本当の原因が context.Canceled だった場合に。context エラーを保持して、呼び出し側が真の操作失敗とキャンセルまたはタイムアウトのリクエストを区別できるようにし、それぞれに適切に対応できるようにしてください。service レイヤー全体での context キャンセルとタイムアウト伝播の動作の詳細な解説については、Go context.Context Done Right を参照してください。

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

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

エラー作成

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

エラーラップ

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

エラー翻訳

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

エラー処理

  • 呼び出し側は errors.Is または errors.As で分岐していますか?
  • context キャンセルとデッドラインは正しく処理されていますか?
  • リトライ可能なエラーは明示的に識別されていますか?
  • バリデーションエラーは構造化されていますか?

ログ出力

  • エラーは処理境界で 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エンジニアリングの新記事をお届けします。