0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsアプリで学ぶデザインパターン - Query Object + Service Object (3/7)

0
Last updated at Posted at 2026-03-16

はじめに

この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第3回です。
第1回から読むことをお勧めします。

記事 何を解決するか
記事1: Value Object ロジックの分散
記事2: Validator Object + Form Object インラインバリデーション
記事3: Query Object + Service Object インラインクエリ・集計ロジック
記事4: Adapter + ActiveJob + Pub/Sub 通知のハードコード・同期処理
記事5: Workflow + Command ファットコントローラ・トランザクション管理
記事6: Policy Object + Concern + Callback 認可・共通スコープ・監査ログ
記事7: Presenter + ViewComponent モデルへの表示ロジック混入

Query Object + Service Object パターン

対応PR:

概要

Query Object はデータベースへの問い合わせロジックを専用クラスに切り出すパターンです。
Service Object はビジネスロジック(複数の処理をまとめた操作)を専用クラスに封じ込めます。

2つを組み合わせることで:

  • コントローラに書かれたクエリ・集計ロジック(15行以上)を削除できる
  • Query Object が Service Object から再利用できる
  • ロジックのテストがコントローラなしで書ける

問題:アンチパターン

main ブランチでは、クエリと集計ロジックがコントローラに直書きされています。

# app/controllers/weekly_reports_controller.rb (main ブランチ)

def create
  # ...バリデーション...

  # インライン クエリ(コントローラが AR の詳細を知りすぎ)
  weight_entries = WeightEntry.where(user: current_user)
                              .where(recorded_on: period_start..period_end)
                              .order(recorded_on: :asc)
  workouts = Workout.where(user: current_user)
                    .where(recorded_on: period_start..period_end)
                    .order(recorded_on: :asc)

  # インライン 集計(15行以上)
  avg_weight_g = if weight_entries.any?
    (weight_entries.sum(:weight_g) / weight_entries.count.to_f).round
  end

  with_fat = weight_entries.where.not(body_fat_bp: nil)
  avg_body_fat_bp = if with_fat.any?
    (with_fat.sum(:body_fat_bp) / with_fat.count.to_f).round
  end

  total_calories_kcal = workouts.sum(:calories_kcal)
  total_workout_min   = workouts.sum(:duration_min)
  # ...
end

問題点

  1. コントローラが AR の詳細を知りすぎ: .where(recorded_on: ...) などの知識がコントローラに
  2. 集計ロジックが散在: 平均計算の実装がコントローラに埋もれる
  3. 再利用不可: 同じクエリを別のアクションで使いたいときにコピペが必要
  4. テストしにくい: クエリや集計だけをテストするにはコントローラのテストが必要

解決策1:Query Object の導入

# app/queries/weight_entries_query.rb

class WeightEntriesQuery
  def self.call(user:, start_date:, end_date:)
    new(user:, start_date:, end_date:).call
  end

  def initialize(user:, start_date:, end_date:)
    @user = user
    @start_date = start_date
    @end_date = end_date
  end

  def call
    WeightEntry.where(user: @user)
               .where(recorded_on: @start_date..@end_date)
               .order(recorded_on: :asc)
  end
end
# app/queries/workouts_query.rb

class WorkoutsQuery
  def self.call(user:, start_date:, end_date:)
    new(user:, start_date:, end_date:).call
  end

  def initialize(user:, start_date:, end_date:)
    @user = user
    @start_date = start_date
    @end_date = end_date
  end

  def call
    Workout.where(user: @user)
           .where(recorded_on: @start_date..@end_date)
           .order(recorded_on: :asc)
  end
end

.call クラスメソッドを持つ規約により、呼び出し側は WeightEntriesQuery.call(...)
シンプルに書けます。内部実装はクラスの中に隠蔽されます。


解決策2:Service Object の導入

# app/services/weekly_report/generate.rb

class WeeklyReport::Generate
  def self.call(user:, start_date:, end_date:)
    new(user:, start_date:, end_date:).call
  end

  def initialize(user:, start_date:, end_date:)
    @user = user
    @start_date = start_date
    @end_date = end_date
  end

  def call
    weight_entries = WeightEntry.where(user: @user)
                                .where(recorded_on: @start_date..@end_date)
                                .order(recorded_on: :asc)
    workouts = Workout.where(user: @user)
                      .where(recorded_on: @start_date..@end_date)
                      .order(recorded_on: :asc)

    {
      avg_weight_g:        calc_avg_weight(weight_entries),
      avg_body_fat_bp:     calc_avg_body_fat(weight_entries),
      total_calories_kcal: workouts.sum(:calories_kcal),
      total_workout_min:   workouts.sum(:duration_min)
    }
  end

  private

  def calc_avg_weight(entries)
    return nil if entries.empty?
    (entries.sum(:weight_g) / entries.count.to_f).round
  end

  def calc_avg_body_fat(entries)
    with_fat = entries.where.not(body_fat_bp: nil)
    return nil if with_fat.empty?
    (with_fat.sum(:body_fat_bp) / with_fat.count.to_f).round
  end
end

Service Object は集計ロジックをプライベートメソッドに整理し、
戻り値としてハッシュを返します。コントローラはこのハッシュを受け取るだけです。


Before / After 比較

コントローラの変化

# Before: クエリ + 集計が20行以上コントローラに存在
def create
  weight_entries = WeightEntry.where(user: current_user)
                              .where(recorded_on: period_start..period_end)
                              .order(recorded_on: :asc)
  workouts = Workout.where(user: current_user)
                    .where(recorded_on: period_start..period_end)
                    .order(recorded_on: :asc)

  avg_weight_g = if weight_entries.any?
    (weight_entries.sum(:weight_g) / weight_entries.count.to_f).round
  end
  # ...15行以上の集計ロジック...
end

# After: Service Object に1行で委譲
def create
  metrics = WeeklyReport::Generate.call(
    user:       current_user,
    start_date: period_start,
    end_date:   period_end
  )

  @report = WeeklyReport.create!(user: current_user, **metrics)
end

Query Object の再利用

# Query Object は Service Object からも、コントローラからも呼べる
# Service Object から
entries = WeightEntriesQuery.call(user: user, start_date: start, end_date: end_date)

# コントローラの index アクションなど別の箇所からも
recent_entries = WeightEntriesQuery.call(user: current_user, start_date: 1.month.ago, end_date: Date.today)

テストのしやすさ

# Query Object のテスト(コントローラ不要)
RSpec.describe WeightEntriesQuery do
  it "指定期間の体重記録を返す" do
    user = create(:user)
    entry = create(:weight_entry, user: user, recorded_on: Date.today)
    create(:weight_entry, user: user, recorded_on: 2.months.ago)  # 期間外

    result = WeightEntriesQuery.call(
      user: user,
      start_date: 1.week.ago,
      end_date: Date.today
    )

    expect(result).to contain_exactly(entry)
  end
end

# Service Object のテスト
RSpec.describe WeeklyReport::Generate do
  it "平均体重を計算して返す" do
    user = create(:user)
    create(:weight_entry, user: user, weight_g: 70_000, recorded_on: Date.today)
    create(:weight_entry, user: user, weight_g: 72_000, recorded_on: 1.day.ago)

    result = WeeklyReport::Generate.call(
      user: user, start_date: 1.week.ago, end_date: Date.today
    )

    expect(result[:avg_weight_g]).to eq(71_000)
  end
end

まとめ

Query Object のメリット

  • AR のクエリ詳細をコントローラから隠蔽
  • 再利用可能(複数のアクションやサービスから呼べる)
  • クエリのテストを独立して書ける
  • スコープとの使い分け:スコープはモデルに属する単純な条件、Query Object は複数の条件を組み合わせた複雑なクエリに向く

Service Object のメリット

  • ビジネスロジック(集計・計算)をコントローラから分離
  • call クラスメソッドによる統一されたインターフェース
  • 戻り値が明確(ハッシュ、オブジェクト等)でテストしやすい

.call パターンの規約

このプロジェクトでは Query Object・Service Object ともに .call(...) で呼び出す規約を採用しています。
new + call の2段階を1行に収められるため、呼び出し側がすっきりします。

# すべてこの形式で呼べる
WeightEntriesQuery.call(user:, start_date:, end_date:)
WeeklyReport::Generate.call(user:, start_date:, end_date:)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?