GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)
Ginで新しいAPIを作り始めるとき、最初に迷うのがフォルダ構成だった。とりあえず動かすだけならmain.goに全部書いてしまえばいいんだけど、それをやると後で必ず後悔する。外部APIとの連携が増えてきたり、ハンドラが10本を超えたあたりで一気に読めなくなる。
今はClean Architectureの考え方をベースにpkg/以下をレイヤーごとに切る構成に落ち着いている。handler → usecase → repository/gatewayという一方向の依存で、内側の層が外側を知らない設計だ。DB操作にはGORM、外部サービスへの接続はgatewayパターンで抽象化している。これはPorts and Adaptersとも呼ばれる考え方で、ローカル環境ではgatewaymockに差し替えることで外部サービスなしに動かせる。
全体のディレクトリ構成
まず全体像から。
myapp/
├── main.go
├── Makefile
├── docker/
│ ├── docker-compose.yml
│ └── docker-compose.test.yml
├── .env.local
├── .env.staging
├── .env.production
├── pkg/
│ ├── handler/
│ │ ├── user_handler.go
│ │ └── item_handler.go
│ ├── gateway/
│ │ ├── db.go
│ │ ├── secrets.go
│ │ ├── payment_gateway.go
│ │ ├── payment_gateway_mock.go
│ │ ├── payment_gateway_test.go
│ │ ├── notification_gateway.go
│ │ ├── notification_gateway_mock.go
│ │ └── notification_gateway_test.go
│ ├── usecase/
│ │ └── user_usecase.go
│ ├── repository/
│ │ └── user_repository.go
│ ├── domain/
│ │ └── user.go
│ └── config/
│ └── config.go
├── migrations/
│ ├── 000001_create_users.up.sql
│ ├── 000001_create_users.down.sql
│ └── 000002_create_items.up.sql
│ └── 000002_create_items.down.sql
├── go.mod
└── go.sum
ルートのmain.goがエントリポイントで、ここでDIをまとめてGinを起動する。pkg/以下に全てのロジックが収まる構成で、DBも「外部リソースへの接続」としてgateway/に置く。Stripeや通知サービスへの接続と同じ扱いで、gateway/db.goがGORMの接続を返す。
pkg/handler — ルーティングとリクエスト処理
handler/はリクエスト・レスポンスの変換を担う。ルーティングはmain.goに書くので、ハンドラは処理だけに集中できる。ビジネスロジックはusecaseに渡すだけにする。
// pkg/handler/user_handler.go
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"myapp/pkg/usecase"
)
type UserHandler struct {
userUsecase usecase.UserUsecase
}
func NewUserHandler(uu usecase.UserUsecase) *UserHandler {
return &UserHandler{userUsecase: uu}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id := c.Param("id")
user, err := h.userUsecase.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
ハンドラごとにファイルを分けているので、user_handler.goが肥大化してきたらitem_handler.goのように追加するだけで済む。ルーティングはmain.goに集めているので、エンドポイントの一覧をそこで把握できる。
pkg/gateway — 外部サービスとの連携
外部APIや外部サービスとのやり取りはgateway/に閉じ込める。決済サービス、通知サービス、外部のマスターデータAPIなど、自分たちが管理していないシステムとの境界がここだ。
gatewayの中身はインターフェースと実装に分けておくと、テスト時にモックに差し替えやすい。
// pkg/gateway/payment_gateway.go
package gateway
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type PaymentGateway interface {
Charge(ctx context.Context, amount int, token string) error
}
type stripeGateway struct {
apiKey string
baseURL string
client *http.Client
}
func NewStripeGateway(apiKey string) PaymentGateway {
return &stripeGateway{
apiKey: apiKey,
baseURL: "https://api.stripe.com/v1",
client: &http.Client{},
}
}
func (g *stripeGateway) Charge(ctx context.Context, amount int, token string) error {
// Stripe APIへのリクエスト処理
_ = json.NewEncoder
_ = fmt.Sprintf
return nil
}
ここがgatewayと呼ぶ理由は、外の世界への「玄関口」として責務を明確にするためだ。usecaseはgatewayのインターフェースだけを知っていればよくて、裏でStripeを使おうがPayPayを使おうがusecaseは気にしない。外部サービスの乗り換えや追加がしやすい構造になる。
ローカル用のmock実装(_mockファイル)
ローカル環境では外部サービスを呼ばず、同じインターフェースを実装したmockが固定値を返す。ファイル名はpayment_gateway_mock.goのように_mockサフィックスをつけて本実装と並べておく。
// pkg/gateway/payment_gateway_mock.go
package gateway
import (
"context"
"log"
)
type mockPaymentGateway struct{}
func NewMockPaymentGateway() PaymentGateway {
return &mockPaymentGateway{}
}
func (m *mockPaymentGateway) Charge(ctx context.Context, amount int, token string) error {
log.Printf("[mock] PaymentGateway.Charge: amount=%d token=%s → success", amount, token)
return nil
}
本実装と同じパッケージに置くのでインターフェースをそのまま参照できる。main.goでenv値を見て差し替えるだけで、usecaseもhandlerも変更不要になる。
単体テストはGoの標準規則に従って_test.goサフィックスをつける。payment_gateway_test.goのように本実装ファイルと対応した名前にしておくと、どのファイルのテストか迷わない。テスト内でmockを使いたい場合は同パッケージのpayment_gateway_mock.goをそのまま参照できる。
// pkg/gateway/payment_gateway_test.go
package gateway_test
import (
"context"
"testing"
"myapp/pkg/gateway"
)
func TestMockPaymentGateway_Charge(t *testing.T) {
gw := gateway.NewMockPaymentGateway()
if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// main.go — gatewayの差し替え箇所
var paymentGw gateway.PaymentGateway
if cfg.Env == "local" {
paymentGw = gateway.NewMockPaymentGateway()
} else {
paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
}
pkg/domain — GORMモデルの定義
domain/にはGORMのモデル定義を置く。gorm.Modelを埋め込むとID・CreatedAt・UpdatedAt・DeletedAt(ソフトデリート用)が自動で付いてくるので、基本的にはこれを使っている。
// pkg/domain/user.go
package domain
import (
"context"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Name string `gorm:"not null"`
Email string `gorm:"uniqueIndex;not null"`
}
type UserRepository interface {
FindByID(ctx context.Context, id uint) (*User, error)
Create(ctx context.Context, user *User) error
}
リポジトリのインターフェースもここに定義しておく。これによってusecase/はdomain.UserRepositoryだけを知っていればよく、GORMの実装の詳細と切り離せる。
pkg/repository — GORMによるDB操作の実装
repository/がGORMを直接使う唯一の層で、ここ以外でGORMのコードは書かない。*gorm.DBを受け取って各メソッドを実装する。
// pkg/repository/user_repository.go
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"myapp/pkg/domain"
)
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByID(ctx context.Context, id uint) (*domain.User, error) {
var user domain.User
result := r.db.WithContext(ctx).First(&user, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return &user, result.Error
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
db.WithContext(ctx)でコンテキストを必ず渡す。タイムアウトやキャンセルをDB層まで伝播させるためで、省略するとリクエストがキャンセルされてもクエリが走り続ける。gorm.ErrRecordNotFoundはレコードが見つからない場合のエラーなので、errors.Isで判定してnilを返すか上位層に任せるかを決める。
ローカル環境のDB — docker-compose で用意する
ローカルではDBをDockerで立てる。docker/フォルダにdocker-compose.ymlをまとめて、ルートから-fで指定して起動する。
docker compose -f docker/docker-compose.yml up -d
# docker/docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
ports:
- "5432:5432"
.env.localのDATABASE_DSNはこのコンテナに向ける。
# .env.local の DATABASE_DSN
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable
pkg/gateway/db.go — GORM接続の初期化
DBは「外部リソースへの接続」なので、Stripeや通知サービスと同じくgateway/に収める。db.goがGORMの接続を生成して返すだけのシンプルな役割を持つ。
// pkg/gateway/db.go
package gateway
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("db connection failed: %w", err)
}
return db, nil
}
DB接続の生成だけに絞る。テーブル作成はgolang-migrateで管理するのでAutoMigrateは使わない。
migrations — golang-migrateでテーブル管理
テーブルのDDLはバージョン管理されたSQLファイルで管理する。golang-migrateはup/downのペアで変更を管理でき、適用済みのバージョンをDBに記録してくれる。CLIのインストール方法はREADMEに記載する。
ファイル名は連番_説明.up.sql/連番_説明.down.sqlの形式でmigrations/に置く。
-- migrations/000001_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;
マイグレーションはアプリ起動時に自動実行しない。Makefileのターゲットで明示的に実行する。migrate-up-testはテスト用コンテナ(port 5433)向けのターゲットで、テスト実行前に1回だけ手動で叩く。
# Makefile
DSN ?= $(DATABASE_DSN)
TEST_DSN ?= postgres://user:password@localhost:5433/testdb?sslmode=disable
migrate-up:
migrate -path migrations -database "$(DSN)" up
migrate-up-test:
migrate -path migrations -database "$(TEST_DSN)" up
migrate-down:
migrate -path migrations -database "$(DSN)" down 1
migrate-version:
migrate -path migrations -database "$(DSN)" version
migrate-create:
migrate create -ext sql -dir migrations -seq $(name)
テーブル定義を本番・開発・テストで一致させるために、どの環境でも同じSQLファイルをgolang-migrateで適用する。AutoMigrateはGORMのstructから推測してDDLを生成するので、意図しないカラム変更が起きる可能性がある。SQLを自分で書いて管理する方が変更の意図が明確になる。
pkg/config — 環境別の設定読み込み
機密情報(DB接続文字列・APIキーなど)はAWS Secrets Managerに登録して取得する。ポート番号やGinのモードなどの非機密値は環境ごとに1ファイルで管理する。各ファイルはその環境の非機密値を全て持ち、gitにコミットして問題ない内容だけ書く。
# .env.local ※gitにコミットしない(機密値を含む)
PORT=:8080
GIN_MODE=debug
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable
STRIPE_KEY=sk_test_xxxxxxxxxxxx
# .env.staging ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=debug
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/staging/secrets
# .env.production ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=release
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/production/secrets
AWS Secrets Managerへの接続は他の外部連携と同じくgateway/に置く。シークレットはJSON形式で登録しておき、取得後にパースして使う。
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
// pkg/gateway/secrets.go
package gateway
import (
"context"
"encoding/json"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)
type Secrets struct {
DatabaseDSN string `json:"DATABASE_DSN"`
StripeKey string `json:"STRIPE_KEY"`
}
func LoadSecrets(ctx context.Context, region, secretName string) (*Secrets, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
client := secretsmanager.NewFromConfig(cfg)
result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
})
if err != nil {
return nil, fmt.Errorf("failed to get secret: %w", err)
}
var secrets Secrets
if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil {
return nil, fmt.Errorf("failed to parse secret: %w", err)
}
return &secrets, nil
}
configはこのgatewayを呼び出してシークレットを取得し、Configに詰めて返す。Loadはcontextを受け取る形にして、タイムアウトを外から制御できるようにしておく。
// pkg/config/config.go
package config
import (
"context"
"fmt"
"os"
"github.com/joho/godotenv"
"myapp/pkg/gateway"
)
type Config struct {
Env string
DSN string
StripeKey string
Port string
}
func Load(ctx context.Context) (*Config, error) {
env := os.Getenv("APP_ENV")
if env == "" {
env = "local"
}
envFile := fmt.Sprintf(".env.%s", env)
_ = godotenv.Load(envFile)
// local は .env.local から直接読む(Docker上のDBを使うため AWS SM 不要)
if env == "local" {
return &Config{
Env: env,
DSN: os.Getenv("DATABASE_DSN"),
StripeKey: os.Getenv("STRIPE_KEY"),
Port: getEnvOrDefault("PORT", ":8080"),
}, nil
}
// staging / production は AWS Secrets Manager から機密値を取得
secrets, err := gateway.LoadSecrets(
ctx,
os.Getenv("AWS_REGION"),
os.Getenv("SECRET_NAME"),
)
if err != nil {
return nil, fmt.Errorf("failed to load secrets: %w", err)
}
return &Config{
Env: env,
DSN: secrets.DatabaseDSN,
StripeKey: secrets.StripeKey,
Port: getEnvOrDefault("PORT", ":8080"),
}, nil
}
func getEnvOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
localのときはAWS SMを呼ばずに.env.localから直接読む。DBなどのミドルウェアはDockerで用意するので、DATABASE_DSNにはDockerコンテナのホストを書けばいい。.env.localは機密値を含むので.gitignoreに追加してリポジトリに含めない。staging/productionは非機密値だけを各envファイルに書いてgitで管理し、機密値はAWS SMから取得する。シークレット名を環境ごとに分けておくと(myapp/staging/secrets、myapp/production/secrets)、環境を間違えてもシークレットが混ざらない。
pkg/usecase — ビジネスロジック
usecase/はビジネスロジックの層で、handlerからの入力を受けてrepositoryやgatewayを組み合わせて処理を行う。「ユーザーを作成する」という操作がバリデーション→DB保存→通知送信という手順なら、その流れをusecaseに書く。GORMの*gorm.DBはここには渡さない。repositoryのインターフェースだけに依存させる。
main.go でDIをまとめる
各レイヤーの依存関係はmain.goで一か所に集めて組み立てる。DIコンテナは使わず、素直にコンストラクタを呼んでいく方が小〜中規模のAPIでは読みやすいと感じている。
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"myapp/pkg/config"
"myapp/pkg/gateway"
"myapp/pkg/handler"
"myapp/pkg/repository"
"myapp/pkg/usecase"
)
func main() {
ctx := context.Background()
cfg, err := config.Load(ctx)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
gormDB, err := gateway.NewDB(cfg.DSN)
if err != nil {
log.Fatalf("failed to connect db: %v", err)
}
sqlDB, err := gormDB.DB()
if err != nil {
log.Fatalf("failed to get sql.DB: %v", err)
}
defer sqlDB.Close()
userRepo := repository.NewUserRepository(gormDB)
var paymentGw gateway.PaymentGateway
if cfg.Env == "local" {
paymentGw = gateway.NewMockPaymentGateway()
} else {
paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
}
userUsecase := usecase.NewUserUsecase(userRepo, paymentGw)
userHandler := handler.NewUserHandler(userUsecase)
r := gin.Default()
v1 := r.Group("/api/v1")
{
v1.GET("/users/:id", userHandler.GetUser)
v1.POST("/users", userHandler.CreateUser)
}
srv := &http.Server{
Addr: cfg.Port,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("server forced to shutdown: %v", err)
}
}
ルーティングをmain.goに書くことで、エンドポイントの一覧がここに集まる。依存の向きはhandler → usecase → repository/gatewayという一方向を保っている。
r.Run()ではなくhttp.Serverを使ってgoroutineで起動しているのは、graceful shutdownのためだ。SIGINT/SIGTERMを受け取ったらsrv.Shutdown()を呼ぶことで、処理中のリクエストが終わるのを待ってからサーバーを止める。タイムアウトは5秒にしているが、DBトランザクションが長い処理がある場合は調整が必要だ。sqlDB.Close()はdeferに積んでいるので、Shutdown()でリクエストが全て終わった後にDB接続が閉じられる。
テスト戦略 — DBは本物、外部APIはmock
層によってテストの方針を分けている。
repository層はテスト用の共通コンテナを使う。 DBをmockにすると「コードは通るけどSQLが壊れてる」という状況が発生する。カラム名のミス、トランザクションのバグ、マイグレーションとのズレはmockでは検出できない。テストごとにコンテナを起動するとGoの並列テストで大量のコンテナが立ち上がって負荷になるので、テスト専用コンテナを1つ起動して使い回す方が現実的だ。
# docker/docker-compose.test.yml
services:
db-test:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: testdb
ports:
- "5433:5432" # 開発用5432と競合しないようにずらす
# テスト前に一度だけ起動
docker compose -f docker/docker-compose.test.yml up -d
テストコードは TestMain でDB接続を1回だけ確立し、パッケージ内の全テストで使い回す。並列テスト間のデータ干渉はトランザクションのロールバックで防ぐ。
テストコンテナへのマイグレーションはアプリ起動時と同じ方針でAutoMigrateは使わない。テスト実行前にmake migrate-up-testを手動で実行してスキーマを整えておく。テーブル定義が本番と一致した状態でテストできるのが利点だ。
// pkg/repository/testmain_test.go
package repository_test
import (
"fmt"
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var testDB *gorm.DB
func TestMain(m *testing.M) {
dsn := "postgres://user:password@localhost:5433/testdb?sslmode=disable"
var err error
testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
fmt.Println("failed to connect test db:", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// pkg/repository/user_repository_test.go
package repository_test
import (
"context"
"testing"
)
func TestUserRepository_Create(t *testing.T) {
t.Parallel()
tx := testDB.Begin()
defer tx.Rollback() // テスト終了後にロールバックしてデータを残さない
repo := NewUserRepository(tx)
err := repo.Create(context.Background(), &domain.User{Name: "test", Email: "test@example.com"})
if err != nil {
t.Fatal(err)
}
}
gateway層(外部API)は_mock.goで固定値を返す。 単体テストで本物のStripeやSlackを叩くと、テストの実行頻度によってはレートリミットに引っかかったりAPIキーを消費したりする。最悪の場合アカウントがブロックされる。外部サービスはこちらがコントロールできないので、テスト時はmockを使って正常系・エラー系のレスポンスを固定値で再現する。
// pkg/gateway/payment_gateway_test.go
package gateway_test
import (
"context"
"testing"
"myapp/pkg/gateway"
)
func TestMockPaymentGateway_Charge(t *testing.T) {
t.Parallel()
gw := gateway.NewMockPaymentGateway()
if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
この方針を一言で言うと「自分たちが管理しているものは本物でテストし、管理できないものはmockで代替する」になる。
まとめ
- ローカルDBは
docker/docker-compose.ymlで管理。docker compose -f docker/docker-compose.yml up -dで起動 localは.env.localから直接読み込み、staging/productionはAWS SMから機密値を取得- ルーティングは
main.goに集約。エンドポイント一覧をひとつの場所で把握できる pkg/handler/はリクエスト/レスポンス変換のみ、ビジネスロジックは持たないpkg/domain/にGORMモデルとリポジトリインターフェースを定義、実装はrepositoryに分離するpkg/repository/がGORMを直接使う唯一の層。db.WithContext(ctx)は必ず通すpkg/gateway/は外部サービスとの境界。インターフェースで抽象化するとテストが楽になる- repositoryのテストは
docker-compose.test.ymlの共通コンテナを使う。並列テストの負荷を避けつつ本物のDBで検証 - gateway(外部API)のテストは
_mock.goで固定値を返す。レートリミット・APIブロック対策 pkg/gateway/db.goでGORM接続のみ管理。AutoMigrateは使わず、DDLはgolang-migrateのSQLファイルで管理する- テスト実行前に
make migrate-up-testでテストDBにスキーマを適用する。TestMain内でのマイグレーション実行はしない - ルートの
main.goで依存を組み立てるシンプルなDIにする - 依存の向きはhandler → usecase → repository/gatewayの一方向を保つ(Clean Architecture / Ports and Adapters)