Practice · SOLID · SRP · Card 6
Where does this query belong?
A class method on User that does complex finder logic. It grew over time. It's still on the model. Should it stay?
The code
A class method on User. Five filters, an order, a limit, two joins.
class User < ApplicationRecord
has_many :orders
has_many :payments
def self.churn_risks(since: 30.days.ago, min_spend_cents: 50_00, segment: :all)
base = active.joins(:orders, :payments)
base = base.where(orders: { created_at: since.. })
base = base.where("payments.amount_cents >= ?", min_spend_cents)
base = base.where(segment: segment) unless segment == :all
base = base.where("users.last_login_at < ?", 14.days.ago)
base = base.where.not(id: NotificationLog.recent_recipient_ids)
base.group("users.id")
.order("MAX(orders.created_at) ASC")
.limit(500)
end
end The question
This works. Tests pass. It belongs to the User model. What's the SRP-shaped problem, and where should this logic actually live?
Take a moment. Ask: is this code about a User, or about a specific business question that happens to start with User? The answer tells you where it belongs.
What's wrong
The method is named after a business concept ("churn risks") that the User model doesn't otherwise know about. Putting it on User means: every developer reading User has to scroll past it, the User model now depends on NotificationLog and Payment query details, and the method can't be reused or tested without loading the full User model with its concerns and validations.
It's also doing too many things: scoping, joining, filtering, grouping, and ordering — all about answering one specific question. Each change to the business rule means editing this method, which means risking the rest of User indirectly.
Extract a query object
A query object is a class whose entire job is one business question. It can hold its parameters as instance state, its filters as private methods, and its result as the return of a single public call method.
class ChurnRiskQuery
def initialize(since: 30.days.ago, min_spend_cents: 50_00, segment: :all)
@since = since
@min_spend_cents = min_spend_cents
@segment = segment
end
def call
relation = active_users
relation = scope_by_segment(relation)
relation = with_recent_orders(relation)
relation = with_minimum_spend(relation)
relation = inactive_users(relation)
relation = not_recently_notified(relation)
sort_and_limit(relation)
end
private
# one short method per filter
# ...
end
# Caller:
risks = ChurnRiskQuery.new(min_spend_cents: 100_00).call User no longer knows about churn. The query is testable in isolation. Changing the business rule means editing one focused class. The shape is also reusable — if marketing wants the same query with different defaults, you instantiate with different parameters.
Theory
For the full walkthrough of query objects anchored in real OSS code, read SOLID · SRP · Query Objects.