Fat Controllerをリファクタリングしたい!
はじめに
ここ数ヶ月間でRailsを使用したアプリケーションの新規機能開発を担当し、見事Fat Controllerを実装してしまいました。
そのため、振り返りとして単一責務の原則に則った実装や抽象化したクラスの作成方法など、Fat Controllerをリファクタリングしながら問題点や改善策についてまとめました。
Fat Controllerとは
まず、改めてFat Controllerとはどういった状態のものなのかについて整理します。
コントローラに置くロジックを増やしすぎたときの問題は、単一責任の原則(SRP: Single Responsibility Principle)に違反することです。つまり、処理をコントローラ内で引き受けすぎてしまっているということです。この状態になるとコード量が増えて責務を抱え込みすぎてしまいがちです。ここでいう「ファット」は、コントローラファイルのコード量も、コントローラがサポートするロジックの量も増えるということを指します。ファットコントローラは多くの場合アンチパターンと見なされます。
要するに、コントローラーが過剰な責務を持ってしまい、肥大化してしまっている状態のことを言います。
結果として、色々な責務を持っているコントローラーになってしまうと、可読性の低いコード・変更に弱いコード・再利用性の低いコード になってしまいます。
そのため、Fat Controllerにならないように実装するには「単一責務の原則」の考えが大切になります。
単一責務の原則
次に、改めて単一責務の原則についても整理してみます。
クラス(オブジェクト)が担う責任は1つに限定すべきである(かつその責務は完全にカプセル化されるべきである)。
つまり、1つのクラスやモジュールが複数の責務を持つのではなく、1つの明確な責務だけを担うべきという考え方になります。
この原則を意識することで、コードの可読性・保守性が向上します。
では、次に単一責務の原則についてBAD・GOODなコード例をみてみましょう。
🙅♂️ BAD
以下のコードはフルーツのECサイトを例として考えた際のコントローラーになります。
index, show, searchのアクションが実装されており、責務が混在してしまっています。また、少し極端ではありますが、各アクションのデータ整形のロジックが重複してしまっています。
class Api::FruitsController < ApplicationController
def index
fruits = Fruit.all
result = fruits.map do |fruit|
{
id: fruit.id,
name: fruit.name,
price: fruit.price,
in_stock: fruit.stock > 0
}
end
render json: result
end
def show
fruit = Fruit.find(params[:id])
render json: {
id: fruit.id,
name: fruit.name,
price: fruit.price,
in_stock: fruit.stock > 0
}
end
def search
fruits = Fruit.where('name LIKE :query', query: "%#{params[:query]}%")
result = fruits.map do |fruit|
{
id: fruit.id,
name: fruit.name,
price: fruit.price,
in_stock: fruit.stock > 0
}
end
render json: result
end
end
🙆♂️ GOOD
以下のように、fruits_controller.rb
にはデータ取得系のアクションを実装し、検索の責務を持つアクションについては fruits/search_controller.rb
へ切り出しました。これにより、各コントローラーの責務が明確になり、単一責務の原則に則った実装になります。
また、ActiveRecordのクエリロジックは models/fruit.rb
にスコープとして切り出し、再利用性と保守性を向上させています。さらに、データ整形の責務を専用のシリアライザ fruit_serializer.rb
に分離することで、コントローラーが軽量化され、ロジックがシンプルになりました。
class Api::FruitsController < ApplicationController
def index
fruits = Fruit.cheap(500) # 例: 500円以下のフルーツ
render json: fruits.map { |fruit| FruitSerializer.new(fruit).as_json }
end
def show
fruit = Fruit.find(params[:id])
render json: FruitSerializer.new(fruit).as_json
end
end
class Api::Fruits::SearchController < ApplicationController
def search
fruits = Fruit.search_by_name(params[:query])
render json: fruits.map { |fruit| FruitSerializer.new(fruit).as_json }
end
end
class Fruit < ApplicationRecord
scope :cheap, ->(max_price) { where('price <= ?', max_price) }
scope :search_by_name, ->(query) { where('name LIKE ?', "%#{query}%") }
end
class FruitSerializer
def initialize(fruit)
@fruit = fruit
end
def as_json
{
id: @fruit.id,
name: @fruit.name,
price: @fruit.price,
in_stock: @fruit.stock > 0
}
end
end
私が実装したFat Controller
次に私が実装したFat Controllerのコードを元に問題点とリファクタリング案を考えていきたいと思います。
今回もフルーツECサイトを仮定して説明します。
🙅♂️ BAD
class Api::Fruits::OrdersController < Api::BaseController
before_action :set_order, only: %i[update cancel details]
before_action :validate_cart, only: %i[create]
# 現在進行中の注文を取得
def current_order_summary
current_order = current_user.orders.where(status: 'in_progress').last
if current_order.blank?
render json: { order_status: false }
else
order_items = current_order.order_items.includes(:product)
total_price = order_items.sum { |item| item.product.price * item.quantity }
shipping_address = current_order.shipping_address
order_summary = {
id: current_order.id,
status: current_order.status,
shipping_address: shipping_address,
total_price: total_price,
items: order_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
price: item.product.price
}
end
}
render json: { order_status: true, order_summary: order_summary }
end
end
# 新規の注文を作成
def create
new_order = current_user.orders.build(order_params)
if new_order.save
cart.items.each do |cart_item|
new_order.order_items.create(product_id: cart_item.product_id, quantity: cart_item.quantity)
end
cart.clear
render json: { message: 'Order created successfully', order_id: new_order.id }, status: :created
else
render json: { message: 'Failed to create order', errors: new_order.errors.full_messages }, status: :unprocessable_entity
end
end
# 注文情報の更新
def update
if @order.update(order_params)
render json: { message: 'Order updated successfully', order: @order }, status: :ok
else
render json: { message: 'Failed to update order', errors: @order.errors.full_messages }, status: :unprocessable_entity
end
end
# 注文の詳細情報
def details
if @order.present?
order_details = {
id: @order.id,
status: @order.status,
items: @order.order_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
price: item.product.price
}
end,
total_price: @order.order_items.sum { |item| item.product.price * item.quantity },
shipping_address: @order.shipping_address
}
render json: { order: order_details }, status: :ok
else
render json: { message: 'Order not found' }, status: :not_found
end
end
# 注文のキャンセル
def cancel
if @order.cancelable?
@order.update(status: 'canceled')
render json: { message: 'Order canceled successfully' }, status: :ok
else
render json: { message: 'Order cannot be canceled' }, status: :unprocessable_entity
end
end
# 商品検索
def search
keyword = params[:keyword]
orders = current_user.orders.joins(order_items: :product)
.where('products.name ILIKE ?', "%#{keyword}%")
.distinct
if orders.present?
search_results = orders.map do |order|
{
id: order.id,
status: order.status,
total_price: order.order_items.sum { |item| item.product.price * item.quantity },
items: order.order_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
price: item.product.price
}
}
}
end
render json: { results: search_results }, status: :ok
else
render json: { message: 'No orders found matching the search criteria' }, status: :not_found
end
end
# 過去の注文取得
def past_orders
past_orders = current_user.orders.where(status: 'completed').order(created_at: :desc)
if past_orders.present?
past_orders_data = past_orders.map do |order|
{
id: order.id,
status: order.status,
completed_at: order.updated_at.strftime('%Y-%m-%d %H:%M'),
total_price: order.order_items.sum { |item| item.product.price * item.quantity },
items: order.order_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
price: item.product.price
}
}
}
end
render json: { past_orders: past_orders_data }, status: :ok
else
render json: { message: 'No past orders found' }, status: :not_found
end
end
private
def set_order
@order = current_user.orders.find_by(id: params[:id])
unless @order
render json: { message: 'Order not found' }, status: :not_found
end
end
def order_params
params.require(:order).permit(:shipping_address, :payment_method, order_items_attributes: %i[product_id quantity])
end
def validate_cart
if cart.items.empty?
render json: { message: 'Your cart is empty' }, status: :unprocessable_entity
end
end
def cart
@cart ||= current_user.cart
end
end
いい感じにFat Controllerな実装になっていますね笑
次にFat Controllerの原因となっている箇所について説明します。
Fat Controllerの原因
◼︎ 注文に関する責務が混在したアクションが同じクラスに定義されている。
current_order_summary
past_orders
search
はデータ取得ロジック・データ加工・レスポンス生成の責務を含んでいます。そのため、単一責務の原則に則っておらず、Fat Controllerの原因になっています。
◼︎ 似ているロジックを抽象化したクラスに切り出せていない。
current_order_summary
past_orders
details
で合計金額の計算や商品情報のフォーマットが繰り返されているため、共通ロジックとして抽象化することができる。
◼︎ モデルのスコープに切り出せていない。
current_user.orders.where(status: 'in_progress')
や current_user.orders.where(status: 'completed').order(created_at: :desc)
のようなメソッドチェーンがアクション内に直接書かれています。
スコープを導入することで、複雑なクエリを簡潔に記述でき、他の箇所でも再利用可能になります。また、コントローラーに直接クエリを書くことを避けられるため、可読性が向上します。
では、次にリファクタリングをしながら、Fat Controllerを解消していきたいと思います。
Fat Controllerを解消
◼︎ 単一責務の原則に則って責務をクラスごとに分割
- 現在進行中の注文を取得するアクションの
current_order_summary
🙆♂️ GOOD
class CurrentOrderSummary
def initialize(user)
@user = user
end
def call
current_order = @user.orders.in_progress.last
return { order_status: false } unless current_order
{
order_status: true,
order_summary: OrderDetailsPresenter.new(current_order).as_json
}
end
end
上記のようにクラスを分割することにより、app/controllers/api/fruits/orders_controller.rb
では以下のように呼び出すだけになります。
# 現在進行中の注文を取得
def current_order_summary
render json: CurrentOrderSummary.new(current_user).call
end
その他のアクションも同様の形でクラスに分割しています。
- 新規の注文を作成する
create
🙆♂️ GOOD
class OrderCreator
Result = Struct.new(:success?, :order, :error_message)
def initialize(user, order_params, cart)
@user = user
@order_params = order_params
@cart = cart
end
def call
order = @user.orders.build(@order_params)
if order.save
@cart.items.each do |cart_item|
order.order_items.create(product_id: cart_item.product_id, quantity: cart_item.quantity)
end
@cart.clear
Result.new(true, order, nil)
else
Result.new(false, nil, order.errors.full_messages.to_sentence)
end
end
end
# 新規の注文を作成
def create
result = OrderCreator.new(current_user, order_params, cart).call
if result.success?
render json: { message: 'Order created successfully', order_id: result.order.id }, status: :created
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
- 注文情報を更新する
update
🙆♂️ GOOD
class OrderUpdater
Result = Struct.new(:success?, :error_message)
def initialize(order, params)
@order = order
@params = params
end
def call
if @order.update(@params)
Result.new(true, nil)
else
Result.new(false, @order.errors.full_messages.to_sentence)
end
end
end
# 注文情報の更新
def update
result = OrderUpdater.new(@order, order_params).call
if result.success?
render json: { message: 'Order updated successfully', order: @order }, status: :ok
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
- 注文の詳細情報する
details
OrderDetailsPresenter
クラスでは、共通のロジックを抽出してまとめています。
これにより再利用することが可能になりました。
🙆♂️ GOOD
class OrderDetailsPresenter
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
status: @order.status,
total_price: @order.order_items.sum { |item| item.product.price * item.quantity },
shipping_address: @order.shipping_address,
items: @order.order_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
price: item.product.price
}
end
}
end
end
# 注文の詳細情報
def details
render json: OrderDetailsPresenter.new(@order).as_json
end
- 注文のキャンセルする
cancel
class OrderCanceler
Result = Struct.new(:success?, :error_message)
def initialize(order)
@order = order
end
def call
if @order.cancelable?
@order.update(status: 'canceled')
Result.new(true, nil)
else
Result.new(false, 'Order cannot be canceled')
end
end
end
# 注文のキャンセル
def cancel
result = OrderCanceler.new(@order).call
if result.success?
render json: { message: 'Order canceled successfully' }, status: :ok
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
- 商品を検索する
search
🙆♂️ GOOD
class OrderSearcher
def initialize(user, keyword)
@user = user
@keyword = keyword
end
def call
orders = @user.orders.joins(order_items: :product)
.where('products.name ILIKE ?', "%#{@keyword}%")
.distinct
orders.map { |order| OrderDetailsPresenter.new(order).as_json }
end
end
# 商品検索
def search
results = OrderSearcher.new(current_user, params[:keyword]).call
if results.present?
render json: { results: results }, status: :ok
else
render json: { message: 'No orders found matching the search criteria' }, status: :not_found
end
end
- 過去の注文取得する
past_orders
🙆♂️ GOOD
class PastOrdersFetcher
def initialize(user)
@user = user
end
def call
past_orders = @user.orders.completed
past_orders.map { |order| OrderDetailsPresenter.new(order).as_json }
end
end
# 過去の注文取得
def past_orders
render json: PastOrdersFetcher.new(current_user).call
end
◼︎ モデルにスコープの導入
🙆♂️ GOOD
class Order < ApplicationRecord
scope :in_progress, -> { where(status: 'in_progress') }
scope :completed, -> { where(status: 'completed').order(created_at: :desc) }
end
app/controllers/api/fruits/orders_controller.rbの全体コード
class Api::Fruits::OrdersController < Api::BaseController
before_action :set_order, only: %i[update cancel details]
before_action :validate_cart, only: %i[create]
# 現在進行中の注文を取得
def current_order_summary
render json: CurrentOrderSummary.new(current_user).call
end
# 新規の注文を作成
def create
result = OrderCreator.new(current_user, order_params, cart).call
if result.success?
render json: { message: 'Order created successfully', order_id: result.order.id }, status: :created
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
# 注文情報の更新
def update
result = OrderUpdater.new(@order, order_params).call
if result.success?
render json: { message: 'Order updated successfully', order: @order }, status: :ok
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
# 注文の詳細情報
def details
render json: OrderDetailsPresenter.new(@order).as_json
end
# 注文のキャンセル
def cancel
result = OrderCanceler.new(@order).call
if result.success?
render json: { message: 'Order canceled successfully' }, status: :ok
else
render json: { message: result.error_message }, status: :unprocessable_entity
end
end
# 商品検索
def search
results = OrderSearcher.new(current_user, params[:keyword]).call
if results.present?
render json: { results: results }, status: :ok
else
render json: { message: 'No orders found matching the search criteria' }, status: :not_found
end
end
# 過去の注文取得
def past_orders
render json: PastOrdersFetcher.new(current_user).call
end
private
def set_order
@order = current_user.orders.find_by(id: params[:id])
render json: { message: 'Order not found' }, status: :not_found unless @order
end
def order_params
params.require(:order).permit(:shipping_address, :payment_method, order_items_attributes: %i[product_id quantity])
end
def validate_cart
render json: { message: 'Your cart is empty' }, status: :unprocessable_entity if cart.items.empty?
end
def cart
@cart ||= current_user.cart
end
end
このように「単一責務のクラス」「共通ロジックの抽出」「モデルのスコープを導入」を軸にリファクタリングを行うとFat Controllerが解消されたのではないでしょうか?
また、クラス・ロジックの再利用性・保守性も向上したかと思います。
まとめ
今回は、Fat Controllerの問題点を整理し、その原因と改善方法を具体的に見てきました。
最後に要点をまとめて終わりにしようと思います。
◼︎ Fat Controllerの原因
コントローラーに複数の責務が混在していたこと。
共通ロジックが重複し、コードの再利用性が低かったこと。
ActiveRecordのクエリが直接記述され、複雑性を増していたこと。
◼︎ 改善方法
単一責務の原則に則り、各アクションを専用クラスに切り出すことで、責務を明確化。
共通ロジックを抽出して再利用可能なクラス(プレゼンターやヘルパー)にまとめた。
ActiveRecordのスコープを導入し、メソッドチェーンを簡潔に記述。
◼︎ 改善後のメリット
コントローラーが軽量化され、役割が明確になり、コードの可読性が向上。
再利用性が高まり、ロジックの修正が他の箇所に影響を与えにくくなった。
クラスやメソッドが独立してテストしやすくなり、品質の向上にもつながった。
リファクタリングを通じて、単一責務の原則やコードの抽象化、再利用性の重要性を改めて実感しました。
今後も、これらの原則を念頭に置きながら、読みやすく変更に強いコードを書いていきたいと思います。
参考情報
Discussion