サブクラスを用いたFatModel対策

はじめに

Railsでアプリを開発するにあたり切っても切り離せない問題がFatModel問題です。ビジネスロジックをモデルに実装するのが定石ですが、実装するにつれてモデルが肥大化してしまいます。

この問題を解決するためにはモデル内の機能を切り分ける必要があります。ServiceクラスやPORO等さまざまな切り分け方がありますが、今回はその中の一つ、サブクラスをご紹介します。

実行環境

この記事は以下の動作環境で動作確認しています。

  • ruby (2.6.5)
  • rails (6.0.2)

具体例

概要

本記事では、具体的なサンプルアプリを例にしてサブクラスの紹介をします。

今回のサンプルアプリは書籍管理アプリです。書籍の情報をデータベースに登録し、ユーザー間の貸し借りができるような機能を持っています。

書籍情報に関する機能を持つBookモデルについて、サブクラスを用いて機能の切り分けをしていきます。

書籍情報更新の際のバリデーション

Bookモデルにはいくつかのカラムがあり、それらを更新する機能が実装されています。
カラムの値を更新する際は、その本を借りているユーザーがいるかどうかをチェックする必要があります。本が貸し出し状態の場合エラーを返すというバリデーションが実装されています。

class Book < ApplicationRecord
  belongs_to :user
  has_one :rental # 貸し出し状態を表す中間テーブル
  has_one :borrower, through: rental, source: :user
  has_many :likes # お気に入り登録状態を表す中間テーブル
  has_many :liked_user, through: likes, source: :user

  validates :name, presence: true
  validate :should_be_available, on: :update
  
  def should_be_available
    errors[:base] << '貸し出し中は更新できません' if self.borrower.present?
  end
end

ところがこのように実装すると、貸出中の本を他の誰かがお気に入り登録したとき、以下のようにバリデーションが通らなくなります。

def create
  book = Book.find_by(id: params[:id])
  book.likes.build(user: current_user)
  if book.save # ここでfalseが返ってくる
  ︙

お気に入り登録機能は、貸し出し状態に関係なく登録・更新したいです。

book.rb 内で上記のロジックを実現するためには、バリデーションをするかしないかを判定する条件分岐を入れる必要があります。

これだけの実装ならまだしも、

  • 貸し出し状態に関係なく値を更新したいカラムがある
  • 貸し出し中は更新したくないような中間テーブルがある

というような要件が増えると、バリデーションの条件分岐がどんどん複雑になってきます。
複雑な条件分岐が一つのモデルファイルにどんどん詰め込まれることで、FatModelになってしまいます。

サブクラスによる機能分離

サブクラス定義

この問題の原因は複雑なロジックを1つのファイルに詰め込もうとしていることです。
モデルをサブクラスに分割することで、条件に応じてクラスを使い分け、モデルをスッキリさせることができます。

今回は、貸し出し状態をチェックするクラスBooks::AvailabilityCheckerをBookのサブクラスとして作成します。

作成したサブクラスの方にバリデーションを実装し、元のBookモデルからバリデーションを削除します。

active_type というgemを導入することで、以下のように定義することができます。

class Books::AvailabilityChecker < ActiveType::Record[Book]     
  validate :should_be_available, on: :update                    

  private

    def should_be_available
      errors[:base] << '貸し出し中は更新できません' if self.borrower.present?
    end                                                         
end     

サブクラスの使い方

Books::AvailabilityCheckerはBookクラスを継承したクラスです。したがって、Bookを扱うときと同じやり方でBooks::AvailabilityCheckerを扱うことができます。

以下がコマンド例です。

def update
  @book_availability_checker = Books::AvailabilityChecker.find_by(id: params[:id]) # Bookモデルから、指定したidを持つレコードを取得
  @book_availability_checker.assign_attributes(name: 'new_book_name') # カラム値更新
  if @book_availability_checker.save # 貸出中の場合、ここでfalseが返される
  ︙ 

このように、貸し出し状態をチェックしたい場合はBooks::AvailabilityCheckerを、チェックしたくない場合はBookを使うことで、機能の切り分けができるようになりました。

これらの操作を、該当するコントローラや別のモデル(またはそのサブクラス)に実装していきましょう

おわりに

サブクラスを定義することで、機能の切り分けができるようになります。

今回ご紹介したのはバリデーションに関する切り分けでしたが、特定のメソッド、コールバックを実行するためのサブクラスも同様に作成することができます。

サブクラスをうまく利用して、スリムなモデルを目指してみてください。