Service objects: taming fat Rails models
Skinny controllers pushed all the logic into fat models, and now they're unmanageable. Service objects are the most popular fix — here is how to use them well, when they help, and how they turn into a worse mess if you overdo it.
Rails taught a generation of developers “skinny controllers, fat models”, and it was
good advice — right up until the models got too fat. A mature Rails app reliably
grows a User model that is two thousand lines long: persistence, validations,
callbacks, business rules, notification logic, payment logic, reporting helpers, all
piled into one class because “it’s about a user”. That class is impossible to hold in
your head, terrifying to change, and slow to test. Service objects are the most
popular answer to this problem, and a good one — but only if you understand what they
are actually for. Here is how we use them, and the ways they go wrong.
The problem: where does business logic go?
Rails gives you a clear home for two kinds of code: persistence and data rules go in
the model, request/response handling goes in the controller. What it conspicuously
does not give you is a home for business processes — the multi-step operations
that are the actual point of your application. “Place an order” is not a property of
the Order record; it is a process that creates an order, charges a card, decrements
stock, sends a confirmation, and maybe notifies a warehouse. Stuff that into the
model and it bloats; stuff it into the controller and it cannot be reused or tested
in isolation. There is no obvious place, so it sprawls.
A service object is just a plain Ruby class that gives that process a home. Nothing more exotic than that.
The basic shape
A service object is a PORO (plain old Ruby object) named for the action it performs, with one public method that performs it:
class PlaceOrder
def initialize(cart, payment_method)
@cart = cart
@payment_method = payment_method
end
def call
ActiveRecord::Base.transaction do
order = create_order
charge_payment(order)
decrement_stock(order)
order
end
end
private
attr_reader :cart, :payment_method
def create_order
Order.create!(line_items: cart.line_items, total: cart.total)
end
def charge_payment(order)
PaymentGateway.charge(order.total, payment_method)
end
def decrement_stock(order)
order.line_items.each { |li| li.product.decrement!(:stock, li.quantity) }
end
end
The controller becomes genuinely skinny — it gathers input, calls the service, and renders a result:
def create
order = PlaceOrder.new(current_cart, params[:payment_method]).call
redirect_to order
rescue PaymentError => e
redirect_to cart_path, alert: e.message
end
The conventions that have settled around this: name the class as a verb phrase
(PlaceOrder, CancelSubscription, ImportCsv), expose a single public method
(call is idiomatic), keep everything else private, and let the constructor take the
collaborators. A common nicety is a class method so callers write
PlaceOrder.call(cart, pm).
Why this is better
Three concrete wins, not just tidiness:
- The business process has a name and a place. “How do we place an order?” has a
single answer: read
PlaceOrder. The logic is not scattered across a model callback, a controller, and an observer. - It is testable in isolation. You can unit-test
PlaceOrderdirectly with a cart and a payment method — no controller, no HTTP, no view. Fast tests for the logic that matters most. - The model goes back on a diet.
Orderbecomes responsible for being an order — its data and invariants — not for the entire choreography of ordering. The multi-model dance lives in the service that coordinates them.
Transactions and failure are first-class
Service objects are the natural home for transaction boundaries, and that is one of
their best features. A business process that touches several records usually needs to
be all-or-nothing — and the service is exactly the right scope to wrap in a
transaction block, so a failure halfway through rolls everything back. The service
also gives you one clear place to define how the process fails: raise a domain
error, or return a result object the caller inspects. We tend to prefer an explicit
result for expected failures over exceptions-as-control-flow:
Result = Struct.new(:success?, :order, :error)
def call
order = nil
ActiveRecord::Base.transaction do
order = create_order
charge_payment(order)
decrement_stock(order)
end
Result.new(true, order, nil)
rescue PaymentError => e
Result.new(false, nil, e.message)
end
The caller does result = PlaceOrder.call(...); if result.success? — failure is part
of the interface, not a surprise that escapes upward.
Where it goes wrong
Service objects are easy to cargo-cult, and overdone they produce a codebase that is worse than the fat model you were fleeing. The failure modes we have learned to watch for:
- One service per controller action, reflexively. If
UpdateUserNamejust callsuser.update(name: ...), you have added a layer of indirection that buys nothing. Service objects are for processes — multiple steps, multiple objects, real coordination. A one-line wrapper around a model method is pure ceremony; let the controller call the model. - Anemic models. Push everything out into services and your models become bags of database columns with no behaviour, while a parallel universe of service classes holds all the logic. That is not object-oriented design; it is procedural code in Ruby costume. Behaviour that genuinely belongs to a single record (a method over its own attributes) should stay on the model.
- God services. A
PlaceOrderthat grows to 400 lines has just become the fat model again, relocated. When a service gets big, decompose it into smaller services it calls, not into more private methods. - Shared mutable state and unclear inputs. Keep services stateless beyond their constructor args, with explicit inputs and one clear output. A service that reaches into globals or mutates its inputs is hard to reason about as the thing it promised to be: a function with a name.
Verdict
Service objects are the right tool for a real and specific problem: business
processes that span multiple models and have nowhere natural to live in vanilla
Rails. Used for that — a named class, a single call, a transaction boundary, a
clear result — they make an app dramatically easier to understand, test, and change,
and they keep your models focused on being models. The discipline is knowing the
boundary: a service object is for a process, not for every action. Reach for one
when logic spans objects and steps; leave the model to do what only it can. Get that
judgment right and the fat-model problem simply stops happening.