Fat Controllerの解消でService層を検討したが、Railsらしい設計を選んだ話
はじめに
Railsを学び始めて間もない私ですが、アンケート選択機能を実装していたら、コントローラーがどんどん長くなってしまいました。気がつくと100行を超えるようなメソッドができてしまい、読みにくさを感じ修正をしたいと思いました。
「Fat Controllerは良くない」ということは知っていたので、調べてみると「Service層を使えば解決可能」という記事をたくさん見つけました。早速実装を検討していたのですが、色々と調べているうちに、本当にService層が正解なのかいう疑問が湧いてきました。
問題の発生:コントローラーの肥大化
当初のコード(一部)
class SurveySelectionsController < ApplicationController
def index
@page = params[:page] || "select"
if @page == "select"
# アンケート選択画面用の変数セット
@categories = Category.all
@survey_types = SurveyType.all
@surveys = build_surveys_query
@selected_survey_ids = @event.survey_selections.pluck(:survey_id)
# ... 大量の変数定義
else
# 管理画面用の変数セット
@survey_selections = @event.survey_selections.includes(:survey, :event)
# ... さらに大量の変数定義
end
end
def create
survey_ids = selection_params[:survey_ids] || []
survey_ids.reject!(&:blank?)
existing_survey_ids = @event.survey_selections.pluck(:survey_id)
new_survey_ids = survey_ids.map(&:to_i) - existing_survey_ids
# ... 複雑な登録処理
end
private
def build_surveys_query
surveys = Survey.all
# カテゴリ・タイプ・キーワードでの絞り込み処理
# ... 長い条件分岐
end
end
感じていた課題
- 各アクションが長すぎる:1つのアクションに複数の責務が混在
-
条件分岐の複雑化:
@page
による画面切り替えロジック - 重複コード:似たような変数定義が複数箇所に
- テストしにくい:プライベートメソッドの単体テストが困難
最初のアプローチ:Service層の検討
考えていたService層設計
# 検討していたService層の分割案
class SurveySelectionCreator
def self.call(event, survey_ids)
# アンケート選択の登録処理
end
end
class SurveySelectionUpdater
def self.call(survey_selection, params)
# 状態更新処理
end
end
class SurveysQueryService
def self.call(params)
# アンケートの絞り込み処理
end
end
一見すると責務が分離され、テストもしやすそうに見えます。しかし、Rails設計について調べていく中で「Service層はRailsにおいてはアンチパターンになりうる」という考え方があることを知り、非常に驚きました。
Service層が適さない理由
調べてみると、以下のような理由でService層の導入には慎重になるべきことがわかりました。
Railsの設計思想との乖離
Railsは層(レイヤー)分割を減らして、書くコード量を減らす設計になっています。モデルを例に挙げると、ORマッパー、バリデーション、コールバック、フォームオブジェクト、ビジネスロジック置き場といろんな機能をあわせ持っています。
結合による生産性向上
- 適切な結合:重複コードを削減し、コード量を減らす
- 登場人物の削減:クラス・ファイル数を減らして全体把握を容易に
- モデルの概念拡張:1つのクラスに関連機能を集約
Service層の具体的な問題
- Serviceオブジェクト間でのメソッド共有が困難
- 設計方針の統一が難しい:チーム内で「Service」の認識がバラバラとなりやすいらしい
- 責務の境界が曖昧:どこに何を書くべきか迷いやすいらしい
- 「私の考える最強のService対決」:チーム内で設計方針について議論に時間がかかり着地点を見つけるのが困難になりがち
Railsらしい解決アプローチ
1. モデルへの責務移動
# Survey モデルに絞り込みロジックを移動
class Survey < ApplicationRecord
scope :by_category, ->(category_id) {
category_id == "none" ? where(category_id: nil) : where(category_id: category_id)
}
scope :by_type, ->(type_id) {
type_id == "none" ? where(survey_type_id: nil) : where(survey_type_id: type_id)
}
scope :by_keyword, ->(keyword) {
where("content ILIKE ?", "%#{keyword}%") if keyword.present?
}
def self.filter_by_params(params)
surveys = includes(:category, :survey_type)
surveys = surveys.by_category(params[:category_id]) if params[:category_id].present?
surveys = surveys.by_type(params[:survey_type_id]) if params[:survey_type_id].present?
surveys = surveys.by_keyword(params[:keyword]) if params[:keyword].present?
surveys
end
end
2. コントローラーの整理・分割
class SurveysController < ApplicationController
before_action :authenticate_user!
before_action :set_event, only: [:select, :search, :create]
def select
setup_survey_selection_data
end
def search
setup_survey_selection_data
respond_to { |format| format.js }
end
def create
result = create_survey_selections
redirect_with_result(result)
end
private
def setup_survey_selection_data
@categories = Category.all
@survey_types = SurveyType.all
@surveys = Survey.filter_by_params(search_params)
@selected_survey_ids = @event.survey_selections.pluck(:survey_id)
setup_filter_options
end
def create_survey_selections
survey_ids = extract_survey_ids
return { success: false, message: "アンケートを選択してください" } if survey_ids.empty?
new_survey_ids = survey_ids - existing_survey_ids
@event.add_survey_selections(new_survey_ids, current_user)
end
# その他のプライベートメソッド...
end
3. Event モデルでの関連処理
class Event < ApplicationRecord
has_many :survey_selections, dependent: :destroy
def add_survey_selections(survey_ids, user)
success_count = 0
errors = []
survey_ids.each do |survey_id|
survey = Survey.find_by(id: survey_id)
next unless survey
selection = survey_selections.build(
survey_id: survey_id,
selected_at: Time.current,
user_id: user.id,
completed: false
)
if selection.save
success_count += 1
else
errors << "アンケートID #{survey_id}: #{selection.errors.full_messages.join(', ')}"
end
end
{ success_count: success_count, errors: errors }
end
end
改善後の効果
コードの改善点
- 責務の明確化:各モデルが自身に関連するロジックを持つ
- 再利用性の向上:モデルメソッドは他の箇所でも利用可能
- テストの簡素化:モデルメソッドの単体テストが容易
- Railsらしさの維持:フレームワークの思想に沿った設計
パフォーマンスの向上
# 改善前:N+1問題の可能性
@surveys = build_surveys_query
# 改善後:eager loading
@surveys = Survey.filter_by_params(search_params)
# includes(:category, :survey_type) をモデル内で実行
学んだこと
1. フレームワークの思想を理解する重要性
「Fat Controller解消 = Service層」という固定観念を持っていましたが、調べれば調べるほどRailsの設計思想を理解した上でのアプローチが重要だと学びました。
2. 「層を増やすことの覚悟」
新しい層を入れるのは相当の覚悟を持って挑まねばならない作業
この考え方は多くの記事で言及されており、非常に印象的でした。安易に層を増やすのではなく、既存の仕組みを最大限活用することの大切さを実感しました。
3. チーム開発での設計統一
Service層を導入すると「私の考える最強のService対決」になりがちという話は、実際の開発でよく起こりうる課題を表現しているようです。まだ、学習中なので実際はわかりませんが、非常に参考になりました。
まとめ
Fat Controllerの解消にService層を検討することは一般的ですが、Railsにおいては必ずしも最適解ではないことを学びました。
推奨アプローチの順序
- モデルのメソッドとして切り出せないか?
- Viewに依存したロジックはHelperに切り出せないか?
- そもそもControllerを分割できないか?
- それでも困難な場合は、モデルの分割や独自クラスの検討
Railsの設計思想を理解し、フレームワークの長所を活かした設計を心がけることで、より保守性の高いアプリケーションを作ることができます。
「Service層ありき」ではなく、「Railsらしさ」を軸にした設計判断の重要性を改めて実感した貴重な経験でした。
参考記事
初学者のため、間違えていたらすいません。