【Ruby on Rails】Fat Controller/Fat Modelを避けるためのService Objectパターン

Ruby

こんにちは、かつコーチです。

Railsで開発を続けていると、コントローラやモデルがどんどん肥大化していく問題に必ず突き当たります。

いわゆるFat Controller(処理を詰め込みすぎたコントローラ)やFat Model(責務が多すぎるモデル)です。

今回は、この肥大化を防ぐための代表的な設計パターンであるService Objectを、Before/Afterのリファクタリング例つきで解説します。

Fat Controller/Fat Modelが起きる原因

コントローラにビジネスロジックを書いてしまう

Railsのscaffoldは便利ですが、そのまま拡張していくと危険です。

在庫確認・決済処理・メール送信・ポイント付与のようなビジネスロジック(業務上の判断や処理の流れ)を、ついcreateアクションに直接書いてしまいがちです。

アクション1つに複数の関心事が混在すると、テストが書きにくくなり、変更の影響範囲も読みにくくなります。

モデルに全部詰め込んでしまう

「コントローラを薄くしよう」という意識だけが先行すると、今度はモデルに処理が集中します。

Orderモデルに注文処理・在庫更新・通知送信のロジックがすべて集まると、モデルが数百行に膨れ上がります。

こうなると、注文モデル本来の責務である「注文データの整合性を守ること」が見えにくくなってしまいます。

原因は「複数モデルにまたがる処理の置き場所がない」こと

Fat Controller/Fat Modelの根本原因は、複数のモデルにまたがる処理の置き場所が、標準のMVCには用意されていない点にあります。

注文作成なら「注文の作成」「在庫の減算」「メール送信」「ポイント付与」という複数の責務が1つの操作にまたがります。

これらをどこに書くべきか迷った結果、手近なコントローラやモデルに書いてしまうというのが典型的な流れです。

Service Objectでビジネスロジックを分離する

Service Objectとは

Service Objectとは、1つのユースケース(例:注文を確定する)を1つのクラスとして切り出す設計パターンです。

Railsに標準の仕組みはありませんが、app/servicesディレクトリを作り、.callメソッドを1つ持つクラスとして実装するのが定番のスタイルです。

# app/services/order_creation_service.rb
class OrderCreationService
  class OutOfStockError < StandardError; end

  def initialize(user:, cart:)
    @user = user
    @cart = cart
  end

  def call
    ActiveRecord::Base.transaction do
      order = build_order
      decrement_stock!
      order.save!
      OrderMailer.confirmation(order).deliver_later
      order
    end
  rescue OutOfStockError => e
    Rails.logger.warn("在庫不足で注文失敗: #{e.message}")
    raise
  end

  private

  def build_order
    @user.orders.build(total_price: @cart.total_price, items_attributes: @cart.items_attributes)
  end

  def decrement_stock!
    @cart.items.each do |item|
      item.product.with_lock do
        raise OutOfStockError, item.product.name if item.product.stock < item.quantity

        item.product.decrement!(:stock, item.quantity)
      end
    end
  end
end

呼び出し側のコントローラは、Service Objectの.callを呼ぶだけのシンプルな形になります。

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    order = OrderCreationService.new(user: current_user, cart: current_cart).call
    redirect_to order, notice: "注文が完了しました"
  rescue OrderCreationService::OutOfStockError => e
    redirect_to cart_path, alert: "在庫が不足しています: #{e.message}"
  end
end

Before/Afterで見るリファクタリング

❌ Before:コントローラに複数の責務が混在している

class OrdersController < ApplicationController
  def create
    order = current_user.orders.build(order_params)

    ActiveRecord::Base.transaction do
      current_cart.items.each do |item|
        if item.product.stock < item.quantity
          redirect_to cart_path, alert: "在庫が不足しています" and return
        end
        item.product.decrement!(:stock, item.quantity)
      end
      order.save!
    end

    OrderMailer.confirmation(order).deliver_later
    redirect_to order, notice: "注文が完了しました"
  end
end

このコードは動きますが、在庫チェックのロジックをテストしたいだけなのに、リクエストとレスポンスを含むコントローラのテストが必要になります。

✅ After:Service Objectに処理を委譲する

先ほどのOrderCreationServiceを使えば、コントローラは呼び出しとエラーハンドリングだけに専念できます。

ビジネスロジックが独立したクラスになるため、リクエストを介さずに単体テストできる点が大きなメリットです。

# spec/services/order_creation_service_spec.rb
RSpec.describe OrderCreationService do
  it "在庫が不足している場合はOutOfStockErrorを発生させる" do
    product = create(:product, stock: 0)
    cart = create(:cart, :with_item, product: product)

    expect {
      described_class.new(user: cart.user, cart: cart).call
    }.to raise_error(OrderCreationService::OutOfStockError)
  end
end

よくあるつまずきポイント・エラー対処

トランザクション漏れで在庫だけ減ってしまう

私が実際にハマったのが、トランザクションの範囲を誤ったケースです。

❌ Before:トランザクションの外で在庫を減らしてしまう

def call
  decrement_stock!
  ActiveRecord::Base.transaction do
    order = build_order
    order.save!
  end
end

このコードでは、在庫減算後にorder.save!ActiveRecord::RecordInvalidで失敗すると、在庫だけが減って注文が作られない状態になります。

本番相当のステージング環境で確認したところ、実際に在庫数だけが減り、注文一覧には何も表示されないというデータ不整合が発生しました。

✅ After:一連の処理を1つのトランザクションにまとめる

先ほどの実装例の通り、注文の作成・在庫減算・保存までをActiveRecord::Base.transactionの中に収めます。

こうすることで、途中で例外が発生した場合はすべての変更がロールバックされ、データの整合性が保たれます。

まとめ

この記事のポイント

  • Fat Controller/Fat Modelは、複数モデルにまたがる処理の置き場所がないことが原因で発生する
  • app/servicesにService Objectを切り出し、.callメソッド1つでユースケースを表現する
  • コントローラはService Objectの呼び出しとエラーハンドリングに専念させる
  • 複数のDB更新をまたぐ処理は、必ず1つのトランザクションにまとめる

次に読むべき記事

→ 次の記事:Concern(関心の分離)の使い方

タグ: Ruby on Rails, 上級者向け, 設計

タイトルとURLをコピーしました