Ruby on Rails

RailsのFat Model対策|1500行model.rbを分割する4手法と判断基準

1500行を超えたmodel.rbは、テストの遅さ・レビューの負荷・マージコンフリクトという形でチーム全体の開発速度を落とす負債です。本記事では、Rails 7系を前提に、concern・query object・form object・service object・POROという分割先の実装パターンと、「どの行数・どの症状になったらどれを使うか」の判断基準を、動作検証済みのコードつきで整理します。あわせて、全ロジックをservice objectへ逃がす「やりすぎリファクタリング」がなぜ別のアンチパターンになるのかも扱います。

目次

まとめ:行数ではなく症状で選ぶFat Model分割先と着手の順序

分割先の選定は「何行になったか」ではなく「何が起きているか」で決めます。検索条件の分岐が増えたならquery object、画面都合のバリデーションが混ざったならform object、複数モデルをまたぐ更新手続きが居座っているならservice object、複数モデルで同じ振る舞いを重複させているならconcern、テーブルに対応しない概念が数値や文字列のまま引き回されているならPOROが受け皿になります。

着手順は「読み取り系から」が原則です。query objectとPOROは既存の書き込みパスを変えないため、失敗時の影響範囲が小さく、1500行級のモデルでも安全に始められます。service objectへの移設はトランザクション境界の再設計を伴うため最後に回します。逃がした後のmodel.rbに関連・バリデーション・ドメインの計算だけが残っていれば成功、空っぽになっていたら逃がしすぎです。

Fat Controller対策の副作用として生まれるFat Modelの構造と実害

Fat Modelは怠慢の産物ではなく、「Skinny Controller, Fat Model」という正しい指針を長期間守り続けた結果として生まれます。まず構造を押さえます。

Skinny Controller方針の徹底が1500行の神クラスを生む経緯

Railsコミュニティでは、コントローラにビジネスロジックを書くFat Controllerを避け、ロジックをモデルへ寄せる方針が2006年頃から定着してきました。コントローラ側の指針としては今も有効で、アクションは「パラメータの解釈・モデルへの指示・レスポンスの選択」に絞るのが基本形です。

問題は行き先です。MVCの3層しか置き場がないと信じたまま数年運用すると、検索条件も帳票整形も外部API連携も決済手続きも、すべてが単一のモデルファイルへ集まります。Orderのような業務の中心にあるモデルほど吸引力が強く、気づけば1500行の「神クラス」になります。つまりFat Model対策とは、モデルを削ることではなく、app配下にモデル以外の置き場を増やすことです。

1500行超のmodel.rbで起きる実害:テスト時間・レビュー・コンフリクト

行数そのものより、行数が引き起こす症状が問題です。実務で観測しやすい実害を挙げます。

  • テストの肥大化:1つのモデルspecに数百ケースがぶら下がり、1ファイルの修正でも全ケースの再実行待ちが発生する
  • レビュー負荷:どの機能の変更でも同じファイルに差分が出るため、レビュアーが変更の影響範囲を特定できない
  • マージコンフリクト:複数チケットが同一ファイルを触るため、リリースのたびに手動マージが発生する
  • 暗黙の相互依存:コールバックとバリデーションが遠く離れた行のメソッドに依存し、修正の副作用が予測できない

目安となる数値も置いておきます。RuboCopのMetrics/ClassLengthはデフォルトで1クラス100行を上限とし、超過を警告する設定です。100行は厳しめの基準ですが、その15倍の1500行は、静的解析の想定を大きく外れた状態だと判断できます。

concern・query・form・service・POROの5つの分割先と実装パターン

分割先は大きく5種類です。それぞれ「何を引き受けるための箱か」が異なるため、実装パターンと引き受ける責務をセットで押さえます。掲載コードはRuby 3.2+ActiveRecord環境で実行し、動作を確認したものです。

concern:複数モデルで重複する横断的な振る舞いの共通化

concernはActiveSupport::Concernを使ったモジュールで、includedブロック内にスコープや関連を書ける点が素のmoduleとの違いです。向いているのは「論理削除」「公開状態の管理」「タグ付け」のように、複数のモデルが同じインターフェースで持つべき振る舞いです。

# app/models/concerns/discardable.rb
module Discardable
  extend ActiveSupport::Concern
 
  included do
    scope :kept, -> { where(discarded_at: nil) }
    scope :discarded, -> { where.not(discarded_at: nil) }
  end
 
  def discard!
    update!(discarded_at: Time.current)
  end
 
  def discarded?
    discarded_at.present?
  end
end
 
# app/models/order.rb
class Order < ApplicationRecord
  include Discardable
end

注意点が1つあります。concernは行数を別ファイルへ移すだけで、クラスの責務は減らしません。Orderにしか使わないロジックをOrderConfirmableのようなconcernへ切り出すと、見かけの行数は減りますが、実体は「分割された神クラス」のままです。2つ以上のモデルにincludeされる見込みがないなら、concernではなく後述のPOROかservice objectを選びます。検索キーワードとして「rails concern アンチパターン」が存在する程度には、この誤用は頻発しています。

query object:増殖する検索条件と絞り込みスコープの隔離先

スコープが数十個並び、絞り込み条件の組み合わせがモデル内のクラスメソッドで分岐し始めたら、query objectの出番です。1クエリ1クラスとし、callがActiveRecord::Relationを返すように作ると、呼び出し側でさらにチェーンできます。

# app/queries/orders/search_query.rb
class Orders::SearchQuery
  def initialize(relation = Order.all)
    @relation = relation
  end
 
  def call(status: nil, min_amount: nil, confirmed_from: nil)
    scope = @relation
    scope = scope.where(status: status) if status
    scope = scope.where("total_amount_cents >= ?", min_amount) if min_amount
    scope = scope.where("confirmed_at >= ?", confirmed_from) if confirmed_from
    scope.order(confirmed_at: :desc)
  end
end
 
# 呼び出し側(controller)
@orders = Orders::SearchQuery.new.call(status: "confirmed", min_amount: 1000)

Relationを返す設計にしておくと、ページネーションやeager loadを呼び出し側の都合で足せます。配列を返す設計はチェーンが切れるため不向きです。読み取り専用でロールバックの考慮が不要なぶん、1500行モデルの分割で最初に着手する対象として適しています。

form object:画面都合のバリデーションと複数モデル更新の受け皿

「検索フォームの入力チェック」「会員登録でUserとProfileを同時に作る」といった画面都合の関心事をテーブルと1対1のモデルに書くのが、肥大化の典型的な主因です。ActiveModel::ModelとActiveModel::Attributesを組み合わせると、テーブルなしでバリデーションと型変換を持つフォーム専用クラスを作れます。Rails 7.0からはActiveModel::Modelの実体がActiveModel::APIへ移されたため、最小構成ならActiveModel::APIのincludeでも同じことができます。

# app/forms/order_search_form.rb
class OrderSearchForm
  include ActiveModel::Model      # Rails 7以降は ActiveModel::API でも可
  include ActiveModel::Attributes
 
  attribute :status, :string
  attribute :min_amount, :integer
  attribute :confirmed_from, :date
 
  validates :min_amount, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
 
  def results
    return Order.none unless valid?
    Orders::SearchQuery.new.call(
      status: status,
      min_amount: min_amount,
      confirmed_from: confirmed_from
    )
  end
end

form_withにそのまま渡せるのがActiveModelを使う利点で、ビュー側の書き方はActiveRecordモデルと変わりません。「このバリデーションは全保存経路で守るべきか、この画面だけか」が振り分け基準になります。全経路で守るならモデルに残し、画面固有ならform objectへ移します。

service object:トランザクション境界を持つ複数モデル更新の手続き

「注文を確定し、在庫を引き当て、失敗したら全部戻す」のような複数モデルをまたぐ手続きは、どのモデルに書いても座りが悪く、Fat Model化の主犯になりがちです。1ユースケース1クラスのservice objectへ移し、トランザクション境界をクラス内で完結させます。

# app/errors/stock_shortage_error.rb
class StockShortageError < StandardError; end
 
# app/services/orders/confirm_service.rb
# (Inventoryは在庫テーブルに対応するActiveRecordモデル)
class Orders::ConfirmService
  Result = Struct.new(:success?, :order, :error, keyword_init: true)
 
  def initialize(order)
    @order = order
  end
 
  def call
    ActiveRecord::Base.transaction do
      reserve_stock!
      @order.update!(status: "confirmed", confirmed_at: Time.current)
    end
    Result.new(success?: true, order: @order)
  rescue ActiveRecord::RecordInvalid, StockShortageError => e
    Result.new(success?: false, order: @order, error: e.message)
  end
 
  private
 
  def reserve_stock!
    @order.order_items.each do |item|
      inventory = Inventory.lock.find_by!(item_name: item.item_name)
      raise StockShortageError, "#{item.item_name} の在庫不足" if inventory.stock < item.quantity
      inventory.update!(stock: inventory.stock - item.quantity)
    end
  end
end

戻り値を裸のtrue/falseではなくResult構造体にしておくと、呼び出し側のコントローラはresult.success?で分岐し、失敗理由をresult.errorから取れます。例外をservice objectの外へ漏らさない設計にすると、コントローラ側のrescueが不要になります。掲載コードは在庫不足時に注文ステータスと在庫数の両方がロールバックされることを確認済みです。

PORO・値オブジェクト:Railsに依存しないドメイン概念の抽出

POROはPlain Old Ruby Objectの略で、Railsの部品を継承もincludeもしない素のRubyクラスを指します。金額・期間・住所のように、テーブルは持たないが業務上の意味と振る舞いを持つ概念が、Integer やStringのままモデル中に散らばっているなら、値オブジェクトとして抽出します。

# app/models/money.rb(テーブルを持たないPORO)
class Money
  include Comparable
  attr_reader :cents
 
  def initialize(cents)
    @cents = Integer(cents)
  end
 
  def +(other)
    Money.new(cents + other.cents)
  end
 
  def <=>(other)
    cents <=> other.cents
  end
 
  def to_s
    "#{cents / 100}円"
  end
end

この抽出が効くのは、金額の加算や比較のロジックがモデル内の複数メソッドに重複しているケースです。ActiveRecordにはcomposed_ofという値オブジェクト連携の仕組みも標準で残っています。POROはDBもRailsも読み込まずに単体テストできるため、テスト実行時間の短縮効果が分割先の中で最も大きい選択肢です。

行数と症状で決める分割手法の選定基準と1500行model.rbの進め方

手法が5つあると「どれを使うか」で迷います。判断は症状から逆引きします。

RuboCopの100行基準から1500行までの症状別トリガー対応表

行数は精密な閾値ではなく点検のきっかけとして使います。目安と症状の対応を表にまとめます。

症状(トリガー) 行数の目安 分割先
スコープ・検索分岐の増殖 300行前後から query object
画面都合のバリデーション混入 行数不問 form object
複数モデル更新の手続き常駐 500行前後から service object
複数モデルで同じ振る舞い重複 行数不問 concern
単位・概念が生の数値で散在 行数不問 PORO・値オブジェクト

表の読み方として、行数列に「行数不問」が3つある点に注目してください。form object・concern・POROのトリガーは行数ではなく設計の歪みそのものです。一方でRuboCopデフォルトの100行を超えた段階では、まだ分割を急ぐ必要はありません。関連とバリデーションだけで100行を超えるモデルは健全に存在します。機械的に従うより、300行を超えたあたりで上表の症状が出ていないかを点検する運用が現実的です。

1500行model.rbを壊さず分割する5ステップの実務手順

1500行級の分割は一括では行いません。リリース単位を小さく保つ手順を示します。

  1. 計測と分類:メソッドを「読み取り・書き込み手続き・整形・横断的振る舞い」に色分けし、行数の内訳を把握する
  2. 特性テストの確保:分割対象のpublicメソッドに対する既存テストの有無を確認し、無い箇所には現状の挙動を固定するテストを先に足す
  3. 読み取り系の移設:query objectとPOROを先に抽出する。書き込みパスを変えないため、障害時の切り戻しが容易
  4. 書き込み系の移設:service objectとform objectへ手続きを移す。トランザクション境界が変わるため、1ユースケースずつリリースする
  5. 残置の確認:モデルに残ったのが関連・バリデーション・ドメインロジックだけかを確認し、RuboCopの閾値を現状値まで下げて再肥大を検知する

2番目の特性テストを飛ばすと、移設のdiffレビューで挙動の同一性を人力保証する羽目になります。1500行のモデルではそれは現実的に不可能です。テストを先に足す工数は、移設そのものより大きくなることも珍しくありませんが、削れない工程です。

service object乱立を招くやりすぎリファクタリングの失敗パターン

Fat Model対策の記事は分割を勧めて終わりがちですが、分割のしすぎは別のアンチパターンを生みます。この章は自戒を込めて独立させます。

全ロジックをservice objectへ逃がした先に待つドメインの空洞化

「迷ったらservice object」を数年続けたコードベースでは、app/servicesに数百クラスが並び、モデルは関連定義だけの抜け殻になります。この状態には名前がついていて、Martin Fowlerがドメインモデル貧血症(Anemic Domain Model)と命名したアンチパターンに該当します。データと振る舞いが分離し、オブジェクト指向の利点であるカプセル化を捨てて手続き型に回帰した状態です。

症状は具体的です。UpdateOrderService・OrderUpdateService・Orders::UpdaterServiceのような命名の揺れが放置され、どのserviceがどれを呼ぶのか誰も把握できなくなります。serviceがserviceを呼ぶ多段構造では、トランザクション境界がネストして「外側のrollbackで内側の更新が戻らない」類の不具合を招きがちです。1500行のモデル1つより、相互に呼び合う300個のserviceのほうが読解コストが高い、という逆転が実際に起こります。

service objectを採用すべきでない場面の判定条件と代替の選び方

ここは判断を言い切ります。次の条件に当てはまる処理をservice objectにしてはいけません。

  • 単一モデルの属性しか触らない処理:モデルのインスタンスメソッドに書く。Order#confirm!で足りるものにOrders::ConfirmServiceは過剰
  • 読み取りしかしない処理:query objectかPOROに書く。副作用がない処理にトランザクション境界の箱は不要
  • 1画面の入力検証だけの処理:form objectに書く。serviceにparamsを渡して検証させる構造は責務の取り違え

service objectが正当化されるのは「複数モデルをまたぐ」「トランザクション境界を1か所で管理したい」「外部APIやジョブ投入を伴う」のいずれかを満たすユースケースだけです。逆に言えば、この記事で扱った1500行モデルの中身の大半は、service objectではなくquery object・form object・POROに行き先があります。分割後にapp/servicesが最大勢力になっていたら、振り分けを間違えています。

よくある質問

Fat Model対策とRailsのレイヤー分割について、社内外から聞かれることの多い質問をまとめます。

concernと素のmoduleはどう違いますか?

ActiveSupport::Concernをextendしたモジュールは、includedブロック内でscope・validates・関連定義などのクラスマクロを書け、依存するconcern同士のinclude順も解決してくれます。素のmoduleで同じことをするにはincludedフックとclass_evalの組み合わせが必要です。インスタンスメソッドの共有だけが目的なら素のmoduleで足り、クラスレベルの定義を共有したい場合にconcernを選びます。

service objectの命名と置き場所の社内規約はどう決めるべきですか?

先に規約を1行で決めてから導入します。推奨は「app/services配下・リソース名の名前空間・動詞+Service・publicメソッドはcallのみ」です。例えばOrders::ConfirmServiceのような形です。この規約がないままserviceを増やすと、本文で触れた命名の揺れと多段呼び出しが数か月で発生します。既存プロジェクトに後から入れる場合も、新規作成分から規約を強制し、既存分はリネームのみのPRで段階的に寄せます。

Fat Modelの分割はどの単位でリリースすべきですか?

1クラスの抽出=1PR=1リリースが基本単位です。query objectを3つ作ったらまとめて出したくなりますが、切り戻しの単位が大きくなるだけで利点がありません。書き込み系(service object・form object)の移設は特に、1ユースケースずつ本番の挙動を確認してから次へ進みます。機能開発と同じブランチに分割リファクタリングを混ぜるのも、レビューと切り戻しの両方を難しくするため避けます。

gemを追加せずにform objectやquery objectを作れますか?

作れます。本記事のコードはActiveModel::Model・ActiveModel::Attributes・ActiveRecordというRails標準の部品だけで書いており、追加のgemは不要です。dry-rbやinteractorなどの整理用gemもありますが、まず標準部品で規約を固めてから、足りない機能が明確になった時点で導入を検討する順序を推奨します。

コールバックが多すぎるモデルはどう整理すべきですか?

before_saveやafter_commitが十数個並ぶモデルは、コールバックの中身を確認し、「そのモデル自身の整合性維持」以外をservice objectへ移します。属性の正規化や集計カラムの更新はモデルに残してよい候補です。他モデルの更新・メール送信・外部API呼び出しがコールバックに入っている場合は、保存のたびに暗黙実行される点が不具合の温床になるため、確定処理を担うservice objectへ明示的な呼び出しとして移すのが定石です。

関連記事

資料請求

RELATED POSTS 関連記事