
Every June, a student shows us a controller that's 400 lines long. The action validates input, updates a user's subscription, and sends a Slack notification. We tell them to stop. Then we show them three patterns that fix the mess: service objects, query objects, and view presenters. These aren't theoretical. They're the difference between a codebase you can debug at 2 a.m. and one that makes you quit.
Service objects for the heavy lifting
A controller should know what to do, not how to do it. When an action has more than two steps, that's a signal the logic belongs elsewhere. Service objects are plain Ruby classes that wrap a single business operation. They take input, return a result, and keep the controller thin.
Here's a typical messy controller from a student project:
def upgrade
@user = User.find(params[:id])
Plan.transaction do
@user.subscription.update!(plan: params[:plan])
@user.credit_card.update!(token: params[:token])
Notifier.slack("user_upgraded", @user.id)
end
redirect_to @user, notice: "Upgraded."
end
That's three responsibilities in one action. If the Slack call fails, the whole transaction rolls back. If you need to test the upgrade logic, you have to simulate a full HTTP request. A service object fixes both problems:
class UpgradeUser
Result = Struct.new(:success, :user, :error)
def self.call(user, plan, token)
new(user, plan, token).call
end
def initialize(user, plan, token)
@user = user
@plan = plan
@token = token
end
def call
Plan.transaction do
@user.subscription.update!(plan: @plan)
@user.credit_card.update!(token: @token)
Notifier.slack("user_upgraded", @user.id)
end
Result.new(true, @user, nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(false, @user, e.record.errors)
end
end
Now the controller is two lines: call the service, then redirect based on the result. You can test UpgradeUser in isolation, without touching routes or sessions. We tell students to put service objects in app/services/ – plain Ruby, no inheritance, just a class with a .call class method. That's it. Don't overcomplicate it with dependency injection or abstract base classes. The point is to move logic out of the controller, not to build a framework.
One mistake we see every week: people put service objects in the model. That's worse than leaving the logic in the controller. Models already have enough jobs. Keep service objects separate. If you're not sure whether to use one, count the ifs in your action. More than two conditionals? That's a service.
Query objects keep the model readable
The second pattern we teach is the query object. Models accumulate scopes like barnacles. At some point, User.active.subscribed.in_region stops being readable. A query object extracts complex queries into their own class.
We had a student who needed to find "premium users who haven't logged in for 30 days but have an active trial". The scope chain was six lines long and impossible to test. We suggested a query object:
class ExpiringPremiumUsers
def self.call
User.joins(:subscription)
.where(subscriptions: { plan: "premium" })
.where("last_sign_in_at < ?", 30.days.ago)
.where("trial_ends_at > ?", Time.now)
end
end
Then the controller calls ExpiringPremiumUsers.call instead of a chain of scopes. The query object is a named, testable unit. You can write a spec for it that doesn't care about the controller or the model. We keep these in app/queries/.
The rule of thumb we give: if a scope chain is longer than three or needs a where with raw SQL, extract it. Don't do this for every simple User.where(active: true) – that's over-engineering. But when you see the same five-line query in three places, it's time.
Query objects also make it easier to change the database later. If you migrate from Postgres to MySQL, you only fix the query object, not every controller that called the scope. That's a benefit you'll appreciate in month eight of a project.
View presenters for presentation logic
The third pattern is the view presenter (or decorator). Views should not know business rules. We see students writing if @user.admin? && @user.subscription.trial? inside a partial. That's logic that belongs in a presenter.
A presenter is a small class that wraps a model and exposes view-ready methods. For example:
class UserPresenter
def initialize(user)
@user = user
end
def account_status
if @user.admin?
"Admin"
elsif @user.subscription.trial?
"Trial (ends #{@user.subscription.trial_ends_at.strftime("%b %d")})"
else
@user.subscription.plan.humanize
end
end
end
Now the view calls presenter.account_status and nothing else. We use a simple delegate or just call it directly. No need for a gem like Draper unless you want it. The key is that the view stops making decisions.
We had a student who put a discount_percentage calculation in the view for a Black Friday sale. When the sale ended, they had to find and remove that code from three different templates. A presenter would have made it one method, changed in one place. We don't use presenters for every attribute – only when there's conditional logic or formatting. If it's just @user.name, don't wrap it.
The three patterns work together. A service object does the write, a query object does the read, and a presenter does the display. They each have a single job, and they keep each other honest. When we see a controller with a service call, a query call, and a presenter assignment, we know the developer understood the assignment.
Common questions
Should I use these patterns from the start of a project?
No. Start with a simple controller and model. Add a service object when an action grows past three steps or you need to test business logic in isolation. Add a query object when you repeat the same scope chain twice. Add a presenter when a view starts checking conditions. Patterns are solutions to problems you've already hit, not decorations for greenfield code.
How do I decide between a service object and a model method?
If the logic involves multiple models or a transaction, use a service object. If it's a single model's own behavior, a model method is fine. A service object that only touches one model is often overkill. Ask yourself: would this be awkward to call as User.find(...).upgrade_to_premium? If yes, it's a service.
Do these patterns slow down the app?
Not in any measurable way. A service object is just a class instantiation. A query object is a class method that returns a relation. A presenter is a wrapper. The overhead is negligible compared to a single database query. The real cost is in your own discipline – you have to stop yourself from taking the shortcut of throwing logic into the controller. That's a habit, not a performance issue.
Courses this relates to
Come and sit a class before you decide
Reading about it only gets you so far. Pick a day, sit in on a class that is already running, write some code, and talk to the students in it. Free, and nothing to pay afterwards unless you want to join.