2026/05/07

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM) golang gin gorm clean architecture api folder structure

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を埋め込むとIDCreatedAtUpdatedAtDeletedAt(ソフトデリート用)が自動で付いてくるので、基本的にはこれを使っている。

// 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.localDATABASE_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/secretsmyapp/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)