go-web-expert について
Go の Web 開発者向けに、本番品質を意識した五つのルールをまとめたガイドです。依存性注入、入力検証、エラー処理、HTTP テスト、ドキュメントを扱います。
主な機能
- 01可変依存関係をサーバー構造体に注入
- 02エラーの明示的な処理とラップ
- 03リクエスト境界での JSON 検証
- 04httptest によるテーブル駆動テスト
- 05エクスポートされた識別子のドキュメント化
- 06定められたゲートに沿ったハンドラー変更の確認
活用シーン
- 01新しい HTTP ハンドラーの実装
- 02リクエスト検証とエラーレスポンスの確認
- 03ハンドラーやミドルウェアのテスト作成
- 04Go Web コードの規約適合性レビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill go-web-expertインストール後、Claude Code で/go-web-expertを入力して読み込まれたか確認します。
同梱ファイルreferences/testing-handlers.mdreferences/validation.md
詳細
Go Web エキスパートシステム
本番品質の Go Web アプリケーションを実現するための、譲れない5つのルールです。すべてのハンドラー、すべてのサービス、すべてのコード行が、5つすべてを満たす必要があります。
クイックリファレンス
| トピック | リファレンス |
|---|---|
| バリデーションタグ、カスタムバリデーター、ネストした構造体、エラーのフォーマット | references/validation.md |
| httptest のパターン、ミドルウェアのテスト、統合テスト、フィクスチャ | references/testing-handlers.md |
遵守すべきルール
| # | ルール | 要約 |
|---|---|---|
| 1 | グローバル状態ゼロ | すべてのハンドラーを構造体のメソッドにし、変更可能な状態のパッケージレベル var は使用しません |
| 2 | 明示的なエラー処理 | すべてのエラーを確認し、fmt.Errorf("doing X: %w", err) でラップします |
| 3 | バリデーションを最初に行う | 受信するすべての JSON を境界で go-playground/validator を使って検証します |
| 4 | テスト可能性 | すべてのハンドラーに、テーブル駆動テストで httptest を使う _test.go を用意します |
| 5 | ドキュメント | エクスポートされるすべてのシンボルに、その名前で始まる Go ドキュメントコメントを付けます |
厳守事項(新しい HTTP ハンドラー)
順番どおりに適用します。現在のステップの合格条件が満たされるまでは、次のステップが完了したものとして扱わないでください(「頭の中で確認した」ではなく、ディスク上またはテスト出力で客観的に確認できる証拠が必要です)。
-
依存関係(ルール 1) — 合格条件: ハンドラーが、変更可能な依存関係(
db、ロガー、HTTP クライアント、キャッシュ)をすべて保持する構造体のメソッドであること。新しいパッケージレベルvarは、パッケージレベルで許可されるものの許可リストにあるものに限ります。証拠: コンストラクターで依存関係が設定されており、そのリストにある禁止グローバルが新たに追加されていないこと。 -
境界(ルール 3) — サービス/ドメインコードの呼び出し前に合格すること:合格条件: リクエストがタグ付き構造体にデコードされ、
validate.Struct(または同等の処理)が実行されること。無効な JSON とバリデーション失敗に対して、HTTP ステータスと本文が定義されていること(例:400/422)。証拠: ハンドラー内にデコードとvalidate.Structがあり、テストまたは手動実行で不正な入力に対する 422/400 が確認できること。 -
エラー(ルール 2) — 合格条件: ハンドラーの処理経路で
_による破棄がなく、json.NewEncoder(w).Encodeのエラーが処理され、上位に渡すかログに記録するエラーが、このスキルの規定に従ってラップ(%w)されているか、AppErrorにマッピングされていること。証拠: 差分を確認し、無視されたエラーや、ラップが必要な箇所でコンテキストのないreturn errがないこと。 -
テスト(ルール 4) — 合格条件: ハンドラーのパッケージに
_test.goがあり、httptestを使ってServeHTTPを呼び出し、少なくとも成功ケースと非 2xx ケース(バリデーション、Not Found、またはドメインエラー)を含むこと。証拠: テストファイルのパスが存在し、そのパッケージに対するgo testが成功すること。 -
ドキュメント(ルール 5) — 合格条件: 変更に含まれる新規または変更されたエクスポート識別子すべてに、最初の行がその識別子の名前で始まるドキュメントコメントがあること。証拠:
go doc <pkg>または IDE/ドキュメントのプレビューで、新しいエクスポートの概要が表示されること。
ルール 1:グローバル状態ゼロ
すべてのハンドラーをサーバー構造体のメソッドにする必要があります。データベース、ロガー、クライアント、その他の変更可能な状態を保持するパッケージレベル var は使用しません。
// FORBIDDEN
var db *sql.DB
var logger *slog.Logger
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := db.QueryRow(...) // global state -- untestable, unsafe
}
// REQUIRED
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := s.db.QueryRow(...) // explicit dependency
}
パッケージレベルで許可されるもの
- 定数 --
const maxPageSize = 100 - 純粋関数 -- 副作用がなく、引数のみに依存する関数
- センチネルエラー --
var ErrNotFound = errors.New("not found") - バリデーターのインスタンス --
var validate = validator.New()(初期化後はステートレス)
パッケージレベルで禁止されるもの
- データベース接続(
*sql.DB、*pgxpool.Pool) - ロガー(
*slog.Logger) - タイムアウトまたはトランスポートを設定した HTTP クライアント
- 環境変数から読み込む設定構造体
- キャッシュ、レートリミッター、その他の変更可能な共有リソース
コンストラクターパターン
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
ルール 2:明示的なエラー処理
エラーを決して無視しないでください。エラーが発生したときに何を実行しようとしていたかを説明するコンテキストを付けて、すべてのエラーをラップする必要があります。
// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data) // error ignored
// REQUIRED
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something for user %s: %w", userID, err)
}
if err := json.NewEncoder(w).Encode(data); err != nil {
s.logger.Error("encoding response", "err", err, "request_id", reqID)
}
エラーのラップ規約
形式:"<verb>ing <noun>: %w" -- 小文字で、ピリオドを付けず、呼び出しチェーンのコンテキストを示します。
// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)
// Bad wrapping
return fmt.Errorf("error: %w", err) // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err // no wrapping at all
HTTP API 用の構造化エラー型
type AppError struct {
Code int `json:"-"`
Message string `json:"error"`
Detail string `json:"detail,omitempty"`
}
func (e *AppError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
writeJSON(w, appErr.Code, appErr)
return
}
slog.Error("unhandled error",
"err", err,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
よくある間違い
// MISTAKE: not checking Close errors on writers
defer f.Close() // at minimum, log Close errors for writable resources
// BETTER for writable resources:
defer func() {
if err := f.Close(); err != nil {
s.logger.Error("closing file", "err", err)
}
}()
// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()
ルール 3:まずバリデーション
受信するすべての JSON に go-playground/validator を使用します。境界でバリデーションし、内部データは信頼します。
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,gte=0,lte=150"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
}
if err := validate.Struct(req); err != nil {
return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
}
// From here, req is trusted
user, err := s.userService.Create(r.Context(), req.Name, req.Email)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
writeJSON(w, http.StatusCreated, user)
return nil
}
バリデーションエラーのフォーマット
func formatValidationErrors(err error) string {
var msgs []string
for _, e := range err.(validator.ValidationErrors) {
msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
}
return strings.Join(msgs, "; ")
}
バリデーション境界のルール
- 境界でバリデーションする -- HTTP ハンドラー、メッセージコンシューマー、CLI 入力
- 内部データは信頼する -- サービスレイヤーは、すでにバリデーション済みの型を受け取ります
- 二度バリデーションしない -- ハンドラーでバリデーション済みの場合、サービスで同じフィールドを再バリデーションしません
カスタムバリデーター、ネストされた構造体のバリデーション、スライスのバリデーション、フィールド間のバリデーションについては、references/validation.md を参照してください。
ルール 4:テスト容易性
すべてのハンドラーには、httptest を使用する対応する _test.go ファイルが必要です。ハンドラーメソッドを直接呼び出すのではなく、HTTP レイヤーを通してテストします。
func TestServer_handleGetUser(t *testing.T) {
mockStore := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
srv := NewServer(mockStore, slog.Default())
tests := []struct {
name string
path string
wantStatus int
wantBody string
}{
{
name: "existing user",
path: "/api/users/123",
wantStatus: http.StatusOK,
wantBody: `"name":"Alice"`,
},
{
name: "not found",
path: "/api/users/999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
}
})
}
}
主なテスト原則
- HTTP を通してテストする --
httptest.NewRequestとhttptest.NewRecorderを使用し、srv.ServeHTTPを呼び出します - インターフェースベースのモック -- コンシューマー側で小さなインターフェースを定義し、テスト用のモック実装を作成します
- テーブル駆動テスト -- テストケースを格納した単一の
[]structと、単一のt.Runループを使用します - エラーパスも重要です -- 200 だけでなく、400、404、422、500 もテストします
- グローバルなテスト状態を使わない -- 各テストで、それぞれのモックを持つ独自のサーバーを作成します
ミドルウェアのテスト、実データベースを使った統合テスト、ファイルアップロードのテスト、ストリーミングレスポンスのテストについては、references/testing-handlers.md を参照してください。
ルール 5:ドキュメント
エクスポートされた関数、型、メソッド、定数にはすべて、標準規約に従った Go doc コメントが必要です。
// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
// ...
}
// Server handles HTTP requests for the user API.
type Server struct {
// ...
}
// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
// ...
}
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")
doc コメントの規約
- 名前から始める --
// CreateUser creates...のように記述し、// This function creates...とはしません - 最初の文が要約です --
go docの一覧や IDE のツールチップに表示されます - 重要なエラーの戻り値に言及する -- 呼び出し側がどのエラーを確認すべきか把握できるようにします
- 自明なことは説明しない --
// SetName sets the nameには価値がありません - 何をするかではなく、なぜそうするかを説明する -- 動作が自明でない場合は、その理由を説明します
パッケージのドキュメント
// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user
横断的な関心事
5 つのルールは互いに補強し合います。それぞれがどのように関係するかを説明します。
グローバル状態をなくすとテスト容易性が向上する
すべての依存関係が構造体に含まれるため、テストでモックを注入できます。
// Production
srv := NewServer(realDB, prodLogger)
// Test
srv := NewServer(mockStore, slog.Default())
db がグローバルな var だった場合、テストではパッケージ状態を変更する必要があり、並列テストで競合状態が発生します。
先にバリデーションするとエラー処理が簡潔になる
ハンドラーが境界でバリデーションすることで、サービスレイヤーは入力が有効だと想定できます。つまり、サービスレイヤーのエラーは常に予期しないもの(データベースの障害、ネットワークの問題)となり、エラー処理が簡潔になります。
func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
// No need to check if name is empty -- handler already validated
user := &User{Name: name, Email: email}
if err := s.store.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
return user, nil
}
ドキュメントによってエラー処理を把握しやすくなる
エラーの戻り値に言及する doc コメントは、呼び出し側が処理すべき内容を示します。
// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {
セルフレビュー用チェックリスト
ハンドラーまたはサービスを完成とみなす前に、5 つのルールすべてを確認してください。
グローバル状態をなくす
- 変更可能な状態(db、ロガー、クライアント)にパッケージレベルの
varを使用していない - すべてのハンドラーが構造体のメソッドになっている
- コンストラクターを通して依存関係を注入している
明示的なエラー処理
- 戻り値のエラーを無視するために
_を使用していない - すべてのエラーを
fmt.Errorf("doing X: %w", err)でラップしている -
json.NewEncoder(w).Encode(...)のエラーを確認するか、ログに記録している - HTTP エラーレスポンスに構造化された
AppErrorを使用している
まずバリデーション
- すべてのリクエスト構造体に
validateタグがある - ビジネスロジックを実行する前に
validate.Struct(req)を呼び出している - バリデーションエラーは、フィールドごとの詳細を含む 422 を返す
- サービスレイヤーで、ハンドラーがバリデーション済みのデータを再バリデーションしていない
テスト容易性
- 各ハンドラーファイルに対応する
_test.goファイルがある - テストでは
httptest.NewRequestとhttptest.NewRecorderを使用している - テーブル駆動テストで正常系とエラー系を網羅している
- モックは具象型ではなく、限定的なインターフェースを実装している
ドキュメント
- エクスポートされるすべての関数に、関数名で始まるドキュメントコメントがある
- エラーの戻り値について説明されている
- パッケージにドキュメントコメントがある
参照資料を読み込むタイミング
次の場合はvalidation.mdを読み込みます。
- バリデーションタグを持つ新しいリクエスト型を追加するとき
- カスタムバリデーターを作成するとき
- ネストされた構造体、スライス、またはマップを検証するとき
- API レスポンス用にバリデーションエラーを整形するとき
次の場合はtesting-handlers.mdを読み込みます。
- プロジェクトで初めてハンドラーテストを書くとき
- ミドルウェアチェーンや認証をテストするとき
- 実際のデータベースを使った統合テストを設定するとき
- ファイルのアップロードやストリーミングレスポンスをテストするとき