Service Objects in Ruby — How I Structure Business Logic
by Eric Hanson, Backend Developer at Clean Systems Consulting
The problem they solve
Rails gives you models and controllers. Models accumulate business logic until they're 800-line files with 40 callbacks. Controllers accumulate business logic when developers run out of patience putting it in models. Neither is the right place for multi-step operations that touch multiple models, call external services, and need to fail cleanly at any step.
A service object is a plain Ruby class with a single public method that executes one business operation. No inheritance required. No gem required. The pattern's value is in the constraints it enforces, not in any technical mechanism.
The basic structure
class PlaceOrder
def initialize(user:, cart:, payment_method:)
@user = user
@cart = cart
@payment_method = payment_method
end
def call
validate_cart
reserve_inventory
charge_payment
create_order
notify_user
end
private
attr_reader :user, :cart, :payment_method
def validate_cart
raise InvalidCartError, "Cart is empty" if cart.items.empty?
end
def reserve_inventory
InventoryService.reserve(cart.items)
end
def charge_payment
@charge = PaymentGateway.charge(
amount: cart.total,
method: payment_method
)
end
def create_order
@order = Order.create!(
user: user,
items: cart.items,
charge: @charge
)
end
def notify_user
OrderMailer.confirmation(@order).deliver_later
end
end
The caller:
PlaceOrder.new(user: current_user, cart: @cart, payment_method: params[:payment_method]).call
This is the minimal version. It works, it's readable, and it's testable. But it has two problems at scale: error signaling is all exceptions, and there's no structured way to communicate partial results back to the caller.
Result objects instead of bare exceptions
Exceptions are right for unexpected failures. They're wrong for expected business outcomes like "payment declined" or "cart item out of stock" — these aren't exceptional, they're normal paths. When every failure raises, controllers fill up with rescue blocks, and callers can't distinguish between a validation failure and a network timeout without inspecting exception types.
A result object gives you a structured return value:
class Result
attr_reader :value, :error
def self.ok(value = nil)
new(success: true, value: value)
end
def self.fail(error)
new(success: false, error: error)
end
def initialize(success:, value: nil, error: nil)
@success = success
@value = value
@error = error
end
def ok? = @success
def failed? = !@success
end
The service returns a Result instead of raising for expected failures:
def call
return Result.fail("Cart is empty") if cart.items.empty?
charge = PaymentGateway.charge(amount: cart.total, method: payment_method)
return Result.fail("Payment declined: #{charge.decline_reason}") unless charge.ok?
order = Order.create!(user: user, items: cart.items, charge: charge)
OrderMailer.confirmation(order).deliver_later
Result.ok(order)
rescue InventoryService::ReservationError => e
Result.fail("Item unavailable: #{e.message}")
end
The controller:
result = PlaceOrder.new(user: current_user, cart: @cart, payment_method: params[:payment_method]).call
if result.ok?
redirect_to order_path(result.value)
else
flash[:error] = result.error
render :checkout
end
The controller now has one branch, not three rescue blocks. The service has explicit, readable failure paths. Network failures and unexpected exceptions still bubble up as real exceptions — they're not caught here because they warrant intervention, not a flash message.
The class-method shortcut
The .new(...).call pattern is verbose at call sites. A class-method wrapper cleans it up:
class PlaceOrder
def self.call(...)
new(...).call
end
# rest unchanged
end
# Caller
result = PlaceOrder.call(user: current_user, cart: @cart, payment_method: params[:payment_method])
... (argument forwarding, Ruby 2.7+) passes all arguments through without listing them. This keeps the class-method delegation a one-liner regardless of how the initializer signature changes.
Handling rollbacks across steps
Multi-step operations that mutate state need to clean up on failure. The naive approach is nested rescues everywhere. A cleaner approach is making each step reversible:
def call
reservation = InventoryService.reserve(cart.items)
charge = PaymentGateway.charge(amount: cart.total, method: payment_method)
unless charge.ok?
InventoryService.release(reservation)
return Result.fail("Payment declined")
end
order = Order.create!(user: user, items: cart.items, charge: charge)
Result.ok(order)
rescue => e
InventoryService.release(reservation) if reservation
raise
end
For operations entirely within a single database, wrap in ActiveRecord::Base.transaction and let the database handle rollback. For operations that span a database and an external service — charging a card, then writing the order — you cannot get atomicity from a transaction. You need to handle the partial-failure case explicitly: either compensating actions (release the reservation, void the charge) or an idempotent retry queue. A transaction block does not help when the external call already succeeded.
What belongs in a service object and what doesn't
Service objects solve one problem: encapsulating a multi-step business operation that doesn't belong in a model or controller. They're not a catch-all for "logic."
Use a service object for:
- Multi-model writes that should succeed or fail together
- Operations that call external services as part of their execution
- Business processes with multiple named steps that need to be testable in isolation
Don't use a service object for:
- Single-model CRUD that ActiveRecord handles cleanly
- Query logic — that belongs in scopes, query objects, or repository objects
- Transformations with no side effects — a plain method or module function is sufficient
The signal that you've over-applied the pattern is a service object with one step and no branching. CreateUser.call(params) that just calls User.create!(params) adds a layer of indirection with no return on that investment.
Testing
Service objects test cleanly because they're plain Ruby objects with explicit dependencies:
describe PlaceOrder do
let(:user) { build_stubbed(:user) }
let(:cart) { instance_double(Cart, items: [item], total: 50_00) }
let(:payment_method) { "pm_test_visa" }
context "when payment is declined" do
before do
allow(PaymentGateway).to receive(:charge).and_return(double(ok?: false, decline_reason: "insufficient funds"))
end
it "returns a failed result" do
result = PlaceOrder.call(user: user, cart: cart, payment_method: payment_method)
expect(result).to be_failed
expect(result.error).to include("insufficient funds")
end
end
end
No controller, no request spec, no HTTP overhead. The test exercises the business logic directly. Mocking PaymentGateway at the class level is straightforward because the dependency is explicit in the code — not buried in a callback or pulled from a global registry.
The structure that holds up
Keyword arguments in the initializer, private readers, a single public call method, a result object for expected outcomes, real exceptions for unexpected ones. That's the whole pattern. Every team that adopts service objects ends up converging on some version of this after enough PRs arguing about where the logic should live.
The arguments about naming (call vs execute vs perform), file organization (app/services vs app/operations), and result object gems (dry-monads, railway) are real but secondary. Get the boundaries right first — what goes in, what comes out, what constitutes a failure — and the rest is convention.