gorm をなるべく隠蔽した Repository 実装と DB ラッパの構成
はじめに
gorm は強力な ORM だが、その機能をそのままアプリケーション層で使い続けると、テストの困難さや責務の分散などが問題になる。
gorm を使いながらも、インフラの詳細に依存しない形で Repository を設計した。
ここでは EmailVerification
というモデルを題材に、Repository 層と DB ラッパー層の分離設計について紹介する。
なお、本稿のパターンを採用したアプリケーションを OSS として公開しているので、必要に応じてそちらを参考にしてもよい。
全体構成
internal/
├── infra/
│ └── storage/
│ └── database/ // gorm ラッパー
├── domain/
│ └── auth/
│ ├── model/ // ドメインモデル
│ └── repository/ // Repository 実装
Repository はドメイン層の一部として domain/auth/repository
に配置する。
この配置により、ドメインロジックと密接に関係する永続化処理をドメインの一部として扱うことができる。
インフラに依存しないインターフェースとして設計し、実装のみが外部依存を持つ構成となる。
Repository インターフェース
EmailVerification
の Repository は以下のインターフェースを持つ。
type EmailVerification interface {
Create(ctx context.Context, m *model.EmailVerification) error
FindByEmail(ctx context.Context, email string, scope ...database.Scope) (*model.EmailVerification, error)
FindByRequestedTokenAndPinCode(ctx context.Context, token, pinCode string, scope ...database.Scope) (*model.EmailVerification, error)
FindByVerifiedToken(ctx context.Context, token string, scope ...database.Scope) (*model.EmailVerification, error)
Update(ctx context.Context, m *model.EmailVerification) error
WithTx(tx *database.DB) EmailVerification
}
gorm の直接的な使用はここでは一切見えないようにする。
WithTx()
メソッドはトランザクションコンテキストにおける Repository の再生成を可能にする。呼び出し側は gorm.DB
を直接扱う必要はなく、トランザクション中の DB をラップした状態で再構築された Repository を通じて一貫性のある操作を実現できる。これにより、gorm の Begin()
や Commit()
、Rollback()
の操作を Repository 利用側に漏らさず、責務を明確に分離できる。
gorm ラッパー構造 database.DB
type DB struct { *gorm.DB }
gorm の機能をカプセル化し、インフラ層で完結するよう設計。
トランザクション、ロック、スコープ、セッション設定などもここで吸収する。
type WriterTransactional interface {
WriterTransaction(ctx context.Context, f func(tx WriterTransactional) error) error
LockForUpdate() WriterTransactional
FullSaveAssociations() WriterTransactional
WriterDB() *DB
}
たとえば LockForUpdate()
は、gorm.Clauses(clause.Locking{Strength: "UPDATE"})
を呼ぶ内部実装を隠蔽しており、呼び出し側は gorm の API を意識することなく、単純なメソッド呼び出しで行ロックを実現できる。
このように GORM 特有の構文やオプションをラッパーで隠蔽することで、ビジネスロジックとの依存を最小限に抑えられる。
Writer
や Reader
を合成した ReadWriter
で読み書き分離にも対応できる。
Scope の抽象化
type Scope func(db *DB) *DB
GORM の Scopes
を使う際に、ビジネスロジック側では Scope
型で扱い、GORM に渡すときだけ .Gorm()
に変換する。
これにより、呼び出し側は GORM を意識せず柔軟にスコープを指定できる。
Scopes(scopes).Gorm()
この設計により、Repository インターフェースに用途ごとの検索条件(例:FindUnverified
、FindValidRequested
など)をメソッドとして追加する必要がなくなる。
代わりに、利用側が Scope を合成・注入することで振る舞いを調整でき、インターフェースの肥大化を防げる。
柔軟性を維持しつつ、実装の共通化と責務の明確化を実現している。
これについて別に記事を書いたので、詳細はそちらを参照されたい。
実装例:EmailVerification の検索
func (repo *emailVerification) FindByEmail(ctx context.Context, email string, scopes ...database.Scope) (*model.EmailVerification, error) {
var m model.EmailVerification
err := repo.db.WithContext(ctx).Scopes(database.Scopes(scopes).Gorm()...).
First(&m, "email = ?", email).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &m, err
}
まとめ
gorm を使いつつも、アプリケーション層にリークさせずに抽象化することで、
- テストしやすい
- トランザクション管理を統一できる
- 読み書き分離やロック戦略を一貫して扱える
- GORM のオプションや構文を意識せずに使える
というメリットが得られる。
インフラに依存せず、ビジネスロジックに集中できるコードベースを維持するために、このような分離は非常に効果的である。
Discussion