📚 背景知識(読んでから問題へ)
Day 018ではerrors.Is(センチネルエラーの判定)とerrors.As(カスタムエラー型の判定)という「使い分けの道具」を学びました。今日はその一歩先、「そもそも、あるエラーをセンチネルエラーとして定義すべきか、カスタムエラー型として定義すべきか」という設計判断そのものを扱います。
この判断は実務で頻繁に発生します。新しいエラーケースを追加するたびに、次のどちらかを選ぶ必要があります。
- センチネルエラー(
var ErrXxx = errors.New("...")): パッケージレベルの単一の値。「このエラーかどうか」の二値判定にしか使えないが、シンプルで軽量 - カスタムエラー型(
type XxxError struct { ... }): 独自のフィールドを持つ構造体。「どのフィールドが」「どんな理由で」失敗したかという追加情報を持ち運べるが、定義・利用のコストが高い
判断基準は主に3つです。
- 呼び出し元が追加情報を必要とするか — 「失敗した」という事実だけで十分ならセンチネル、「どのフィールドが」「いくつ超過したか」などの情報が必要ならカスタム型
- エラーの種類が今後増減する可能性があるか — 頻繁に種類が増えるならカスタム型に
Codeのようなフィールドを持たせて拡張しやすくする、逼迫していないなら都度センチネルを足す方が単純 - 公開APIの後方互換性コスト — センチネルエラーは
varの追加だけで済むが、カスタム型は構造体のフィールド変更が破壊的変更になりやすい(後方互換のためにフィールドの追加はできてもリネーム・削除はできない)
Goの標準ライブラリ自身もこの2つを併用しています。たとえばioパッケージはio.EOFという値だけのセンチネルエラーを使う一方、strconvパッケージは*strconv.NumErrorという「どの関数の・どの入力値で・どんなエラーが起きたか」を持つカスタム型を使っています。この違いは偶然ではなく、「io.EOFはただ『終端に達した』という事実だけで十分」「strconvのエラーは元の入力文字列を見せないとデバッグしづらい」という要件の違いを反映した設計判断です。
「とりあえずカスタム型にしておけば安全」という判断は過剰設計(YAGNI違反)になりがちです。逆に「とりあえずセンチネルで済ませる」を続けると、後から「このエラーの原因になった値が知りたい」という要求が来たときに、公開APIを壊さずに情報を追加する方法がなくなり詰みます。この見極めが今日のテーマです。
📝 問題
あなたは決済代行システムのSDK(他のチームが利用する内部ライブラリ)のpaymentパッケージを設計しています。以下の4つのエラーケースについて、それぞれ「センチネルエラー」と「カスタムエラー型」のどちらで設計すべきかを判断し、理由を示した上で、実際に動くpaymentパッケージと、それを利用するmainパッケージのコードを実装してください。
エラーケース
- 残高不足: 決済しようとした金額が口座残高を超えている。呼び出し元は「不足額」と「現在の残高」をログ・ユーザー表示のために取得したい
- 決済処理の一時停止: メンテナンス等でシステム全体が決済を受け付けていない状態。呼び出し元は「このエラーかどうか」だけを判定し、リトライキューに積む
- 不正なカード番号形式: 呼び出し元は「どのフィールド(
CardNumber/ExpiryDate/CVV)が」「どんな理由で」不正だったかを取得し、フォームのエラー表示に使いたい - 限度額超過: 1回の決済における上限金額(
MaxAmount)を超えた。呼び出し元は「超過額」ではなく「限度額そのもの」をエラーメッセージに含めて表示したいが、リトライ判定には使わない
実装要件
- 上記4ケースそれぞれについて、センチネルエラー/カスタムエラー型のどちらを採用するかを設計判断として(コード内のコメントで)明記し、各ケースごとに1〜2文で理由を書くこと
paymentパッケージにCharge(accountBalance int, amount int, card Card) errorという関数を実装し、上記4ケースを判定順に検査して、該当するエラーを返すこと(Card構造体はCardNumber string,ExpiryDate string,CVV stringを持つ)- カード番号は「16桁の数字であること」、有効期限は
"MM/YY"形式であること、CVVは「3桁の数字であること」を検証すること main関数で以下をすべて実演すること- 残高不足ケースを発生させ、
errors.Asで追加情報(不足額・残高)を取り出して表示する - 決済停止ケースを発生させ、
errors.Isでリトライキューに積むかどうかを判定する - 不正なカード番号ケースを発生させ、
errors.Asでフィールド名と理由を取り出す - 限度額超過ケースを発生させ、エラーメッセージに限度額が含まれることを確認する
- 残高不足ケースを発生させ、
- すべてのエラーは
Chargeの呼び出し元まで正しく伝わり、判定できる状態で返ること(ラップが必要な場合は%wを使うこと)
🔍 ヒント(段階的開示)
ヒント1 — 方向性
4つのケースを「呼び出し元が追加情報を必要とするか」という1つの軸だけでまず仮に分類してみてください。「情報が要る=カスタム型」「情報が要らない=センチネル」が基本線ですが、1つだけこの基本線に反する(ように見えて実は反していない)ケースが混ざっています。ケース4を注意深く読み、「エラーの中に情報を含める」ことと「その情報を動的な値として持ち運ぶ」ことは必ずしもイコールではない、という点を考えてみてください。
ヒント2 — アプローチ
- ケース1(残高不足)とケース3(不正なカード番号)は、呼び出し元がケースごとに異なる動的な値(不足額・残高/フィールド名・理由)を取り出す必要があるため、フィールドを持つカスタムエラー型が適切です
- ケース2(決済停止)は、値を伴わない「はい/いいえ」の状態フラグなので、
var ErrPaymentSuspended = errors.New(...)という1つのセンチネルエラーで十分です - ケース4(限度額超過)が引っかけです。「メッセージに限度額を含めたい」という要件だけなら、
MaxAmountというその時点で固定の定数をfmt.Errorfでメッセージに焼き込んだセンチネル的なエラーで足ります。呼び出し元は「動的に変わる値」を取り出す必要がなく、リトライ判定にも使わないため、フィールド付きのカスタム型を作るのは過剰設計です - カスタム型を作る場合は、
errors.Asで判定できるようError() stringメソッドを実装し、エラーの型は基本的にポインタ型(*InsufficientBalanceError)で統一してください
ヒント3 — コード骨格
package payment
import (
"errors"
"fmt"
"regexp"
)
// ケース2: 状態フラグのみ → センチネルエラー
var ErrPaymentSuspended = errors.New("payment system is currently suspended")
const MaxAmount = 100000 // ケース4: 固定の上限値。呼び出し元も参照できるよう公開定数にする
// ケース1: 動的な追加情報(不足額・残高)が必要 → カスタムエラー型
type InsufficientBalanceError struct {
Amount int
Balance int
}
func (e *InsufficientBalanceError) Error() string {
// TODO: 不足額 = Amount - Balance を含むメッセージを返す
}
// ケース3: 動的な追加情報(フィールド名・理由)が必要 → カスタムエラー型
type InvalidCardError struct {
Field string
Reason string
}
func (e *InvalidCardError) Error() string {
// TODO
}
type Card struct {
CardNumber string
ExpiryDate string
CVV string
}
var (
cardNumberRe = regexp.MustCompile(`^\d{16}$`)
expiryDateRe = regexp.MustCompile(`^(0[1-9]|1[0-2])/\d{2}$`)
cvvRe = regexp.MustCompile(`^\d{3}$`)
)
func Charge(accountBalance int, amount int, card Card) error {
// TODO: 判定順序 → 決済停止 → カード検証(番号・期限・CVV) → 限度額 → 残高
// TODO: 各ケースで対応するエラーを返す(センチネルはそのまま or %wでラップ、カスタム型は &Xxx{...})
return nil
}
✅ 模範解答
ファイル: payment/payment.go
package payment
import (
"errors"
"fmt"
"regexp"
)
// ── 設計判断 ──────────────────────────────────────────
// ケース1(残高不足) : カスタムエラー型。呼び出し元が Amount/Balance という
// 動的な値をログ・UI表示のために取り出す必要があるため。
// ケース2(決済停止) : センチネルエラー。「停止中かどうか」という状態フラグの
// 二値判定のみで、追加情報が一切不要なため。
// ケース3(カード形式) : カスタムエラー型。Field/Reasonという動的な値をフォーム
// のエラー表示に使う必要があるため。
// ケース4(限度額超過) : センチネル的な扱い(固定値のfmt.Errorf + 公開定数)。
// MaxAmountは呼び出し元にとって「常に同じ値」であり、
// リトライ判定にも使わないため、フィールド付きのカスタム
// 型を作るのは過剰設計(YAGNI違反)。公開定数として
// MaxAmountを公開しておけば、呼び出し元はメッセージを
// パースせずとも限度額そのものを参照できる。
// ─────────────────────────────────────────────────────
// ErrPaymentSuspended はシステム全体が決済停止中であることを示すセンチネルエラー。
var ErrPaymentSuspended = errors.New("payment system is currently suspended")
// MaxAmount は1回の決済における上限金額。呼び出し元からも参照できるよう公開する。
const MaxAmount = 100000
// InsufficientBalanceError は残高不足を表すカスタムエラー型。
// 呼び出し元は不足額・現在の残高という動的な値を必要とするため型で持ち運ぶ。
type InsufficientBalanceError struct {
Amount int // 決済しようとした金額
Balance int // 現在の残高
}
func (e *InsufficientBalanceError) Error() string {
return fmt.Sprintf("insufficient balance: amount=%d balance=%d shortfall=%d",
e.Amount, e.Balance, e.Amount-e.Balance)
}
// InvalidCardError はカード情報の形式不正を表すカスタムエラー型。
// 呼び出し元はどのフィールドが・なぜ不正だったかを取り出してフォームに反映する。
type InvalidCardError struct {
Field string
Reason string
}
func (e *InvalidCardError) Error() string {
return fmt.Sprintf("invalid card field %q: %s", e.Field, e.Reason)
}
type Card struct {
CardNumber string
ExpiryDate string
CVV string
}
var (
cardNumberRe = regexp.MustCompile(`^\d{16}$`)
expiryDateRe = regexp.MustCompile(`^(0[1-9]|1[0-2])/\d{2}$`)
cvvRe = regexp.MustCompile(`^\d{3}$`)
)
// validateCard はカードの形式を検証し、最初に見つかった不正だけを返す。
func validateCard(card Card) error {
if !cardNumberRe.MatchString(card.CardNumber) {
return &InvalidCardError{Field: "CardNumber", Reason: "must be 16 digits"}
}
if !expiryDateRe.MatchString(card.ExpiryDate) {
return &InvalidCardError{Field: "ExpiryDate", Reason: "must be in MM/YY format"}
}
if !cvvRe.MatchString(card.CVV) {
return &InvalidCardError{Field: "CVV", Reason: "must be 3 digits"}
}
return nil
}
// Charge は決済を実行する。判定順序は「システム状態 → カード形式 → 限度額 → 残高」。
// 早い段階の異常ほど後段のチェックより先に検出したいため、この順序にしている。
func Charge(accountBalance int, amount int, card Card) error {
if suspended() {
return fmt.Errorf("payment.Charge: %w", ErrPaymentSuspended)
}
if err := validateCard(card); err != nil {
return fmt.Errorf("payment.Charge: %w", err)
}
if amount > MaxAmount {
return fmt.Errorf("payment.Charge: amount %d exceeds max limit of %d", amount, MaxAmount)
}
if amount > accountBalance {
return fmt.Errorf("payment.Charge: %w", &InsufficientBalanceError{
Amount: amount,
Balance: accountBalance,
})
}
return nil
}
// suspended はシステムが決済停止中かどうかを返す(デモ用に固定値)。
func suspended() bool {
return false
}
ファイル: main.go(同一モジュール内でexample.com/paymentとしてimport)
package main
import (
"errors"
"fmt"
"example.com/payment"
)
func main() {
validCard := payment.Card{CardNumber: "4242424242424242", ExpiryDate: "12/28", CVV: "123"}
// ケース1: 残高不足 → errors.As で Amount/Balance を取り出す
err := payment.Charge(1000, 5000, validCard)
fmt.Println("ケース1 エラー全文:", err)
var balErr *payment.InsufficientBalanceError
if errors.As(err, &balErr) {
fmt.Printf(" → 不足額=%d 現在残高=%d\n", balErr.Amount-balErr.Balance, balErr.Balance)
}
// ケース2: 決済停止 → errors.Is でリトライ判定
// (suspended() は固定でfalseを返す実装のため、ここでは判定ロジックの実演のみ行う)
suspendedErr := fmt.Errorf("payment.Charge: %w", payment.ErrPaymentSuspended)
fmt.Println("ケース2 エラー全文:", suspendedErr)
if errors.Is(suspendedErr, payment.ErrPaymentSuspended) {
fmt.Println(" → リトライキューに積む")
}
// ケース3: 不正なカード番号 → errors.As で Field/Reason を取り出す
invalidCard := payment.Card{CardNumber: "1234", ExpiryDate: "12/28", CVV: "123"}
err = payment.Charge(10000, 5000, invalidCard)
fmt.Println("ケース3 エラー全文:", err)
var cardErr *payment.InvalidCardError
if errors.As(err, &cardErr) {
fmt.Printf(" → フィールド=%s 理由=%s\n", cardErr.Field, cardErr.Reason)
}
// ケース4: 限度額超過 → メッセージに MaxAmount が含まれることを確認
err = payment.Charge(1000000, 200000, validCard)
fmt.Println("ケース4 エラー全文:", err)
fmt.Printf(" → 公開定数からも参照可能: payment.MaxAmount=%d\n", payment.MaxAmount)
}
▶ 実行結果を見る(go run . で検証済み)
ケース1 エラー全文: payment.Charge: insufficient balance: amount=5000 balance=1000 shortfall=4000
→ 不足額=4000 現在残高=1000
ケース2 エラー全文: payment.Charge: payment system is currently suspended
→ リトライキューに積む
ケース3 エラー全文: payment.Charge: invalid card field "CardNumber": must be 16 digits
→ フィールド=CardNumber 理由=must be 16 digits
ケース4 エラー全文: payment.Charge: amount 200000 exceeds max limit of 100000
→ 公開定数からも参照可能: payment.MaxAmount=100000
※ paymentパッケージとmain.goを1つのモジュール(go.modでmodule example.com/xxxと宣言し、payment/payment.goを配置)として実行しています。単一ファイルで試す場合は、両ファイルの内容をpackage mainにまとめ、payment.プレフィックスを除去してください。
🪜 Step-by-Step 解説
「動的な追加情報が要るか」「その情報が固定値か可変値か」の2軸で考えると、ケース1・3は可変値が必要(カスタム型)、ケース2は情報自体が不要(センチネル)、ケース4は情報は要るが固定値(センチネル的にfmt.Errorfへ焼き込む、または公開定数で解決)という4通りに自然に分かれます。この分類を先にコメントとして書き出しておくことで、実装中に「とりあえずカスタム型にしておこう」という安易な判断を防げます。
var ErrPaymentSuspended = errors.New("payment system is currently suspended")
const MaxAmount = 100000
type InsufficientBalanceError struct { Amount, Balance int }
type InvalidCardError struct { Field, Reason string }
MaxAmountをconstとして公開している点がポイントです。カスタム型を作らずとも、呼び出し元はpayment.MaxAmountを直接参照でき、エラーメッセージの文字列をパースする必要がありません。「エラー型を増やす」以外にも「関連する値を公開する」という選択肢があることを覚えておくと、過剰設計を避けやすくなります。
validateCardは複数の検証項目のうち「最初に見つかった不正」だけを返す設計にしています。全項目のエラーをまとめて返す設計(errors.Joinの活用)も可能ですが、今回はフォームの1フィールドずつのインライン検証を想定し、シンプルさを優先しました。設計判断には常に「今回の要件にとって十分な複雑さはどこまでか」という基準が伴います。
%wでラップしてChargeから返すreturn fmt.Errorf("payment.Charge: %w", &InsufficientBalanceError{...})
Chargeという1つの関数から複数種類のエラーが返り得るため、どのエラーであっても「payment.Chargeで起きた」という文脈を一律で追加しています。これにより、呼び出し元はerrがnilでなければまずpayment.Charge起因だと分かり、その後errors.Is/errors.Asで種類を判定する、という2段階の情報取得ができます。
errors.Is/errors.Asを使い分けるif errors.As(err, &balErr) { ... } // 値を取り出したい → As
if errors.Is(err, payment.ErrPaymentSuspended) { ... } // はい/いいえだけでよい → Is
呼び出し元のコードを見ると、センチネルエラーは「分岐条件」として、カスタムエラー型は「データの取り出し元」として使われていることが分かります。この役割の違いこそが設計判断の本質です。
💡 設計思想・なぜこう書くのか
Error()メソッド・(必要なら)Unwrap()メソッドを一式追加するということであり、決して無料ではありません。だからこそGoのエコシステムでは「本当に追加情報が必要になった瞬間にだけ型を作る」という判断が重視され、これはYAGNI原則そのものの実践です。fmt.Errorfで焼き込むか、公開定数として別途提供すれば十分なケースが多く、わざわざ構造体を作る必要はありません。この区別ができないと「情報を含むエラーは全部カスタム型にする」という過剰なルールに陥りがちです。🌐 他言語との比較
| 観点 | Go | Java | Python | TypeScript |
|---|---|---|---|---|
| 「値だけで十分なエラー」の表現 | パッケージレベルのvar Err... = errors.New(...) | 専用の例外クラスを作ることが多い(軽量な代替が言語標準にない) | モジュールレベルの例外クラス、またはシンプルにValueErrorを使い回す | Errorのサブクラス、またはリテラル型のUnion |
| 「追加情報を持つエラー」の表現 | フィールドを持つ構造体 + Error()メソッド | 例外クラスにフィールドとgetterを追加 | 例外クラスの__init__に属性を追加 | Errorを継承したクラスにプロパティを追加 |
| 型を増やすコスト感 | 構造体1つ + メソッド1つで済み比較的軽い | クラス階層の設計(継承・チェック例外か非チェック例外か)まで考慮が必要で重め | 動的型付けのため型を増やすコストは低いが、型チェッカーの恩恵は薄い | インターフェース/クラスの追加は軽いが、instanceof判定や型ガードの記述が伴う |
| 「情報を持つが型は増やしたくない」場合の代替 | 定数の公開 + fmt.Errorfでのメッセージ埋め込み | 例外メッセージに文字列で埋め込むことが多い(構造化は諦めがち) | f-stringでメッセージに埋め込む | テンプレートリテラルでメッセージに埋め込む |
Goで際立つのは、「型を作るコスト」が言語機能として明示的に小さく保たれている(継承階層やチェック例外の設計を考える必要がない)反面、その分「本当に型を作るべきか」という判断を毎回エンジニアが意識的に下す必要がある、という点です。他言語では「とりあえず例外クラスを作る」文化が強い場面でも、Goでは「センチネルで足りないか」を先に検討する習慣が根付いています。
🏆 実務での使いどころ
- 公開SDK/ライブラリの設計: 外部チームや社外の利用者が使うライブラリでは、エラー型を安易に増やすと後方互換性の負債になるため、今日学んだ判断基準に基づいて「本当にカスタム型が必要か」を設計レビューの観点に組み込む
- バリデーションエラーの構造化API化:
InvalidCardErrorのように、フィールド名・理由を持つカスタム型は、HTTPハンドラで400 Bad RequestのJSONレスポンス({"field": "...", "reason": "..."})に直接変換できる形で設計しておくと、ハンドラ層のコードが薄くなる - リトライ制御の設計:
ErrPaymentSuspendedのような状態系のセンチネルエラーは、リトライキュー・サーキットブレーカーの判定ロジックに直結する。「このエラー『だけ』はリトライしてよい」という許可リストの設計にそのまま使われる - 限度額・設定値の公開:
MaxAmountのように、呼び出し元が事前にバリデーションできる値は、エラーが起きてから知るのではなく公開定数・公開関数(payment.MaxAmount)として先出しし、エラー自体をシンプルに保つ設計は多くのAPI/SDKで採用されている
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| すべてのエラーをカスタム型にしてしまう(過剰設計) | 「情報を持たせられる方が安全」という直感 | 追加情報が不要、または固定値で足りるならセンチネルや定数で十分。型を増やすたびに保守コストとAPIの複雑さが増す |
| すべてをセンチネルで済ませようとする | センチネルの定義が1行で書けるため楽 | 「どのフィールドが」「いくつ超過したか」のような動的な値を呼び出し元が必要とする場合、センチネルでは情報を運べず、文字列パースという不安定な手段に頼ることになる |
| カスタム型のフィールドを後から気軽にリネーム・削除する | 公開APIという意識が薄い | エクスポートされた構造体のフィールドはAPIの一部。リネーム・削除は破壊的変更になるため、公開前の設計段階で慎重に決める必要がある |
判定ロジック(errors.Is/errors.As)を書く前に、そもそもどちらの設計が要件に合うか検討しない | 実装を先に書き始めてしまう習慣 | エラー型の設計は「呼び出し元が何を必要とするか」から逆算するべきで、実装の都合(書きやすさ)を優先すると後から使いにくいAPIになりがち |
🚀 次のステップ
- 発展:
InvalidCardErrorをerrors.Joinを使って「全フィールドの不正をまとめて返す」設計に拡張し、その場合に呼び出し元のerrors.Asの挙動がどう変わるか(複数該当時にどの値が取れるか)を確認してみましょう - 次回予告: Day 020 — インターフェース設計の原則(小さいインターフェース)(概念理解→設計)。「Accept interfaces, return structs」というGoの格言を、今日のエラー型設計と同じ「本当に必要な最小限は何か」という視点で掘り下げます