はじめに
この記事は、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
問題点
-
コントローラが AR の詳細を知りすぎ:
.where(recorded_on: ...)などの知識がコントローラに - 集計ロジックが散在: 平均計算の実装がコントローラに埋もれる
- 再利用不可: 同じクエリを別のアクションで使いたいときにコピペが必要
- テストしにくい: クエリや集計だけをテストするにはコントローラのテストが必要
解決策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:)