class PopularPostsQuery
def initialize(relation = Post.all)
@relation = relation
end
def call(timeframe: 7.days, min_views: 100, limit: 20)
@relation
.joins(:author)
.where('posts.created_at >= ?', timeframe.ago)
.where('posts.views >= ?', min_views)
.where('users.status = ?', 'active')
.where(posts: { published_at: ...Time.current })
.select('posts.*, (posts.views * 0.6 + posts.likes_count * 0.4) AS popularity_score')
.order('popularity_score DESC')
.limit(limit)
end
end
module Api
module V1
class PostsController < BaseController
def popular
posts = PopularPostsQuery.new.call(
timeframe: params[:timeframe]&.to_i&.days || 7.days,
min_views: params[:min_views]&.to_i || 100,
limit: params[:limit]&.to_i || 20
)
render json: posts
end
end
end
end
When queries become too complex for scopes—involving multiple joins, subqueries, or raw SQL fragments—I extract them into dedicated query objects. Each query object is a plain Ruby class that encapsulates one specific query pattern and returns an ActiveRecord::Relation so results remain composable. This keeps models lean and makes testing easier since I can verify query logic in isolation. Query objects also improve performance visibility—I can instrument them with logging or metrics to track slow queries. The pattern works particularly well for search and reporting features where query complexity grows over time. I prefer query objects over putting everything in scopes when the logic exceeds 3-4 lines.