Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active RecordでRepositoryパターンを実装する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

Rails: Active RecordでRepositoryパターンを実装する(翻訳)

Repositoryは本質的に、ドメインオブジェクトをその永続化方法から切り離して、それらにアクセスするための限定的なインターフェイスを提供します。Repositoryは戦術的なパターンの一種であり、本記事の導入部で私が説明したい内容よりずっと詳しくMartin FowlerEric Evansが解説しています。Repositoryパターンが推し進めるものは、いわゆるActive Recordパターンと真逆です。なぜパターンをわざわざ別のものに変換するのでしょうか?

Active Recordパターンは両刃の剣であり、その問題はまさに最大の長所から発しています。Active Recordパターンは、「個人起業家」が短期間で製品のプロトタイプを作り上げるときには大変有用であり、規律を守れる精鋭チームに適した柔軟性を提供してくれます。しかし、比較的大規模なレガシーアプリケーションに複数のチームが組織横断的に取り組んでいる場合は制御不能になってしまいます。

素のActiveRecord::Baseでは、350個のインスタンスメソッドがpublicインターフェイスで公開されます。ここに、よく使われるActiveRecord::Relationのメソッドが496個追加されます。このように大規模なActive Recordモデルで可能なすべての利用パターンをカバーする大規模なリファクタリングを実行するとなると悪夢です。以下はリファクタリングで最初に行うチェックリストの一部です。

  • 膨大なクエリAPIの調査
  • さまざまなコールバックの調査
  • リレーション、およびその拡張機能と従来の振る舞いの調査
  • GemfileでActiveRecord::Baseを拡張しているgemの調査
  • 追加されたメソッドや、変更された振る舞いの調査

これはカバーすべき重要な範囲です。お金を稼いでくれるproductionシステムに手を加えるには、それなりの時間とエネルギー、そして自信を必要とします。

以前私の同僚が、大規模なコードベースで表面化したActive Recordのカバー範囲を制御しようと何度か試みていたことが思い出されます。Active Recordの境界表現を支援するnot_activerecord gemや、読み取りに対応するさまざまなQuery Objectパターン(記事1記事2)がありました。

以前Adam PohoreckiがDRUG meetupで述べていた、20%の労力をかけてActive Recordを以下のような形に整えれば、Repositoryで80%のメリットを得られるという話も何となく思い出されます。

class Transaction
  def self.of_id(id)
    find(id)
  end

  def self.last_not_pending_of_user_id(user_id)
    where.not(status: "pending").where(user_id: user_id).order(:id).last
  end
end

この方法、すなわちActiveRecord::Baseのメソッドを"private"として扱い、アプリケーション固有のクラスメソッドだけを使ってモデルにアクセスするという方法は、チームの規律の高さに大きく依存しています。

以下は今回私が作成するRepositoryです。ここには、既にフレームワークにある外部依存関係は含まれていません。

class TransactionRepository
  class Record < ActiveRecord::Base
    self.table_name = "transactions"
  end
  private_constant :Record

  Transaction = Data.define(Record.attribute_names.map(&:to_sym))

  class << self
    def of_id(id)
      as_struct(Record.find(id))
    end

    def last_not_pending_of_user_id(user_id)
      as_struct(Record.where.not(status: "pending").where(user_id: user_id).order(:id).last)
    end

    private

    def as_struct(record)
      Transaction.new(**record.attributes.symbolize_keys)
    end
  end
end

このサンプルを少し分析してみましょう。

  1. このAPIを形成しているのは、 TransactionRepositoryとそのpublicメソッドです。ここには依存関係はなく、ライフサイクル内でステートを保持することもないので、メソッドはシングルトン上に存在します。これらはデータにアクセスする唯一の方法であり、露出は極めて限定的となっています。

  2. TransactionRepository::RecordはActive Recordのクラスになっています。この名前空間はRailsフレームワークの仕組みから「かけ離れている」ので、データベーステーブルをself.table_nameで指定しなければなりません。このRepository内ではRecordを利用することと、そこに機能を実装することが許されています。この定数はRepositoryの外からはアクセスできません。つまりカプセル化が達成されています。

  3. Repositoryクエリからの戻り値はイミュータブルな構造体になっています。ActiveRecord::RelationでもなければActiveRecord::Baseのインスタンスでもありません。

この方法に欠点はあるでしょうか?もちろんあります。他のどんな方法でもそうですが、何を選択し、何を捨てるかが腕の見せ所です。ある分野における利便性を犠牲にする代わりに、予測可能性とメンテナンス性を手に入れることになるので、効果があるかどうかは場合によりけりです。

Active Recordの膨大なAPI露出面積が最も重宝される場所は、ビューレイヤ、そしてその上に構築される多数のヘルパーですが、私たちの構成ではこうしたメリットを得られなくなります。これについては、ActiveModel::Namingの振る舞いをincludeすることで多少取り戻せるでしょう。

他にも方法はあるでしょうか?場合によっては、実行可能な選択肢にCQRS1、すなわち「モデルへの書き込みと読み出しを切り離す手法」も入ってくるかもしれません。書き込みと読み出しを異なる形で実装するときを考慮すると、読み出しをActive Recordに担当させるのが万全です。私の好みは、Railsの非正規化されたSQLデータベース上にRead Modelを実装する方法です。

関連記事

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。