こんにちは、かつコーチです。
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, 上級者向け, 設計