はじめに (対象読者・この記事でわかること)
この記事は、GoでWeb APIやWebアプリケーションを開発し始めたばかりの方、あるいは「DB接続処理をどこに書けばいいか」に悩んでいる中級者の方を対象にしています。
特に「main.goにベタ書きしてしまったけど、いまいちスッキリしない」「クリーンアーキテクチャっぽく分けたいけど、インフラ層・ドメイン層・インターフェースの関係がイマイチ掴めない」という方に最適です。
記事を読み終えると、以下のことがわかります。
- DB接続を「どのレイヤーに置くと」テストしやすく、ビジネスロジックから切り離せるか
- 依存性注入(DI)を使って、本番用・テスト用のDBをスイッチする方法
- 実際のディレクトリ構成と、コードの配置のベストプラクティス
また、サンプルコードはGitHub ActionsでCI回している構成を想定しているため、CI回す前の段階でも手元で単体テストが通る状態を目指します。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Goの基本的な文法(構造体、インターフェース、メソッド)
- SQLドライバ(
database/sql)またはORM(GORM、ent、sqlcなど)のいずれかを使ったことがあること - レイヤードアーキテクチャ(またはヘキサゴナルアーキテクチャ)の用語くらいは聞いたことがあること
なぜ「DB接続をどこに置くか」で悩むのか
Goは標準ライブラリが小さく、フレームワークの縛りが少ない代わりに「どこに何を置けばいいか」が自明ではありません。特にDB接続は「最初の一歩」でmain.goにsql.Openとdefer db.Close()を書いてしまうことが多いですが、これだと以下の課題が出てきます。
- ビジネスロジックが
database/sqlに依存してしまい、単体テストでスタブを差しづらい - トランザクションを張るタイミングがコントローラ層に漏れて、ロジックが散在しがち
- リポジトリ実装が
sql.DBを直接意識してしまい、テスト時にインメモリDB(例:go-sqlmock)を差しづらい
これを解決するには、「DB接続そのもの」と「それを使う実装」を分離し、インターフェース越しに依存性注入する設計が必須です。
本記事では、実務で最も導入しやすい「3層+DI」構成を例に、ディレクトリ構成・コード配置・テスト戦略まで一貫して解説します。
クリーンアーキテクチャで考えるDB接続の配置
ディレクトリ構成の基本方針
以下は、個人的にプロジェクト作成時に真っ先に作るディレクトリ構成です。Goらしく「ドメイン言語」が上位に来るようにし、依存の方向が「外→内」になるようにしています。
github.com/yourname/app/
├── cmd/
│ └── api/ # エントリポイント(main.go)
│ └── main.go
├── internal/
│ ├── config/ # 環境変数や定数
│ ├── domain/ # ビジネスロジック(エンティティ・リポジトリIF)
│ ├── usecase/ # アプリケーションサービス(トランザクション境界)
│ ├── adapter/ # インフラ層(SQL実装、HTTPハンドラ)
│ └── registry/ # 依存性解決(DIコンテナ)
├── migrations/ # マイグレーションSQL
├── Makefile
├── docker-compose.yml
└── go.mod
インターフェースで抽象化する
DB接続を「どこに置くか」の答えは「インフラ層」に集約する一方で、ビジネスロジックがそれを直接見ないようにします。
具体的には、ドメイン層にリポジトリインターフェースを定義し、実装はadapter/repositoryに閉じ込めます。
Go// internal/domain/user/repository.go package user import "context" type Repository interface { Save(ctx context.Context, u *User) error FindByID(ctx context.Context, id string) (*User, error) }
トランザクション境界を明確にする
トランザクションを張るのはユースケース層の責務とし、リポジトリ実装はExecerインターフェース(sql.DBまたはsql.Tx)を受け取るようにします。これにより、単体テスト時にトランザクションを張らない、あるいはgo-sqlmockで置き換えやすくなります。
Go// internal/usecase/user_usecase.go package usecase import ( "context" "github.com/yourname/app/internal/domain/user" ) type UserUsecase struct { repo user.Repository tx Transaction // トランザクションを抽象化したインターフェース } func (u *UserUsecase) Register(ctx context.Context, cmd RegisterCommand) error { return u.tx.WithinTx(ctx, func(ctx context.Context) error { uu, err := user.New(cmd.Name) if err != nil { return err } return u.repo.Save(ctx, uu) }) }
DIコンテナでライフサイクル管理する
main.goでsql.Openしたコネクションを「どこまで渡すか」は、DIコンテナ(registryパッケージ)に集約するとスッキリします。
以下は、Wireを使わない手書きDIの例です。
Go// internal/registry/container.go package registry import ( "database/sql" "github.com/yourname/app/internal/adapter/repository" "github.com/yourname/app/internal/usecase" ) type Container struct { UserUsecase *usecase.UserUsecase } func NewContainer(db *sql.DB) *Container { userRepo := repository.NewUserRepository(db) tx := repository.NewTransaction(db) return &Container{ UserUsecase: usecase.NewUserUsecase(userRepo, tx), } }
main.goでは環境変数を読み込み、コネクションを張ったあとDIコンテナを生成するだけに留めます。
Go// cmd/api/main.go package main import ( "database/sql" "log" "net/http" "os" _ "github.com/go-sql-driver/mysql" "github.com/yourname/app/internal/config" "github.com/yourname/app/internal/registry" ) func main() { cfg := config.Load() db, err := sql.Open("mysql", cfg.DSN()) if err != nil { log.Fatalf("failed to open db: %+v", err) } defer db.Close() container := registry.NewContainer(db) mux := http.NewServeMux() mux.Handle("/users", container.UserUsecase) // 実際はハンドラにラップ log.Fatal(http.ListenAndServe(":8080", mux)) }
ハマりどころ1:import cycle
ドメイン層がdatabase/sqlを見ないようにすると、トランザクションを抽象化したTransactionインターフェースをdomainに置きたくなりますが、実装はインフラ層なのでdomainからadapterを見ると循環してしまいます。
解決策は「トランザクションインターフェースをusecaseパッケージに置く」か、別途domain/transaction.goを作り、どちらの層にも依存させないことです。
ハマりどころ2:テスト用のインメモリ置換
本番はMySQL、テストはSQLiteに差し替えたいケースも多いと思いますが、マイグレーションやDDLの違いで詰まりがちです。
私は「テストでも同じMySQLを使い、go-sqlmockでSQLアサーションする」か、Docker Composeでmysql:8を立ち上げてテストするようにしています。
GitHub Actionsではservices:でMySQLコンテナを起動しておけば、CIでも同じ戦略が使えます。
ハマりどころ3:コネクション数の管理
sql.DBはコネクションプールなので、基本的にアプリケーション全体で1インスタンスを使い回します。
ただし、マイグレーション用の高権限DSNと、アプリ用の低権限DSNを分けたい場合は、internal/adapter/migrationパッケージを作り、そこで別途sql.Openしても構いません。重要なのは「アプリのビジネスロジックが2つ以上のDBコネクションを意識しない」ことです。
まとめ
本記事では、「GoでDB接続処理をどこに書けばいいか」という悩みに対して、クリーンアーキテクチャの観点から以下の方針で解決しました。
- ドメイン層にリポジトリインターフェースを置き、実装は
adapter/repositoryに閉じ込める - トランザクション境界を
usecase層に明確にし、リポジトリはExecerインターフェースを受け取る main.goではsql.Openするだけに留め、DIコンテナ(registry)で依存性を解決- テスト時はgo-sqlmockまたはDocker Compose上のMySQLを使い、本番と同じインターフェースで置き換える
この設計を守ることで、ビジネスロジックがDBに依存せず、単体テストもCIも回しやすい構成が得られます。
次回は「この構成をGORMに置き換えるときの注意点」や「Wireを使った自動DI」について、より実践的な内容を深掘りします。
参考資料
- Go 公式ドキュメント – database/sql
- go-sqlmock – テスト用SQLモックドライバ
- 「クリーンアーキテクチャ」実践入門 – 翔泳社
- Go プログラミング実践入門 – 技術評論社
