7 min read
Service Objects in Rails: Where Business Logic Actually Belongs
Ruby on RailsArchitectureDesign Patterns
Rails gives you models, controllers and views. For a while that is enough. Then a feature arrives that does not belong to any single model — placing an order charges a card, reserves stock, sends two emails and writes an audit entry — and you have to decide where it goes.
Put it in the controller and it becomes untestable and unreusable. Put it in the model and Order starts knowing about payment gateways. Both roads end in the same place: a file nobody wants to open.
The symptom, in both directions
Here is the controller version. It works, and it is the wrong shape:
class OrdersController < ApplicationController
def create
@order = current_user.orders.build(order_params)
ActiveRecord::Base.transaction do
@order.save!
charge = Stripe::Charge.create(
amount: (@order.total * 100).to_i,
currency: "aud",
customer: current_user.stripe_customer_id,
)
@order.update!(charge_id: charge.id, status: "paid")
@order.line_items.each do |item|
item.product.decrement!(:stock_quantity, item.quantity)
end
OrderMailer.confirmation(@order).deliver_later
AdminMailer.new_order(@order).deliver_later
AuditLog.create!(user: current_user, action: "order.created", subject: @order)
end
redirect_to @order, notice: "Thanks for your order!"
rescue Stripe::CardError => e
@order.update(status: "payment_failed")
flash.now[:alert] = e.message
render :new, status: :unprocessable_entity
end
endTo test the stock decrement you have to issue an HTTP request. To reuse any of this from a Rake task or an admin action, you copy it.
Moving the whole block into Order#place! does not fix much. The model gets shorter methods but a wider job: it now depends on Stripe, on two mailers, and on the audit log. Its unit tests need the payment gateway stubbed to check a validation.
The real problem is that this behaviour is not a property of an Order record. It is a process that involves one. That process needs its own object.
A service object that earns its place
# app/services/orders/place.rb
module Orders
class Place
Result = Data.define(:success?, :order, :error)
def initialize(user:, params:, payment_gateway: PaymentGateway.new)
@user = user
@params = params
@payment_gateway = payment_gateway
end
def call
order = @user.orders.build(@params)
return failure(order, order.errors.full_messages.to_sentence) unless order.valid?
ActiveRecord::Base.transaction do
order.save!
charge = charge_for(order)
order.update!(charge_id: charge.id, status: "paid")
reserve_stock(order)
record_audit(order)
end
deliver_notifications(order)
Result.new(success?: true, order: order, error: nil)
rescue PaymentGateway::Declined => e
order.update(status: "payment_failed")
failure(order, e.message)
end
private
def charge_for(order)
@payment_gateway.charge(
amount_cents: (order.total * 100).to_i,
currency: "aud",
customer_id: @user.stripe_customer_id,
)
end
def reserve_stock(order)
order.line_items.each do |item|
item.product.reserve!(item.quantity)
end
end
def record_audit(order)
AuditLog.create!(user: @user, action: "order.created", subject: order)
end
def deliver_notifications(order)
OrderMailer.confirmation(order).deliver_later
AdminMailer.new_order(order).deliver_later
end
def failure(order, message)
Result.new(success?: false, order: order, error: message)
end
end
endThe controller becomes what a controller should be — HTTP in, HTTP out:
class OrdersController < ApplicationController
def create
result = Orders::Place.new(user: current_user, params: order_params).call
if result.success?
redirect_to result.order, notice: "Thanks for your order!"
else
@order = result.order
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
end
endFour decisions in there are worth calling out, because they are what separate a useful service from a class with a call method.
Return a result, not a boolean
A service that returns true/false forces the caller to go looking for the error. One that raises makes the happy path awkward and pushes control flow into rescue. A small result object carries both outcomes explicitly:
Result = Data.define(:success?, :order, :error)Data.define (Ruby 3.2+) gives you an immutable value object for free. Before that, Struct.new(..., keyword_init: true) does the same job.
Inject the things you cannot control
payment_gateway: defaults to the real implementation and accepts a fake. That single keyword argument is the difference between a test that hits the network and one that does not:
RSpec.describe Orders::Place do
let(:gateway) { instance_double(PaymentGateway) }
let(:user) { create(:user, :with_stripe_customer) }
let(:product) { create(:product, stock_quantity: 5) }
let(:params) { { line_items_attributes: [{ product_id: product.id, quantity: 2 }] } }
subject(:place) { described_class.new(user: user, params: params, payment_gateway: gateway) }
context "when the charge succeeds" do
before do
allow(gateway).to receive(:charge).and_return(double(id: "ch_123"))
end
it "marks the order paid and reserves stock" do
result = place.call
expect(result).to be_success
expect(result.order).to be_paid
expect(product.reload.stock_quantity).to eq(3)
end
end
context "when the card is declined" do
before do
allow(gateway).to receive(:charge).and_raise(PaymentGateway::Declined, "Card declined")
end
it "leaves stock untouched and reports the error" do
result = place.call
expect(result).not_to be_success
expect(result.error).to eq("Card declined")
expect(product.reload.stock_quantity).to eq(5)
end
end
endNo HTTP request, no controller, and the transaction rollback is genuinely verified.
Keep side effects outside the transaction
deliver_notifications runs after the transaction block closes, and that ordering is deliberate. deliver_later enqueues a job; if the job backend is Redis-backed and the transaction later rolls back, the job still runs and emails a customer about an order that does not exist. Anything that touches the outside world belongs after the commit.
Rails 7.2 added ActiveRecord::Base.transaction(&:after_commit) hooks that make this explicit, and after_commit_everywhere covers earlier versions. Either way, the principle holds: commit first, notify second.
Let the model keep its own invariants
item.product.reserve!(item.quantity) is a model method, not inlined decrement!:
class Product < ApplicationRecord
class InsufficientStock < StandardError; end
def reserve!(quantity)
with_lock do
raise InsufficientStock, "Only #{stock_quantity} left" if quantity > stock_quantity
update!(stock_quantity: stock_quantity - quantity)
end
end
endStock never going negative is a rule about a product, and it should hold no matter who calls it. Extracting a service layer does not mean models become empty data bags — it means they keep the rules that are genuinely theirs and lose the orchestration that is not.
When not to reach for a service
The pattern is easy to over-apply. A service object is not an improvement when:
It wraps a single Active Record call. Users::Create that does User.create!(params) adds a file and a layer of indirection for nothing. Call the model.
The logic belongs to one model. Formatting, derived attributes, state predicates — those are methods. If it reads only that model's own data, it stays on the model.
The behaviour is shared across models. That is a concern, not a service:
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :archived, -> { where.not(archived_at: nil) }
scope :live, -> { where(archived_at: nil) }
end
def archive!
update!(archived_at: Time.current)
end
def archived?
archived_at.present?
end
endIt is really a query. A method that builds a complex SELECT is a query object, and it reads better as a scope or a dedicated class returning a relation:
# app/queries/orders/needing_fulfilment.rb
module Orders
class NeedingFulfilment
def initialize(relation = Order.all)
@relation = relation
end
def call
@relation
.where(status: "paid")
.where(shipped_at: nil)
.where(created_at: ..3.days.ago)
.includes(:user, line_items: :product)
.order(:created_at)
end
end
endReturning a relation rather than an array matters — the caller can still paginate or add conditions.
Conventions that keep the layer navigable
Once you have twenty services, structure matters more than the individual classes.
Namespace by domain, name by verb. Orders::Place, Orders::Refund, Subscriptions::Cancel. Reading app/services should tell you what the application does:
app/services/
├── orders/
│ ├── place.rb
│ ├── refund.rb
│ └── cancel.rb
├── subscriptions/
│ ├── activate.rb
│ └── cancel.rb
└── imports/
└── process_csv.rbAvoid OrderService — a class named after a noun with "Service" appended attracts unrelated methods until it is the fat model you just escaped.
One public method. call is the convention. If a service needs two entry points, it is two services.
A consistent result type. Pick one shape and use it everywhere, so callers do not have to remember which service raises and which returns. A shared base class is enough:
# app/services/application_service.rb
class ApplicationService
Result = Data.define(:success?, :value, :error) do
def self.success(value) = new(success?: true, value: value, error: nil)
def self.failure(error, value: nil) = new(success?: false, value: value, error: error)
end
def self.call(...) = new(...).call
endDo not compose services by calling them from each other. Two services that each open a transaction and each fire notifications will surprise you. If a process needs several steps, orchestrate them in one service and keep the steps as private methods, or model the sequence explicitly.
The rule underneath all of it
Put logic in a service object when it coordinates more than one collaborator and does not belong to any of them. Leave it on the model when it protects that model's own invariants. Leave it in the controller only if it is about HTTP.
That is not a Rails-specific insight, and it is not a new one. But it is the distinction that keeps an application legible at year three, and it is much cheaper to apply while the file is still short.