はじめに
Ruby on Rails開発において、「Fat Controller」は避けるべきアンチパターンの一つです。本記事では、Fat Controllerがなぜ問題なのか、具体例を交えながら解説し、その解決策を提示します。
Fat Controllerとは
Fat Controller(太ったコントローラー)とは、本来はモデルやサービスオブジェクトが担うべきビジネスロジックをコントローラーに詰め込んでしまった状態を指します。
典型的なFat Controllerの例
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user_id = current_user.id
@order.order_number = generate_order_number
@order.status = 'pending'
# 在庫チェック
@order.order_items.each do |item|
product = Product.find(item.product_id)
if product.stock < item.quantity
flash[:error] = "#{product.name}の在庫が不足しています"
render :new and return
end
end
# 価格計算
subtotal = 0
@order.order_items.each do |item|
product = Product.find(item.product_id)
subtotal += product.price * item.quantity
end
# 割引計算
discount = 0
if current_user.premium_member?
discount = subtotal * 0.1
end
if @order.order_items.count >= 5
discount += subtotal * 0.05
end
tax = (subtotal - discount) * 0.1
@order.total_amount = subtotal - discount + tax
# 在庫を減らす
@order.order_items.each do |item|
product = Product.find(item.product_id)
product.update!(stock: product.stock - item.quantity)
end
if @order.save
# メール送信
OrderMailer.confirmation_email(@order).deliver_later
# 通知送信
NotificationService.notify_admin(@order)
# ポイント付与
points = (@order.total_amount * 0.01).to_i
current_user.update!(points: current_user.points + points)
redirect_to @order, notice: '注文が完了しました'
else
render :new
end
end
end
Fat Controllerの主な問題点
1. テストが困難
コントローラーに多くのロジックが含まれると、テストケースが複雑になり、テストの作成と保守が困難になります。
問題点:
- 複数の責務が混在しているため、単一の機能をテストすることが難しい
- モックやスタブが増え、テストコードが読みにくくなる
- 統合テストに頼らざるを得なくなり、テストの実行時間が長くなる
2. コードの再利用性が低い
ビジネスロジックがコントローラーに書かれていると、他の場所(APIエンドポイント、バックグラウンドジョブ、コンソールなど)から同じロジックを使いたい場合に、コードの重複が発生します。
例:
# Web UIでの注文処理
class OrdersController < ApplicationController
def create
# 注文処理のロジック(50行)
end
end
# API での注文処理(同じロジックを重複して記述)
class Api::V1::OrdersController < ApplicationController
def create
# 注文処理のロジック(50行)- ほぼ同じコード
end
end
3. 保守性の低下
コントローラーが肥大化すると、コードの理解が困難になり、バグの温床となります。
具体的な影響:
- 一つのアクションが100行を超えると、処理の流れを追うのが困難
- 新しい機能を追加する際に、既存のロジックへの影響を把握しづらい
- リファクタリングのリスクが高くなる
4. 単一責任の原則(SRP)違反
コントローラーの本来の責務は「HTTPリクエストを受け取り、適切なモデルやサービスに処理を委譲し、レスポンスを返す」ことです。ビジネスロジックを持つことは、この原則に反します。
5. 並行開発の妨げ
大きなコントローラーファイルは、複数の開発者が同時に作業する際にマージコンフリクトを起こしやすくなります。
解決策
1. モデルへのロジックの移動
計算ロジックやデータの整合性チェックは、モデルに移動します。
# app/models/order.rb
class Order < ApplicationRecord
has_many :order_items
belongs_to :user
before_create :set_order_number
before_save :calculate_total
validates :order_items, presence: true
validate :check_stock_availability
def calculate_total
self.total_amount = subtotal - discount + tax
end
def subtotal
order_items.sum { |item| item.product.price * item.quantity }
end
def discount
amount = 0
amount += subtotal * 0.1 if user.premium_member?
amount += subtotal * 0.05 if order_items.count >= 5
amount
end
def tax
(subtotal - discount) * 0.1
end
private
def set_order_number
self.order_number = "ORD-#{Time.current.strftime('%Y%m%d')}-#{SecureRandom.hex(4)}"
end
def check_stock_availability
order_items.each do |item|
if item.product.stock < item.quantity
errors.add(:base, "#{item.product.name}の在庫が不足しています")
end
end
end
end
2. サービスオブジェクトの導入
複雑なビジネスロジックは、専用のサービスオブジェクトに切り出します。
# app/services/order_creation_service.rb
class OrderCreationService
def initialize(user, order_params)
@user = user
@order_params = order_params
end
def call
ActiveRecord::Base.transaction do
create_order
reduce_stock
award_points
send_notifications
Result.success(@order)
end
rescue => e
Result.failure(e.message)
end
private
def create_order
@order = Order.new(@order_params)
@order.user = @user
@order.status = 'pending'
@order.save!
end
def reduce_stock
@order.order_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
end
def award_points
points = (@order.total_amount * 0.01).to_i
@user.increment!(:points, points)
end
def send_notifications
OrderMailer.confirmation_email(@order).deliver_later
NotificationService.notify_admin(@order)
end
class Result
attr_reader :order, :error
def self.success(order)
new(success: true, order: order)
end
def self.failure(error)
new(success: false, error: error)
end
def initialize(success:, order: nil, error: nil)
@success = success
@order = order
@error = error
end
def success?
@success
end
end
end
3. リファクタリング後のコントローラー
class OrdersController < ApplicationController
def create
result = OrderCreationService.new(current_user, order_params).call
if result.success?
redirect_to result.order, notice: '注文が完了しました'
else
@order = Order.new(order_params)
flash.now[:error] = result.error
render :new
end
end
private
def order_params
params.require(:order).permit(
order_items_attributes: [:product_id, :quantity]
)
end
end
4. その他の設計パターン
フォームオブジェクト: 複雑なフォーム処理には、フォームオブジェクトを使用します。
# app/forms/order_form.rb
class OrderForm
include ActiveModel::Model
attr_accessor :user, :product_ids, :quantities
validates :product_ids, presence: true
validate :validate_stock
def save
return false unless valid?
order = Order.create!(user: user)
product_ids.each_with_index do |product_id, index|
order.order_items.create!(
product_id: product_id,
quantity: quantities[index]
)
end
true
end
private
def validate_stock
# バリデーションロジック
end
end
Concernsの活用: 共通のコントローラーロジックは、Concernsに切り出します。
# app/controllers/concerns/order_processing.rb
module OrderProcessing
extend ActiveSupport::Concern
included do
before_action :check_user_cart, only: [:create]
end
private
def check_user_cart
redirect_to root_path, alert: 'カートが空です' if current_user.cart.empty?
end
end
ベストプラクティス
コントローラーの責務を明確にする
コントローラーは以下の責務のみを持つべきです:
- リクエストパラメータの取得と検証
- 認証・認可のチェック
- 適切なモデル・サービスへの処理の委譲
- レスポンスの生成(リダイレクトまたはビューのレンダリング)
目安となる指標
- 1アクションあたり10〜15行以内を目指す
- コントローラー全体で100行を超えたらリファクタリングを検討
- 複雑な条件分岐がある場合は、ポリシーオブジェクトやストラテジーパターンを検討
まとめ
Fat Controllerは、以下の理由から避けるべきアンチパターンです:
- テストが困難になる
- コードの再利用性が低下する
- 保守性が著しく低下する
- 単一責任の原則に違反する
解決策として:
- ビジネスロジックはモデルへ
- 複雑な処理はサービスオブジェクトへ
- フォーム処理はフォームオブジェクトへ
- 共通処理はConcernsへ
コントローラーはあくまで「交通整理役」として、薄く保つことを心がけましょう。これにより、テストしやすく、保守しやすい、拡張性の高いアプリケーションを構築できます。
コメント