Goのエラーハンドリングアーキテクチャ:境界とパターン
適切な境界でエラーを処理する
Goのエラー処理は、文句を言うのが簡単です。 すべてのGo開発者は、以下のコードを何百回も書いていることでしょう:
if err != nil {
return err
}
しかし、それ自体が面白いわけではありません。面白いのは、そのエラーが何を意味し、どこで処理すべきか、どこでラップすべきか、どこで翻訳すべきか、どこでログに記録すべきか、そして呼び出し元に何を公開すべきかという点です。これがアーキテクチャの問題です。
Goはエラーを値として扱います。これにより、失敗が明確になります。また、コードベースには明確なエラー処理設計が必要であることを意味します。設計がない場合、エラーはランダムな文字列となり、HTTPハンドラはデータベースの詳細を漏洩し、ログは同じ失敗を5回重複して記録し、リトライは間違った理由で発生し、呼び出し元は動作ではなくテキストを解析することになります。

この記事は、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
%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が失敗した場合、呼び出し元は以下の両方を受け取ります:
- 高レベルの操作:設定の読み込み
- 低レベルの原因:アクセス拒否、ファイルが見つからないなど。
これらは両方とも、%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固有のエラーを返すことを強制しないでください。
ドメインエラー vs トランスポートエラー
ドメインエラーをトランスポートエラーから分離してください。
ドメインエラー:
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
}
これは、エラー文字列に「タイムアウト」が含まれているかどうかを確認するよりもはるかに優れています。文字列マッチングは、メッセージが変更されると静かに壊れ、プロデューサーとコンシューマー間の目に見えない結合を生み出します。
検証エラー
検証エラーは多くの場合、構造化されたデータが必要です。
例:
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 slogで、log/slogレコード、JSONハンドラ、文脈相関、および改竄についてカバーしています。これらはすべて、境界レベルのエラーログと自然にペアになります。
下位レイヤー内でログを記録するタイミング
レイヤーが実際にエラーを処理するか、他では表示されない重要な運用文脈を追加する場合にのみ、下位レイヤー内でログを記録します。
例えば、リトライループはデバッグまたはウォーニングレベルで各リトライ試行をログに記録する場合があります。
しかし、ハンドラが最終的なリクエスト失敗をログに記録する場合、リポジトリはすべてのクエリエラーをログに記録すべきではありません。
ユーザー向けエラー vs 運用者向けエラー
内部エラーを直接ユーザーに表示しないでください。
内部エラー:
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であった場合。コンテキストエラーを保持し、呼び出し元が真の操作失敗とキャンセルまたはタイムアウトしたリクエストを区別し、それぞれに適切に対応できるようにします。サービスレイヤー全体でのコンテキストキャンセルとタイムアウト伝播の仕組みの詳細な解説については、Go context.Context Done Rightをご覧ください。
エラーレビューチェックリスト
コードレビューでこのチェックリストを使用してください。
エラー作成
- これは既知の条件ですか?
- センチネルにするべきですか?
- 構造化されたデータが必要ですか?
- カスタムタイプにするべきですか?
- エラーメッセージは明確ですか?
エラーラッピング
- ラップは有用な操作文脈を追加していますか?
%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をご覧ください。