・ Go / 設計

はじめに 👋
こんにちは!H×Hのセンリツ大好きエンジニアです!✌️
Go言語でアプリケーションを開発していると、必ず直面するのがエラーハンドリングの設計問題ですよね。if err != nil の嵐になったり、エラーメッセージが不親切だったり、デバッグ時に必要な情報が足りなかったり...そんな経験はありませんか?😅
この記事はおよそ6800字なので、10分で読めます!
※この記事は7割程度がAIによって執筆されています。
目次 📚
- エラーハンドリングの3つの観点
- 従来のエラーハンドリングの課題
- Failure is Your Domainアプローチ
- エラー構造体の設計
- エラーコードの定義
- 実践的な実装例
- エラー情報の取得
- HTTPレスポンスへの適用
- まとめ
エラーハンドリングの3つの観点 🎭
まずは、エラー情報を必要とする3つの異なる消費者を理解することから始めましょう!
エラー情報には、以下の3つの異なる消費者が存在します:
1. アプリケーション(Application)💻
アプリケーションコードは、機械可読なエラーコードを必要とします。例えば、ユーザーが存在しない場合はENOTFOUND、バリデーションエラーの場合はEINVALIDといった具合です。
// アプリケーションはエラーコードで判断
if ErrorCode(err) == ENOTFOUND {
// ユーザーが見つからない場合の処理
return createNewUser()
}
2. エンドユーザー(End User)👥
エンドユーザーは、人間が読める分かりやすいエラーメッセージを必要とします。技術的な詳細は不要で、「何が問題で、どうすればいいか」が分かる情報が求められます。
// ユーザーに表示するメッセージ
"ユーザー名が既に使用されています。別の名前を選択してください。"
3. オペレーター(Operator)🔧
開発者や運用担当者は、詳細なコンテキスト情報とスタックトレースを必要とします。問題の根本原因を特定し、修正するための情報が必要です。
// ログに出力される詳細情報
user.Create: user validation failed: username already exists: conflict
従来のエラーハンドリングの課題 ❌
従来のGo言語のエラーハンドリングには、いくつかの課題がありました。
課題1: 情報の不足
// 従来の方法
func GetUser(id int) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, err // どこでエラーが発生したかわからない
}
return user, nil
}
このコードの問題点:
- エラーが発生した場所(論理的な操作)が不明
- エラーの種類が判別できない
- ユーザーに表示すべきメッセージがない
課題2: エラーの種類が判別できない
// エラーの種類を判別できない
err := GetUser(123)
if err != nil {
// これは「ユーザーが見つからない」エラー?
// それとも「データベース接続エラー」?
// 判別できない!
}
Failure is Your Domainアプローチ 🌟
「エラーは、CustomerやOrderと同じくらい重要なドメインの一部である」という考え方です!
このアプローチの核心は、エラーをドメインモデルの一級市民として扱うことです。つまり、エラー型も他のビジネスロジックの型と同じように、慎重に設計する必要があるということです。
主な原則
- エラーは構造化されたデータ - 文字列だけではなく、コード、メッセージ、操作情報を含む
- エラーは階層的 - ネストされたエラーで完全なコンテキストを提供
- 複数の消費者に対応 - アプリケーション、ユーザー、オペレーターそれぞれのニーズを満たす
エラー構造体の設計 🏗️
3つの観点をすべて満たすエラー構造体を設計しましょう!
基本的なエラー構造体
// Error は、アプリケーション固有のエラーを表します。
type Error struct {
// 機械可読なエラーコード
Code string
// 人間が読めるエラーメッセージ
Message string
// 論理的な操作名(デバッグ用)
Op string
// ネストされた元のエラー
Err error
}
各フィールドの役割:
Code: アプリケーションコードが判断に使用(例:ENOTFOUND,EINVALID)Message: エンドユーザーに表示するメッセージOp: デバッグ時に操作の流れを追跡するため(例:"user.Create","db.Query")Err: 元のエラーをラップして、完全なコンテキストを保持
Error()メソッドの実装
// Error は、error インターフェースを実装します。
// オペレーター向けに、論理的なスタックトレースを生成します。
func (e *Error) Error() string {
var buf bytes.Buffer
// 現在の操作を出力
if e.Op != "" {
fmt.Fprintf(&buf, "%s: ", e.Op)
}
// ネストされたエラーがある場合
if e.Err != nil {
buf.WriteString(e.Err.Error())
} else {
// エラーメッセージまたはコードを出力
if e.Message != "" {
buf.WriteString(e.Message)
} else {
buf.WriteString(e.Code)
}
}
return buf.String()
}
このメソッドにより、以下のような階層的なエラーメッセージが生成されます:
user.Create: user.Validate: username already exists: conflict
エラーコードの定義 📋
アプリケーション全体で統一されたエラーコードを定義します!
標準的なエラーコード
// アプリケーション固有のエラーコード
const (
// ECONFLICT は、アクションを実行できない(競合)ことを示します。
// 例: ユーザー名の重複、楽観的ロックの失敗
ECONFLICT = "conflict"
// EINTERNAL は、内部エラーを示します。
// これは通常、予期しないエラーで、オペレーターによる調査が必要です。
EINTERNAL = "internal"
// EINVALID は、バリデーションエラーを示します。
// 例: 必須フィールドの欠落、形式が正しくないデータ
EINVALID = "invalid"
// ENOTFOUND は、エンティティが見つからないことを示します。
// 例: ユーザーID、商品IDが存在しない
ENOTFOUND = "not_found"
// EUNAUTHORIZED は、認証が必要なことを示します。
EUNAUTHORIZED = "unauthorized"
// EFORBIDDEN は、権限がないことを示します。
EFORBIDDEN = "forbidden"
)
エラーコードとHTTPステータスコードのマッピング
// ErrorStatusCode は、エラーコードをHTTPステータスコードに変換します。
func ErrorStatusCode(code string) int {
switch code {
case ECONFLICT:
return http.StatusConflict // 409
case EINVALID:
return http.StatusBadRequest // 400
case ENOTFOUND:
return http.StatusNotFound // 404
case EUNAUTHORIZED:
return http.StatusUnauthorized // 401
case EFORBIDDEN:
return http.StatusForbidden // 403
case EINTERNAL:
return http.StatusInternalServerError // 500
default:
return http.StatusInternalServerError // 500
}
}
実践的な実装例 💻
実際のアプリケーションでどのように使用するか見ていきましょう!
ユーザー作成処理の例
完全なユーザー作成処理の実装コード(80行)
package user
import (
"database/sql"
"fmt"
)
// Service は、ユーザー関連のビジネスロジックを提供します。
type Service struct {
db *sql.DB
}
// Create は、新しいユーザーを作成します。
func (s *Service) Create(username, email string) (*User, error) {
const op = "user.Create"
// バリデーション
if err := s.validate(username, email); err != nil {
return nil, &Error{
Op: op,
Err: err,
}
}
// 重複チェック
exists, err := s.usernameExists(username)
if err != nil {
return nil, &Error{
Op: op,
Err: err,
}
}
if exists {
return nil, &Error{
Op: op,
Code: ECONFLICT,
Message: "このユーザー名は既に使用されています",
}
}
// データベースへの保存
user, err := s.insert(username, email)
if err != nil {
return nil, &Error{
Op: op,
Err: err,
}
}
return user, nil
}
// validate は、ユーザー情報のバリデーションを行います。
func (s *Service) validate(username, email string) error {
const op = "user.Validate"
if username == "" {
return &Error{
Op: op,
Code: EINVALID,
Message: "ユーザー名は必須です",
}
}
if len(username) < 3 {
return &Error{
Op: op,
Code: EINVALID,
Message: "ユーザー名は3文字以上である必要があります",
}
}
if email == "" {
return &Error{
Op: op,
Code: EINVALID,
Message: "メールアドレスは必須です",
}
}
return nil
}
// usernameExists は、ユーザー名が既に存在するかチェックします。
func (s *Service) usernameExists(username string) (bool, error) {
const op = "user.UsernameExists"
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count)
if err != nil {
return false, &Error{
Op: op,
Code: EINTERNAL,
Message: "データベースエラーが発生しました",
Err: err,
}
}
return count > 0, nil
}
// insert は、ユーザーをデータベースに挿入します。
func (s *Service) insert(username, email string) (*User, error) {
const op = "user.Insert"
result, err := s.db.Exec(
"INSERT INTO users (username, email) VALUES (?, ?)",
username, email,
)
if err != nil {
return nil, &Error{
Op: op,
Code: EINTERNAL,
Message: "ユーザーの作成に失敗しました",
Err: err,
}
}
id, _ := result.LastInsertId()
return &User{
ID: id,
Username: username,
Email: email,
}, nil
}
type User struct {
ID int64
Username string
Email string
}
このコードの優れた点:
- 階層的なエラー情報: 各関数で
Opを設定し、エラーの発生箇所を追跡可能 - 明確なエラーコード:
ECONFLICT,EINVALID,EINTERNALで処理を分岐可能 - ユーザーフレンドリーなメッセージ: エンドユーザーに分かりやすいメッセージを提供
エラー発生時の出力例
user.Create: user.Validate: ユーザー名は3文字以上である必要があります: invalid
このエラーメッセージから分かること:
user.Create→ ユーザー作成処理で発生user.Validate→ バリデーション処理が原因- ユーザーに表示するメッセージ: 「ユーザー名は3文字以上である必要があります」
- エラーコード:
invalid(バリデーションエラー)
エラー情報の取得 🔍
エラーから必要な情報を取り出すユーティリティ関数を実装しましょう!
エラーコードの取得
// ErrorCode は、エラーから最も具体的なエラーコードを取得します。
// ネストされたエラーを再帰的に探索し、最初に見つかったコードを返します。
func ErrorCode(err error) string {
if err == nil {
return ""
}
// カスタムErrorの場合
if e, ok := err.(*Error); ok {
if e.Code != "" {
return e.Code
}
// ネストされたエラーを探索
return ErrorCode(e.Err)
}
// デフォルトは内部エラー
return EINTERNAL
}
使用例:
err := userService.Create("ab", "[email protected]")
if err != nil {
code := ErrorCode(err)
switch code {
case EINVALID:
// バリデーションエラーの処理
log.Printf("バリデーションエラー: %v", err)
case ECONFLICT:
// 競合エラーの処理
log.Printf("ユーザー名が重複しています: %v", err)
case EINTERNAL:
// 内部エラーの処理
log.Printf("予期しないエラー: %v", err)
}
}
エラーメッセージの取得
// ErrorMessage は、エンドユーザー向けのメッセージを取得します。
// ネストされたエラーから、人間が読めるメッセージを探索します。
func ErrorMessage(err error) string {
if err == nil {
return ""
}
// カスタムErrorの場合
if e, ok := err.(*Error); ok {
if e.Message != "" {
return e.Message
}
// ネストされたエラーを探索
if e.Err != nil {
return ErrorMessage(e.Err)
}
}
// デフォルトメッセージ
return "エラーが発生しました。しばらくしてから再度お試しください。"
}
使用例:
err := userService.Create("ab", "[email protected]")
if err != nil {
// ユーザーに表示するメッセージを取得
message := ErrorMessage(err)
// UIに表示
renderError(message) // "ユーザー名は3文字以上である必要があります"
}
HTTPレスポンスへの適用 🌐
Web APIでエラーを返す際の実践的なパターンです!
エラーレスポンスの構造体
// ErrorResponse は、API のエラーレスポンス形式です。
type ErrorResponse struct {
Error string `json:"error"` // エンドユーザー向けメッセージ
Code string `json:"code,omitempty"` // エラーコード(オプション)
}
HTTPハンドラーでの使用例
完全なHTTPハンドラー実装例(60行)
package api
import (
"encoding/json"
"log"
"net/http"
)
// Handler は、HTTP リクエストを処理します。
type Handler struct {
userService *user.Service
}
// CreateUser は、ユーザー作成 API のハンドラーです。
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
// リクエストのパース
var req struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, &Error{
Code: EINVALID,
Message: "リクエストが不正です",
Err: err,
})
return
}
// ユーザー作成
user, err := h.userService.Create(req.Username, req.Email)
if err != nil {
h.writeError(w, err)
return
}
// 成功レスポンス
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// writeError は、エラーレスポンスを書き込みます。
func (h *Handler) writeError(w http.ResponseWriter, err error) {
// エラーコードとメッセージを取得
code := ErrorCode(err)
message := ErrorMessage(err)
// オペレーター向けにログ出力(詳細情報)
log.Printf("API Error: %v", err)
// HTTPステータスコードを決定
statusCode := ErrorStatusCode(code)
// エラーレスポンスを作成
response := ErrorResponse{
Error: message,
Code: code,
}
// レスポンスを書き込み
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
レスポンス例
成功時(201 Created):
{
"id": 123,
"username": "john_doe",
"email": "[email protected]"
}
バリデーションエラー時(400 Bad Request):
{
"error": "ユーザー名は3文字以上である必要があります",
"code": "invalid"
}
ユーザー名重複時(409 Conflict):
{
"error": "このユーザー名は既に使用されています",
"code": "conflict"
}
内部エラー時(500 Internal Server Error):
{
"error": "エラーが発生しました。しばらくしてから再度お試しください。",
"code": "internal"
}
サーバーログ(オペレーター向け):
API Error: user.Create: user.Validate: ユーザー名は3文字以上である必要があります: invalid
まとめ 🎉
「Failure is Your Domain」アプローチは、エラーハンドリングを次のレベルに引き上げます!
このアプローチの利点
-
保守性の向上 🔧
- エラーの発生箇所が明確
- 論理的なスタックトレースで問題の特定が容易
- 一貫したエラー処理パターン
-
優れたユーザー体験 💕
- 分かりやすいエラーメッセージ
- 適切なHTTPステータスコード
- 次のアクションが明確
-
効率的なデバッグ 🐛
- 詳細なコンテキスト情報
- エラーの階層構造
- ログから問題を迅速に特定可能
-
柔軟な拡張性 🚀
- 新しいエラーコードの追加が容易
- プロジェクト固有のニーズに対応可能
- 既存コードへの影響が最小限
実装時のベストプラクティス
✅ DO(推奨):
- 各関数で
Op(操作名)を設定する - ユーザーフレンドリーな
Messageを提供する - 適切な
Codeを選択する - エラーをラップして完全なコンテキストを保持する
❌ DON'T(非推奨):
- エラーを単純な文字列として扱う
- エラーコードを動的に生成する
- 技術的な詳細をユーザーに見せる
- エラー情報を途中で失う
参考リンク 🔗
次のステップ 🎯
このアプローチを自分のプロジェクトに適用する際は:
- まず、アプリケーション固有のエラーコードを定義
Error構造体を実装- ユーティリティ関数(
ErrorCode,ErrorMessage)を実装 - 既存のエラー処理を段階的に移行
エラーハンドリングは地味ですが、アプリケーションの品質を大きく左右する重要な要素です。「Failure is Your Domain」アプローチで、より堅牢で保守性の高いコードを書いていきましょう!💪✨