Zenn
👌

Finder Objectでリファクタしてみた

2025/03/22に公開

Daily Blogging91日目

とりあえずcontrollerに全ロジック詰め込んでfat controllerを生成してしまったのでリファクタしてみる。

問題点

今回はcontroller内に下記のロジックが集約されているのが問題

  • データ取得
  • データのフィルタリング
  • レスポンス用のデータの整形

※ブログ用にコードは変更

class CouponsController < ApplicationController
  def index
    @coupons = []

    volume = Volume.current.find_by(id: params[:id])
    return if volume.nil?

    # データ取得
    valid_coupons = UserDiscountCoupon.preload(:discount_coupon).
      where(user_id: user_id).
      valid.
      select("discount_coupon_id, expire_at, count(*) as count").
      group(:discount_coupon_id, :expire_at)

    # データをフィルタリング
    target_coupons = valid_coupons.select do |coupon|
      coupon.usable_coupon?(volume.title)
    end

    # レスポンス用に整形
    @coupons = target_coupons.map do |coupon|
      {
        id: coupon.id,
        name: coupon.name,
        discount_percentage: coupon.discount_percentage,
      }
    end
  end
end

一つのcontrollerで色々な責務が存在しているのであとあと辛くなる
可読性:これだとぱっと見でどんな処理を行なっているのかがわからない
再利用性:データに関するロジックをcontrollerにベタ書きしているので他で再利用できない

責務を分けよう

今回の問題でcontrollerの責務でないのはこの二つ

  • データ取得
  • データのフィルタリング

この二つはクーポンの責務なので切り離す

Query Object/Finder Object

データの取得に関するロジックを閉じ込めるデザインパターンのこと
Query Objectは聞いたことあったけどFinder Objectは初めて聞いた

二つの違いはこんな感じっぽい

  • Query Object
    • 1scopeにつき1 Query Object
    • 複雑なクエリを抽象化する
  • Finder Object
    • controllerやserviceクラスから呼び出される
    • ビジネスロジックに基づいたデータ取得を抽象化

どっちを使うのかというよりどっちも使う
クエリ部分はQuery Objectに閉じ込めて、Finder Object内でQuery Objectを呼んだりする

リファクタしてみた

最終的にこんな感じにしてみた

# Query Object
class UserDiscountCoupons::ValidCouponsQuery
  def self.call(user_id)
    UserDiscountCoupon.preload(:discount_coupon).
    where(user_id: user_id).
      valid.
      select("discount_coupon_id, expire_at, count(*) as count").
      group(:discount_coupon_id, :expire_at)
  end
end
# Finder Object
class UserDiscountCoupons::VolumeCouponsFinder
  def self.call(user_id, volume)
    coupons = UserDiscountCoupons::ValidCouponsQuery.call(user_id).select { |coupon| coupon.useable_coupon?(volume.title) }

    coupons.map do |user_discount_coupon|
      {
        id: ~,
        name: ~,
        available_quantity: ~,
        discount_percentage: ~,
        expire_at: ~,
      }
    end
  end
end
class CouponsController < ApplicationController
  def index
    volume = Volume.current.find_by(id: params[:id])
    return if volume.nil?

    @coupons = UserDiscountCoupons::VolumeCouponsFinder.call(auth_token_user.id, volume)
    @coupons.sort_by! { |coupon| coupon[:discount_percentage] }.reverse!
  end
end

データ取得系のロジックを切り離したので、controllerの責務がsortだけになりましたとさ

まとめ

fat controllerを解消するためにとりあえずserviceクラスを採用しがちだったが、
それだと結局serviceクラスに色々な責務が含まれたロジックが移動されるだけなので意味ないよなぁって思ってた。
Query ObjectやFinder Objectならデータの取得に関するロジックの集約と責務がはっきりしているので使いやすそう

今回はFinder Objectでデータ整形しているので、ここは責務を置く場所要検討かも
基本的にこの形でしか使わないのでとりあえずFinder Objectに置いてる

Discussion

ログインするとコメントできます