🕌

gorm をなるべく隠蔽した Repository 実装と DB ラッパの構成

2025/03/22に公開

はじめに

gorm は強力な ORM だが、その機能をそのままアプリケーション層で使い続けると、テストの困難さや責務の分散などが問題になる。
gorm を使いながらも、インフラの詳細に依存しない形で Repository を設計した。
ここでは EmailVerification というモデルを題材に、Repository 層と DB ラッパー層の分離設計について紹介する。
なお、本稿のパターンを採用したアプリケーションを OSS として公開しているので、必要に応じてそちらを参考にしてもよい。
https://github.com/mickamy/sampay/

全体構成

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 特有の構文やオプションをラッパーで隠蔽することで、ビジネスロジックとの依存を最小限に抑えられる。

WriterReader を合成した ReadWriter で読み書き分離にも対応できる。

Scope の抽象化

type Scope func(db *DB) *DB

GORM の Scopes を使う際に、ビジネスロジック側では Scope 型で扱い、GORM に渡すときだけ .Gorm() に変換する。
これにより、呼び出し側は GORM を意識せず柔軟にスコープを指定できる。

Scopes(scopes).Gorm()

この設計により、Repository インターフェースに用途ごとの検索条件(例:FindUnverifiedFindValidRequested など)をメソッドとして追加する必要がなくなる。
代わりに、利用側が Scope を合成・注入することで振る舞いを調整でき、インターフェースの肥大化を防げる。
柔軟性を維持しつつ、実装の共通化と責務の明確化を実現している。
これについて別に記事を書いたので、詳細はそちらを参照されたい。
https://zenn.dev/mickamy/articles/06612aade1bf64

実装例: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