🌊

gorm を使ったリポジトリ層の実装テクニックについて。あるいは便利な gorm.Scope

2025/01/16に公開

きらぼしシステム株式会社でエンジニアをしている mickamy です。
今期の目標で、当社のプレゼンスを高める、というものをおいたので、開発を行う上で得た知見をご紹介したいと思います。
以下、都合により言い切り(ですます調にあらず)です。


当社のプロダクトの一部では DB アクセスに ORM の gorm を利用し、それをラップした repository を use case で利用している。

その際、ちょっとした実装上のテクニックを使うことで、repository の実装が極めてシンプルで種々の使用に耐える柔軟なものになるので、本稿ではそれについて紹介する。

1. 不満

以下のようなリレーションがある素朴なモデルを考えてみる。

type Authentication struct {
	Email          string
	HashedPassword string
}

type User struct {
	ID             string
	Authentication Authentication
}

これを取得する repository を作ると、以下のようになる

type UserRepo struct {
	db *gorm.DB
}

func (repo UserRepo) Get(id string) (User, error) {
	var m User
	err := repo.db.First(&m, "id = ?", id).Error
	return m, err
}

さて、この AuthenticationRepo#Get で取得される UserAuthentication フィールドはゼロ値であるが、User#Authentication を取得したい場合を考えてみよう。

素朴に考えると、以下のように別の func を定義することになる。

func (repo UserRepo) GetWithAuthentication(id string) (User, error) {
	var m User
	err := repo.db.First(&m, "id = ?", id).Joins("Authentication").Error
	return m, err
}

これが一つならこれでいいが、他にもいくつものリレーションが貼られたモデルを取得する場合はどうだろうか?

ロジックを実装するたびに、そこで必要なリレーションを取得する関数を書いていくのだろうか?(func GetForSignIn, func GetForSignUp, func GetForXXX のように? その関数がどのフィールドの値を満たすものかを知るためには、いちいち repository のコードを読みにいくのだろうか?)

もちろんそういった選択肢はありうるが、ロジックの都合が repository 層にまで影響することを筆者は好まない。もっと良い方法はないだろうか?

2. gorm の Scope について

gorm.Scope は、共通のロジックを切り出すために使用されるものである。以下ドキュメントから抜粋する。

https://gorm.io/ja_JP/docs/scopes.html

Scopes を利用することで、共通で使用されるロジックを再利用することができます。
共有ロジックは func(*gorm.DB) *gorm.DB という型として定義します。

よくわからないのでサンプルのコードを見てみよう。

func AmountGreaterThan1000(db *gorm.DB) *gorm.DB {
  return db.Where("amount > ?", 1000)
}

func PaidWithCreditCard(db *gorm.DB) *gorm.DB {
  return db.Where("pay_mode = ?", "card")
}

func PaidWithCod(db *gorm.DB) *gorm.DB {
  return db.Where("pay_mode = ?", "cod")
}
db.Scopes(AmountGreaterThan1000, PaidWithCreditCard).Find(&orders)
// Find all credit card orders and amount greater than 1000

つまり、*gorm.DB を食って *gorm.DB を返す関数を定義することで、gorm 側がそれをクロージャ的に使い、任意の操作を共通化してくれるということだ。

これを利用することで、先の不満を解決したい。

3. Scope の利用

まず、以下のように Scope 型を定義する(repository より上の層に、gorm への依存を伝播させないため)。

type Scope = func(*gorm.DB) *gorm.DB

これを使って、先の repository を以下のように定義する。

func (repo UserRepo) Get(id string, scopes ...Scope) (User, error) {
	var m User
	err := repo.db.Scopes(scopes...).First(&m, "id = ?", id).Error
	return m, err
}

可変長引数として定義している scopes に注目されたい。こうすることで、任意の Scope を任意の数だけ指定できることになる。

Scope の定義と実際にクエリするコードを見てみよう。

func UserJoinAuthentication(db *gorm.DB) *gorm.DB {
	return db.Joins("Authentication")
}

func main() {
	repo := UserRepo{db: NewDB()}
	userWithAuth, _ := repo.Get("1", UserJoinAuthentication)
	userWithoutAuth, _ := repo.Get("1")
}

このように、たった一つの定義で authentications テーブルをジョインするものとそうでないもの、二つの要求を満たす関数が定義できた。

呼び出す側も、リレーションについてどれがゼロ値でどれに値が入っているかを気にする必要はない。なぜなら、まさにその呼び出し元でどのテーブルを join すべきかを指定しているのだから。

総括

gorm の Scope を使うことで、リレーションを管理するコードが非常にシンプルかつ柔軟に書けるようになる。特定のフィールドやリレーションを使いたい場所でのみ Scope を指定することにより、リレーション取得のための関数を無数に定義する必要がなくなる。さらに、Scope を使うことで、repository 層が上位層にロジックの重複をもたらすことなく、状況に応じて動的に対応できるようになる。

ただし Scope の利用には注意点もある。複雑なクエリや多数のリレーションが絡む場合、どの Scope がどのリレーションを参照するかの管理が必要になる。また、過剰に Scope を定義すると管理が煩雑になり、コードの読みやすさに影響する可能性がある。こうした点を踏まえ、Scope を効果的に使いこなすことで、維持管理しやすく、柔軟な repository 層を構築できる。

この実装方針を念頭に置けば、将来的な変更や新たな要件にも容易に対応できる構成が実現できる。

Discussion