開発環境と CLAUDE.md の準備
必要な環境
Claude Code(最新版)
Go 1.24 以上(本稿は 1.25 系で検証しています)
Docker Desktop
PostgreSQL(ローカルまたは Docker)
Git
Go のセットアップは 公式ダウンロードページ から行えます。バージョンを確認しておきます。
go version
# go version go1.25.0 darwin/arm64
docker --version
git --version
CLAUDE.md がコード生成品質の半分を決める
プロジェクト直下の CLAUDE.md は、Claude Code に対する恒久的な指示書です。ここに規約を書いておくかどうかで、生成されるコードの一貫性が目に見えて変わります。
# Project: Go REST API
## 技術スタック
- Go 1.25+ / Gin v1.10+ / GORM v2 / PostgreSQL 16
## アーキテクチャ
- Clean Architecture (Handler → Service → Repository)
- 依存性注入(DI)はコンストラクタ関数で行う
## コード規約
- パッケージ名は小文字のみ
- エラーは必ずラップして返す(fmt.Errorf("...: %w", err))
- ログは log/slog(標準ライブラリ)を使用
- テストは testify を使用
- context.Context を第一引数で受け取る
## 禁止事項
- グローバル変数
- panic()(エラーは必ず返す)
- DI 以外での init() 関数
ポイントは「禁止事項」を明記することです。Claude Code は許可されたことより、禁止されたことのほうを確実に守ります。panic() の禁止を書き忘れた頃の私のプロジェクトでは、エラー処理に panic が混ざったコードが生成され、後からの除去に時間を取られました。最初の5分の投資が、後の数時間を節約してくれます。
プロジェクト構造 — Clean Architecture を Go で組む
構造の生成を依頼する
Claude Code に次のように依頼します。
CLAUDE.md の規約に従い、ユーザー管理 API のプロジェクト構造を
Clean Architecture で設計してください。
ディレクトリ構造と go.mod を作成してください。
生成される構造はこのようになります。
go-api/
├── cmd/
│ └── api/
│ └── main.go ← エントリーポイント
├── internal/
│ ├── domain/
│ │ ├── user.go ← ドメインモデル
│ │ └── errors.go ← ドメインエラー定義
│ ├── repository/
│ │ ├── user_repository.go ← インターフェース
│ │ └── postgres/
│ │ └── user_repository.go ← 実装
│ ├── service/
│ │ └── user_service.go ← ビジネスロジック
│ └── handler/
│ ├── user_handler.go ← HTTP ハンドラー
│ └── middleware/
│ └── auth.go ← JWT 認証ミドルウェア
├── pkg/
│ ├── database/
│ │ └── postgres.go ← DB 接続
│ ├── jwt/
│ │ └── jwt.go ← JWT ユーティリティ
│ └── validator/
│ └── validator.go ← バリデーション
├── migrations/ ← SQL マイグレーション
├── Dockerfile
├── docker-compose.yml
├── .github/
│ └── workflows/
│ └── ci.yml
├── CLAUDE.md
├── go.mod
└── go.sum
internal/ 配下に置いたパッケージは外部モジュールからインポートできない、という Go の言語仕様をそのまま設計に活かしています。ドメインロジックの意図しない公開をコンパイラが防いでくれる構造です。
依存パッケージのインストール
cd go-api
go mod init github.com/yourusername/go-api
# Web フレームワーク
go get github.com/gin-gonic/gin@latest
# ORM
go get gorm.io/gorm@latest
go get gorm.io/driver/postgres@latest
# 環境変数
go get github.com/joho/godotenv@latest
# JWT
go get github.com/golang-jwt/jwt/v5@latest
# バリデーション
go get github.com/go-playground/validator/v10@latest
# テスト
go get github.com/stretchr/testify@latest
バージョンを固定したい場合は @latest の代わりに具体的なバージョンを指定し、go.sum を必ずコミットします。Claude Code に依存追加を任せると古いバージョンを指定することがあるため、依存のバージョン決定だけは人間が go list -m -u all で確認する 運用にしております。
ドメインモデルとリポジトリ層
ドメインモデルの定義
Claude Code へのプロンプト例です。
internal/domain/user.go を作成してください。
ユーザーモデルには以下のフィールドが必要です:
- ID(UUID)
- Email(ユニーク)
- PasswordHash
- Name
- CreatedAt / UpdatedAt / DeletedAt
生成される internal/domain/user.go の例です。
package domain
import (
" time "
" github.com/google/uuid "
)
// User はユーザーのドメインモデルです
type User struct {
ID uuid . UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Email string `gorm:"uniqueIndex;not null;size:255"`
PasswordHash string `gorm:"not null"`
Name string `gorm:"not null;size:100"`
CreatedAt time . Time
UpdatedAt time . Time
DeletedAt * time . Time `gorm:"index"`
}
// UserRepository はユーザーデータアクセスのインターフェースです
type UserRepository interface {
Create ( ctx context . Context , user * User ) error
FindByID ( ctx context . Context , id uuid . UUID ) ( * User , error )
FindByEmail ( ctx context . Context , email string ) ( * User , error )
Update ( ctx context . Context , user * User ) error
Delete ( ctx context . Context , id uuid . UUID ) error
List ( ctx context . Context , offset , limit int ) ([] * User , int64 , error )
}
// ドメインエラー定義
var (
ErrUserNotFound = & DomainError {Code: "USER_NOT_FOUND" , Message: "ユーザーが見つかりません" }
ErrUserAlreadyExists = & DomainError {Code: "USER_ALREADY_EXISTS" , Message: "このメールアドレスは既に使用されています" }
ErrInvalidCredential = & DomainError {Code: "INVALID_CREDENTIAL" , Message: "メールアドレスまたはパスワードが正しくありません" }
)
// DomainError はドメイン層のエラー型です
type DomainError struct {
Code string
Message string
}
func ( e * DomainError ) Error () string {
return e.Message
}
リポジトリのインターフェースをドメイン層に置くのが、この設計の要です。実装(PostgreSQL)はインターフェースに依存し、逆ではありません。テスト時にモックへ差し替えられるのも、この一手間のおかげです。
なお、初版の生成コードでは context.Context を受け取らないシグネチャになりがちです。タイムアウト制御とトレーシングのために、リポジトリの全メソッドは ctx を第一引数で受け取る よう CLAUDE.md に明記しておくことをおすすめします。
PostgreSQL リポジトリ実装
// internal/repository/postgres/user_repository.go
package postgres
import (
" context "
" errors "
" fmt "
" strings "
" github.com/google/uuid "
" gorm.io/gorm "
" github.com/yourusername/go-api/internal/domain "
)
type userRepository struct {
db * gorm . DB
}
// NewUserRepository は PostgreSQL ユーザーリポジトリを生成します
func NewUserRepository ( db * gorm . DB ) domain . UserRepository {
return & userRepository {db: db}
}
func ( r * userRepository ) Create ( ctx context . Context , user * domain . User ) error {
result := r.db. WithContext (ctx). Create (user)
if result.Error != nil {
// UNIQUE 制約違反を検出
if isDuplicateError (result.Error) {
return domain.ErrUserAlreadyExists
}
return fmt. Errorf ( "ユーザー作成エラー: %w " , result.Error)
}
return nil
}
func ( r * userRepository ) FindByID ( ctx context . Context , id uuid . UUID ) ( * domain . User , error ) {
var user domain . User
result := r.db. WithContext (ctx).
Where ( "id = ? AND deleted_at IS NULL" , id). First ( & user)
if result.Error != nil {
if errors. Is (result.Error, gorm.ErrRecordNotFound) {
return nil , domain.ErrUserNotFound
}
return nil , fmt. Errorf ( "ユーザー取得エラー: %w " , result.Error)
}
return & user, nil
}
func ( r * userRepository ) FindByEmail ( ctx context . Context , email string ) ( * domain . User , error ) {
var user domain . User
result := r.db. WithContext (ctx).
Where ( "email = ? AND deleted_at IS NULL" , email). First ( & user)
if result.Error != nil {
if errors. Is (result.Error, gorm.ErrRecordNotFound) {
return nil , domain.ErrUserNotFound
}
return nil , fmt. Errorf ( "ユーザー検索エラー: %w " , result.Error)
}
return & user, nil
}
func ( r * userRepository ) List ( ctx context . Context , offset , limit int ) ([] * domain . User , int64 , error ) {
var users [] * domain . User
var total int64
// 総件数を取得
if err := r.db. WithContext (ctx). Model ( & domain . User {}).
Where ( "deleted_at IS NULL" ). Count ( & total).Error; err != nil {
return nil , 0 , fmt. Errorf ( "件数取得エラー: %w " , err)
}
// ページネーション付きで取得
result := r.db. WithContext (ctx).
Where ( "deleted_at IS NULL" ).
Order ( "created_at DESC" ).
Offset (offset). Limit (limit).
Find ( & users)
if result.Error != nil {
return nil , 0 , fmt. Errorf ( "ユーザー一覧取得エラー: %w " , result.Error)
}
return users, total, nil
}
// isDuplicateError は PostgreSQL の UNIQUE 制約違反を検出します
func isDuplicateError ( err error ) bool {
if err == nil {
return false
}
return errors. Is (err, gorm.ErrDuplicatedKey) ||
strings. Contains (err. Error (), "duplicate key" ) ||
strings. Contains (err. Error (), "UNIQUE constraint failed" )
}
WithContext(ctx) を全クエリに通している点に注目してください。HTTP リクエストがキャンセルされたとき、進行中のクエリも連動して打ち切られます。負荷試験でこの有無を比べると、タイムアウト時のコネクション解放速度に明確な差が出ます。
サービス層 — ビジネスロジックと認証の中核
サービス層はパスワードのハッシュ化や JWT 発行などのビジネスロジックを担当します。
// internal/service/user_service.go
package service
import (
" context "
" fmt "
" log/slog "
" github.com/google/uuid "
" golang.org/x/crypto/bcrypt "
" github.com/yourusername/go-api/internal/domain "
" github.com/yourusername/go-api/pkg/jwt "
)
type UserService struct {
repo domain . UserRepository
jwtManager * jwt . Manager
logger * slog . Logger
}
func NewUserService ( repo domain . UserRepository , jwtManager * jwt . Manager , logger * slog . Logger ) * UserService {
return & UserService {
repo: repo,
jwtManager: jwtManager,
logger: logger,
}
}
// Register は新規ユーザー登録を行います
func ( s * UserService ) Register ( ctx context . Context , email , password , name string ) ( * domain . User , error ) {
// パスワードハッシュ化(コスト12は本番推奨値)
hash, err := bcrypt. GenerateFromPassword ([] byte (password), 12 )
if err != nil {
return nil , fmt. Errorf ( "パスワードハッシュ化エラー: %w " , err)
}
user := & domain . User {
Email: email,
PasswordHash: string (hash),
Name: name,
}
if err := s.repo. Create (ctx, user); err != nil {
return nil , err // ドメインエラーをそのまま返す
}
s.logger. Info ( "ユーザー登録完了" ,
slog. String ( "user_id" , user.ID. String ()),
)
return user, nil
}
// Login はログイン認証と JWT トークン発行を行います
func ( s * UserService ) Login ( ctx context . Context , email , password string ) ( string , error ) {
user, err := s.repo. FindByEmail (ctx, email)
if err != nil {
// ユーザーが見つからない場合も同じエラーを返す(列挙攻撃対策)
return "" , domain.ErrInvalidCredential
}
// パスワード検証
if err := bcrypt. CompareHashAndPassword ([] byte (user.PasswordHash), [] byte (password)); err != nil {
s.logger. Warn ( "ログイン失敗" , slog. String ( "email" , email))
return "" , domain.ErrInvalidCredential
}
// JWT トークン発行
token, err := s.jwtManager. Generate (user.ID, user.Email)
if err != nil {
return "" , fmt. Errorf ( "トークン生成エラー: %w " , err)
}
s.logger. Info ( "ログイン成功" , slog. String ( "user_id" , user.ID. String ()))
return token, nil
}
細かい点ですが、ユーザーが存在しない場合とパスワードが違う場合で同じ ErrInvalidCredential を返しています。エラーを分けると「このメールアドレスは登録されている」という情報が攻撃者に漏れるためです。こうしたセキュリティの定石は、プロンプトで明示しなくても Claude Code がかなりの精度で織り込んでくれるようになりましたが、コードレビューで必ず確認すべき箇所 として私はチェックリストに入れています。
ハンドラー層 — Gin で統一感のある API 面を作る
Claude Code への依頼例です。
internal/handler/user_handler.go を作成してください。
以下のエンドポイントを実装:
- POST /api/v1/auth/register
- POST /api/v1/auth/login
- GET /api/v1/users/:id(JWT 認証必須)
- GET /api/v1/users(JWT 認証必須、ページネーション対応)
リクエストボディは JSON で、バリデーションには go-playground/validator を使用。
エラーレスポンスは統一フォーマットにしてください。
// internal/handler/user_handler.go
package handler
import (
" errors "
" log/slog "
" net/http "
" strconv "
" github.com/gin-gonic/gin "
" github.com/google/uuid "
" github.com/yourusername/go-api/internal/domain "
" github.com/yourusername/go-api/internal/service "
)
type UserHandler struct {
service * service . UserService
logger * slog . Logger
}
func NewUserHandler ( service * service . UserService , logger * slog . Logger ) * UserHandler {
return & UserHandler {service: service, logger: logger}
}
// 統一エラーレスポンス
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
// 登録リクエスト
type RegisterRequest struct {
Email string `json:"email" binding:"required,email,max=255"`
Password string `json:"password" binding:"required,min=8,max=72"`
Name string `json:"name" binding:"required,min=1,max=100"`
}
// Register は新規ユーザー登録を処理します
func ( h * UserHandler ) Register ( c * gin . Context ) {
var req RegisterRequest
if err := c. ShouldBindJSON ( & req); err != nil {
c. JSON (http.StatusBadRequest, ErrorResponse {
Code: "INVALID_REQUEST" ,
Message: "リクエストの形式が正しくありません" ,
})
return
}
user, err := h.service. Register (c.Request. Context (), req.Email, req.Password, req.Name)
if err != nil {
h. handleServiceError (c, err)
return
}
c. JSON (http.StatusCreated, gin . H {
"id" : user.ID,
"email" : user.Email,
"name" : user.Name,
"created_at" : user.CreatedAt,
})
}
// Login はログインと JWT トークン発行を処理します
func ( h * UserHandler ) Login ( c * gin . Context ) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c. ShouldBindJSON ( & req); err != nil {
c. JSON (http.StatusBadRequest, ErrorResponse {Code: "INVALID_REQUEST" , Message: "入力内容を確認してください" })
return
}
token, err := h.service. Login (c.Request. Context (), req.Email, req.Password)
if err != nil {
h. handleServiceError (c, err)
return
}
c. JSON (http.StatusOK, gin . H { "token" : token})
}
// GetUser はユーザー詳細を返します(JWT 認証済み)
func ( h * UserHandler ) GetUser ( c * gin . Context ) {
idStr := c. Param ( "id" )
id, err := uuid. Parse (idStr)
if err != nil {
c. JSON (http.StatusBadRequest, ErrorResponse {Code: "INVALID_ID" , Message: "ID の形式が正しくありません" })
return
}
user, err := h.service. GetByID (c.Request. Context (), id)
if err != nil {
h. handleServiceError (c, err)
return
}
c. JSON (http.StatusOK, gin . H {
"id" : user.ID,
"email" : user.Email,
"name" : user.Name,
"created_at" : user.CreatedAt,
})
}
// ListUsers はユーザー一覧をページネーション付きで返します
func ( h * UserHandler ) ListUsers ( c * gin . Context ) {
page, _ := strconv. Atoi (c. DefaultQuery ( "page" , "1" ))
perPage, _ := strconv. Atoi (c. DefaultQuery ( "per_page" , "20" ))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1 ) * perPage
users, total, err := h.service. List (c.Request. Context (), offset, perPage)
if err != nil {
c. JSON (http.StatusInternalServerError, ErrorResponse {Code: "INTERNAL_ERROR" , Message: "サーバーエラーが発生しました" })
return
}
c. JSON (http.StatusOK, gin . H {
"users" : users,
"total" : total,
"page" : page,
"per_page" : perPage,
})
}
// handleServiceError はサービス層のエラーを HTTP レスポンスに変換します
func ( h * UserHandler ) handleServiceError ( c * gin . Context , err error ) {
var domainErr * domain . DomainError
if errors. As (err, & domainErr) {
status := http.StatusBadRequest
switch domainErr.Code {
case "USER_NOT_FOUND" :
status = http.StatusNotFound
case "INVALID_CREDENTIAL" :
status = http.StatusUnauthorized
case "USER_ALREADY_EXISTS" :
status = http.StatusConflict
}
c. JSON (status, ErrorResponse {Code: domainErr.Code, Message: domainErr.Message})
return
}
h.logger. Error ( "予期せぬエラー" , slog. String ( "error" , err. Error ()))
c. JSON (http.StatusInternalServerError, ErrorResponse {Code: "INTERNAL_ERROR" , Message: "サーバーエラーが発生しました" })
}
handleServiceError に変換ロジックを集約しているのは、エラーレスポンスの形式がエンドポイントごとにばらつくのを防ぐためです。AI にハンドラーを追加生成させたときも、この関数を経由させる規約にしておけば API 面の一貫性が崩れません。
もう1点、Gin では構造体タグに validate: ではなく binding: を使う必要があります。Claude Code は go-playground/validator 単体の流儀で validate: タグを生成することがあり、その場合バリデーションが静かに素通り します。エラーにならないだけに気づきにくく、私自身、テストで未検証のリクエストが通って初めて気づきました。生成直後に不正なリクエストを投げて 400 が返ることを確認する。この一手間を省略しないことをおすすめします。
JWT 認証とルーター設定、そして graceful shutdown
JWT 管理ユーティリティ
// pkg/jwt/jwt.go
package jwt
import (
" fmt "
" time "
" github.com/golang-jwt/jwt/v5 "
" github.com/google/uuid "
)
type Manager struct {
secretKey [] byte
duration time . Duration
}
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt . RegisteredClaims
}
func NewManager ( secretKey string , duration time . Duration ) * Manager {
return & Manager {
secretKey: [] byte (secretKey),
duration: duration,
}
}
// Generate は JWT トークンを生成します
func ( m * Manager ) Generate ( userID uuid . UUID , email string ) ( string , error ) {
claims := & Claims {
UserID: userID. String (),
Email: email,
RegisteredClaims: jwt . RegisteredClaims {
ExpiresAt: jwt. NewNumericDate (time. Now (). Add (m.duration)),
IssuedAt: jwt. NewNumericDate (time. Now ()),
Issuer: "go-api" ,
},
}
token := jwt. NewWithClaims (jwt.SigningMethodHS256, claims)
signed, err := token. SignedString (m.secretKey)
if err != nil {
return "" , fmt. Errorf ( "JWT 署名エラー: %w " , err)
}
return signed, nil
}
// Verify は JWT トークンを検証し Claims を返します
func ( m * Manager ) Verify ( tokenStr string ) ( * Claims , error ) {
token, err := jwt. ParseWithClaims (tokenStr, & Claims {}, func ( t * jwt . Token ) ( interface {}, error ) {
if _, ok := t.Method.( * jwt . SigningMethodHMAC ); ! ok {
return nil , fmt. Errorf ( "予期しない署名方式: %v " , t.Header[ "alg" ])
}
return m.secretKey, nil
})
if err != nil {
return nil , fmt. Errorf ( "トークン検証失敗: %w " , err)
}
claims, ok := token.Claims.( * Claims )
if ! ok || ! token.Valid {
return nil , fmt. Errorf ( "無効なトークン" )
}
return claims, nil
}
署名方式の検証(SigningMethodHMAC のチェック)は省略してはいけない箇所です。これがないと、alg: none を指定した改竄トークンを受け入れてしまう古典的な脆弱性につながります。
main.go — DI と graceful shutdown
エントリーポイントには、依存性注入に加えて graceful shutdown を組み込みます。元になる構成を Claude Code に生成させたあと、運用目線で必ず加筆する箇所です。
// cmd/api/main.go
package main
import (
" context "
" errors "
" log/slog "
" net/http "
" os "
" os/signal "
" syscall "
" time "
" github.com/gin-gonic/gin "
" github.com/joho/godotenv "
" github.com/yourusername/go-api/internal/handler "
" github.com/yourusername/go-api/internal/handler/middleware "
" github.com/yourusername/go-api/internal/repository/postgres "
" github.com/yourusername/go-api/internal/service "
pkgdb " github.com/yourusername/go-api/pkg/database "
" github.com/yourusername/go-api/pkg/jwt "
)
func main () {
godotenv. Load ()
// 構造化ログ設定
logger := slog. New (slog. NewJSONHandler (os.Stdout, & slog . HandlerOptions {
Level: slog.LevelInfo,
}))
slog. SetDefault (logger)
// データベース接続
db, err := pkgdb. NewPostgres (os. Getenv ( "DATABASE_URL" ))
if err != nil {
logger. Error ( "DB 接続失敗" , slog. String ( "error" , err. Error ()))
os. Exit ( 1 )
}
// DI(依存性注入)
jwtManager := jwt. NewManager (os. Getenv ( "JWT_SECRET" ), 24 * time.Hour)
userRepo := postgres. NewUserRepository (db)
userSvc := service. NewUserService (userRepo, jwtManager, logger)
userHandler := handler. NewUserHandler (userSvc, logger)
authMiddleware := middleware. NewAuthMiddleware (jwtManager)
// Gin ルーター
r := gin. New ()
r. Use (gin. Recovery ())
r. Use (middleware. RequestLogger (logger))
// ヘルスチェック
r. GET ( "/health" , func ( c * gin . Context ) {
c. JSON ( 200 , gin . H { "status" : "ok" })
})
// API v1
v1 := r. Group ( "/api/v1" )
{
auth := v1. Group ( "/auth" )
auth. POST ( "/register" , userHandler.Register)
auth. POST ( "/login" , userHandler.Login)
users := v1. Group ( "/users" )
users. Use (authMiddleware. Authenticate ())
users. GET ( "/:id" , userHandler.GetUser)
users. GET ( "" , userHandler.ListUsers)
}
port := os. Getenv ( "PORT" )
if port == "" {
port = "8080"
}
srv := & http . Server {
Addr: ":" + port,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// サーバーを別ゴルーチンで起動
go func () {
logger. Info ( "サーバー起動" , slog. String ( "port" , port))
if err := srv. ListenAndServe (); err != nil && ! errors. Is (err, http.ErrServerClosed) {
logger. Error ( "サーバーエラー" , slog. String ( "error" , err. Error ()))
os. Exit ( 1 )
}
}()
// SIGINT / SIGTERM を待機
quit := make ( chan os . Signal , 1 )
signal. Notify (quit, syscall.SIGINT, syscall.SIGTERM)
<- quit
// 猶予 10 秒で graceful shutdown
logger. Info ( "シャットダウン開始" )
ctx, cancel := context. WithTimeout (context. Background (), 10 * time.Second)
defer cancel ()
if err := srv. Shutdown (ctx); err != nil {
logger. Error ( "強制終了" , slog. String ( "error" , err. Error ()))
}
logger. Info ( "シャットダウン完了" )
}
r.Run() で済ませず http.Server を明示的に組むのは、Kubernetes や各種 PaaS が送る SIGTERM に正しく応答するためです。graceful shutdown がないと、デプロイのたびに処理中のリクエストが切断され、ユーザーには散発的な 502 として見えます。ローカル開発では一切症状が出ないため、本番に出して初めて気づく類の問題です。
コネクションプールの設定
DB 接続も、デフォルト値のままでは本番の負荷に耐えません。
// pkg/database/postgres.go
package database
import (
" fmt "
" time "
" gorm.io/driver/postgres "
" gorm.io/gorm "
)
// NewPostgres は本番向け設定の PostgreSQL 接続を生成します
func NewPostgres ( dsn string ) ( * gorm . DB , error ) {
db, err := gorm. Open (postgres. Open (dsn), & gorm . Config {
TranslateError: true , // gorm.ErrDuplicatedKey 等への変換を有効化
})
if err != nil {
return nil , fmt. Errorf ( "DB 接続エラー: %w " , err)
}
sqlDB, err := db. DB ()
if err != nil {
return nil , fmt. Errorf ( "sql.DB 取得エラー: %w " , err)
}
// コネクションプール設定(PostgreSQL の max_connections と相談して決める)
sqlDB. SetMaxOpenConns ( 25 )
sqlDB. SetMaxIdleConns ( 10 )
sqlDB. SetConnMaxLifetime ( 30 * time.Minute)
sqlDB. SetConnMaxIdleTime ( 5 * time.Minute)
return db, nil
}
TranslateError: true を有効にすると、GORM がドライバ固有のエラーを gorm.ErrDuplicatedKey などの共通エラーへ変換してくれます。先ほどのリポジトリ層で文字列マッチに頼る範囲を減らせるため、新規プロジェクトでは必ず有効化しております。
テストはどこまで AI に任せられるか
サービス層のユニットテスト
テスト生成は Claude Code が最も得意とする領域の一つです。次のプロンプトが効果的です。
internal/service/user_service_test.go を作成してください。
- Register のテスト(正常系・重複エラー・無効なパスワード)
- Login のテスト(正常系・ユーザー未存在・パスワード不一致)
- リポジトリはモック(interface)を使用
- testify の assert を使用
// internal/service/user_service_test.go(抜粋)
package service_test
import (
" context "
" testing "
" github.com/stretchr/testify/assert "
" github.com/stretchr/testify/mock "
" github.com/yourusername/go-api/internal/domain "
" github.com/yourusername/go-api/internal/service "
)
// MockUserRepository はテスト用モックリポジトリ
type MockUserRepository struct {
mock . Mock
}
func ( m * MockUserRepository ) Create ( ctx context . Context , user * domain . User ) error {
args := m. Called (ctx, user)
return args. Error ( 0 )
}
func ( m * MockUserRepository ) FindByEmail ( ctx context . Context , email string ) ( * domain . User , error ) {
args := m. Called (ctx, email)
if args. Get ( 0 ) == nil {
return nil , args. Error ( 1 )
}
return args. Get ( 0 ).( * domain . User ), args. Error ( 1 )
}
// (他のメソッドも同様に実装)
func TestRegister_Success ( t * testing . T ) {
mockRepo := new ( MockUserRepository )
mockRepo. On ( "Create" , mock.Anything, mock. AnythingOfType ( "*domain.User" )). Return ( nil )
svc := service. NewUserService (mockRepo, testJWTManager (), testLogger ())
user, err := svc. Register (context. Background (), "test@example.com" , "SecurePass123!" , "テストユーザー" )
assert. NoError (t, err)
assert. NotNil (t, user)
assert. Equal (t, "test@example.com" , user.Email)
mockRepo. AssertExpectations (t)
}
func TestRegister_DuplicateEmail ( t * testing . T ) {
mockRepo := new ( MockUserRepository )
mockRepo. On ( "Create" , mock.Anything, mock.Anything). Return (domain.ErrUserAlreadyExists)
svc := service. NewUserService (mockRepo, testJWTManager (), testLogger ())
_, err := svc. Register (context. Background (), "existing@example.com" , "Pass1234!" , "ユーザー" )
assert. ErrorIs (t, err, domain.ErrUserAlreadyExists)
}
func TestLogin_WrongPassword ( t * testing . T ) {
// 事前に bcrypt.GenerateFromPassword で生成したハッシュを使用
hash := mustHash ( "CorrectPass123!" )
mockRepo := new ( MockUserRepository )
mockRepo. On ( "FindByEmail" , mock.Anything, "user@example.com" ). Return ( & domain . User {
Email: "user@example.com" , PasswordHash: hash,
}, nil )
svc := service. NewUserService (mockRepo, testJWTManager (), testLogger ())
_, err := svc. Login (context. Background (), "user@example.com" , "WrongPass!" )
assert. ErrorIs (t, err, domain.ErrInvalidCredential)
}
テストを実行します。
# 全テスト実行(データ競合検出付き)
go test ./... -v -race
# カバレッジ確認
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out
-race フラグは必ず付けることをおすすめします。Gin のハンドラーは並行に呼ばれるため、共有状態への不注意なアクセスがあれば、ここで早期に検出できます。
カバレッジの目標値は 70% 以上を目安にしておりますが、数字そのものより「ドメインエラーの分岐がすべてテストされているか」を重視しています。AI が生成するテストは正常系に偏りがちで、こちらから異常系のケースを列挙して依頼したときに初めて網羅されます。テストケースの設計は人間、実装は AI という分担が、現時点では最も品質が安定する組み合わせです。
Docker マルチステージビルド — scratch と distroless の使い分け
Go のシングルバイナリという特性を活かし、最小イメージを構築します。
# ─── Stage 1: ビルドステージ ───────────────────────────────────
FROM golang:1.25-alpine AS builder
WORKDIR /app
# モジュールキャッシュを活用するため先にコピー
COPY go.mod go.sum ./
RUN go mod download
# ソースコードをコピーしてビルド
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags= "-w -s" \
-o /bin/api \
./cmd/api
# ─── Stage 2: 実行ステージ ─────────────────────────────────────
FROM scratch
# CA 証明書(HTTPS 通信に必要)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# バイナリのみコピー
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT [ "/api" ]
FROM scratch を使うと最終イメージは約 15MB に収まります。Node.js ベースの API と比べて 1/10 以下のサイズです。
ただし scratch には割り切りが必要です。シェルが存在しないため docker exec でコンテナに入れず、タイムゾーンデータも DNS リゾルバ設定もありません。デバッグのしやすさを残したい場合は、Google が提供する distroless イメージが現実的な選択肢になります。
# デバッグ性を残したい場合の実行ステージ
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /bin/api /api
EXPOSE 8080
USER nonroot
ENTRYPOINT [ "/api" ]
私の使い分けは「外部 API を呼ばず、ログが充実している小さなサービスは scratch、それ以外は distroless の nonroot」です。イメージサイズの差は数 MB しかなく、non-root 実行がデフォルトで手に入る distroless の安心感が勝る場面は少なくありません。
開発環境は docker-compose で揃えます。
# docker-compose.yml(開発環境)
services :
api :
build : .
ports :
- "8080:8080"
environment :
DATABASE_URL : postgres://postgres:postgres@db:5432/go_api?sslmode=disable
JWT_SECRET : your-secret-key-change-in-production
depends_on :
db :
condition : service_healthy
db :
image : postgres:16-alpine
environment :
POSTGRES_USER : postgres
POSTGRES_PASSWORD : postgres
POSTGRES_DB : go_api
healthcheck :
test : [ "CMD-SHELL" , "pg_isready -U postgres" ]
interval : 5s
timeout : 5s
retries : 5
volumes :
- postgres_data:/var/lib/postgresql/data
volumes :
postgres_data :
depends_on に condition: service_healthy を指定している点が重要です。これがないと、PostgreSQL の起動完了前に API が接続を試みて失敗します。
GitHub Actions で CI/CD を組む
# .github/workflows/ci.yml
name : CI/CD Pipeline
on :
push :
branches : [ main , develop ]
pull_request :
branches : [ main ]
jobs :
test :
name : Test
runs-on : ubuntu-latest
services :
postgres :
image : postgres:16-alpine
env :
POSTGRES_USER : postgres
POSTGRES_PASSWORD : postgres
POSTGRES_DB : go_api_test
options : >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports :
- 5432:5432
steps :
- uses : actions/checkout@v4
- name : Go 環境セットアップ
uses : actions/setup-go@v5
with :
go-version : '1.25'
cache : true
- name : 依存関係インストール
run : go mod download
- name : リント(静的解析)
uses : golangci/golangci-lint-action@v6
with :
version : latest
- name : テスト実行
env :
DATABASE_URL : postgres://postgres:postgres@localhost:5432/go_api_test?sslmode=disable
JWT_SECRET : test-secret-key
run : |
go test ./... -v -race -coverprofile=coverage.out
go tool cover -func=coverage.out | tail -1
- name : カバレッジレポートアップロード
uses : codecov/codecov-action@v4
with :
files : ./coverage.out
build :
name : Docker Build
runs-on : ubuntu-latest
needs : test
if : github.ref == 'refs/heads/main'
steps :
- uses : actions/checkout@v4
- name : Docker Buildx セットアップ
uses : docker/setup-buildx-action@v3
- name : Docker Hub ログイン
uses : docker/login-action@v3
with :
username : ${{ secrets.DOCKER_USERNAME }}
password : ${{ secrets.DOCKER_TOKEN }}
- name : イメージビルド&プッシュ
uses : docker/build-push-action@v5
with :
context : .
push : true
tags : |
${{ secrets.DOCKER_USERNAME }}/go-api:latest
${{ secrets.DOCKER_USERNAME }}/go-api:${{ github.sha }}
cache-from : type=gha
cache-to : type=gha,mode=max
setup-go の cache: true と Buildx の GitHub Actions キャッシュ(type=gha)の2つを効かせると、2回目以降のパイプラインは体感で半分以下の時間になります。CI の待ち時間は開発のリズムを直接左右するため、キャッシュ設定は最初に整える価値があります。
この workflow ファイル自体も Claude Code に生成させられますが、シークレット名(DOCKER_USERNAME 等)はリポジトリ側の設定と一致させる必要があります。生成された YAML を貼って終わりにせず、Settings → Secrets の実際の登録名と突き合わせてください。
つまずきやすいエラーと対処
gorm: record not found が意図せず返る
原因 : GORM の First() はレコードが存在しない場合に ErrRecordNotFound を返しますが、Find() は空のスライスを返してエラーになりません。用途に合わない使い分けが混乱のもとになります。
対処 : 1件取得には First() を使い、必ず errors.Is(err, gorm.ErrRecordNotFound) で判定します。
// ✅ 正しい使い方
var user domain . User
if err := db. WithContext (ctx). First ( & user, "id = ?" , id).Error; err != nil {
if errors. Is (err, gorm.ErrRecordNotFound) {
return nil , domain.ErrUserNotFound
}
return nil , fmt. Errorf ( "DB 取得エラー: %w " , err)
}
JWT の token is expired が頻発する
原因 : サーバー間で時刻がずれていると、ExpiresAt の検証に失敗します。コンテナ環境でも起こり得る問題です。
対処 : 時刻同期を確認した上で、leeway(許容誤差)を設定して軽減します。
// leeway 30 秒を許容
token, err := jwt. ParseWithClaims (tokenStr, & Claims {},
keyFunc,
jwt. WithLeeway ( 30 * time.Second),
)
bind: address already in use
原因 : 前のプロセスがポートを占有しています。開発中の再起動で起きやすいエラーです。
対処 : ポートを使用しているプロセスを特定して終了します。
# 8080 ポートを使用しているプロセスを確認
lsof -ti:8080
# プロセスを終了
lsof -ti:8080 | xargs kill -9
scratch イメージで外部 HTTPS 通信が失敗する
原因 : FROM scratch には CA 証明書が含まれていません。x509: certificate signed by unknown authority というエラーが出たらこれを疑ってください。
対処 : 本稿の Dockerfile のように、ビルドステージから /etc/ssl/certs/ca-certificates.crt をコピーします。ローカルの Docker では再現せず、本番だけで失敗することがあるため、見落とすと調査に時間を取られます。
Claude Code × Go 開発で効くプロンプトの型
エラーハンドリングをまとめて生成する
Go では全ての関数がエラーを返すため、ハンドリングが冗長になりがちです。変換規則を明示して一括生成させると、一貫性が保てます。
以下の仕様に従い、service 層の全メソッドのエラーハンドリングを
ドメインエラーへの変換を含む形で実装してください:
- gorm.ErrRecordNotFound → domain.ErrUserNotFound
- 重複エラー → domain.ErrUserAlreadyExists
- その他 → fmt.Errorf でラップしたエラー
テーブル駆動テストを依頼する
Go コミュニティの慣習であるテーブル駆動テストは、明示的に指定しないと生成されないことがあります。
user_handler_test.go に、以下のエンドポイントのテーブル駆動テストを
作成してください(httptest.NewRecorder を使用):
- POST /api/v1/auth/register
- 正常系(201)
- 重複エラー(409)
- バリデーションエラー(400)
- 無効な JSON(400)
各テストケースを testCases スライスで管理してください。
マイグレーションファイルの自動生成
migrations/ フォルダに、以下のテーブルの SQL マイグレーション
(up/down)を作成してください:
- users(UUID 主キー、email unique、created_at/updated_at/deleted_at)
- 命名規則: 001_create_users_table.up.sql / .down.sql
down 側のマイグレーションまで揃えておくと、ステージング環境での切り戻しが一気に楽になります。AI はこうした「対になる成果物」を一度に生成するのが得意で、人間が手で書くと省略しがちな部分こそ任せる価値があります。
次の一歩 — 動くコードから運用に耐えるコードへ
ここまで、Claude Code と Go で REST API を本番品質に近づける工程を一通り歩いてきました。
振り返って強調したいのは、AI と人間の分担線です。プロジェクト構造・各層の実装・テストコード・Dockerfile・CI 設定の生成は、Claude Code に任せて差し支えありません。一方で、CLAUDE.md に書く規約の設計、エラー分岐とセキュリティ定石のレビュー、graceful shutdown やコネクションプールといった運用設定の判断は、人間が引き受けるべき領域として残ります。
次のステップとしては、本稿のコードをベースに /health エンドポイントを DB 死活確認付きの readiness probe へ拡張し、Kubernetes や各種 PaaS へのデプロイを試してみてください。graceful shutdown を先に組み込んであるため、ローリングアップデートがそのまま無停止で機能するはずです。
私はこの構成を自分の標準スタックとして使い続けていますが、Go の素直さと Claude Code の生成力は、堅実に噛み合う組み合わせだと感じております。みなさまのプロジェクトの参考になれば幸いです。