【Rails設計】Fat Controllerを回避する!Service層とConcernの活用法

Rails

はじめに

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. リクエストパラメータの取得と検証
  2. 認証・認可のチェック
  3. 適切なモデル・サービスへの処理の委譲
  4. レスポンスの生成(リダイレクトまたはビューのレンダリング)

目安となる指標

  • 1アクションあたり10〜15行以内を目指す
  • コントローラー全体で100行を超えたらリファクタリングを検討
  • 複雑な条件分岐がある場合は、ポリシーオブジェクトやストラテジーパターンを検討

まとめ

Fat Controllerは、以下の理由から避けるべきアンチパターンです:

  • テストが困難になる
  • コードの再利用性が低下する
  • 保守性が著しく低下する
  • 単一責任の原則に違反する

解決策として:

  • ビジネスロジックはモデルへ
  • 複雑な処理はサービスオブジェクトへ
  • フォーム処理はフォームオブジェクトへ
  • 共通処理はConcernsへ

コントローラーはあくまで「交通整理役」として、薄く保つことを心がけましょう。これにより、テストしやすく、保守しやすい、拡張性の高いアプリケーションを構築できます。

参考リソース

コメント

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