How to Use Service Objects in Rails to Keep Controllers Thin
Prerequisites
- Rails 6+ application
- Understanding of MVC pattern and Active Record
The Problem
A typical Rails controller that does too much:
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user = current_user
if @order.save
# Charge the customer
Stripe::Charge.create(
amount: @order.total_cents,
currency: "usd",
source: params[:stripe_token]
)
# Send confirmation email
OrderMailer.confirmation(@order).deliver_now
# Notify admin
AdminNotificationJob.perform_later(@order.id)
# Update inventory
@order.line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
redirect_to @order, notice: "Order placed!"
else
render :new
end
end
end
This controller knows about payments, emails, jobs, and inventory. It violates the Single Responsibility Principle and is painful to test.
The Solution: Service Objects
Service objects encapsulate a single business operation. They live in app/services/ and follow a simple convention.
# app/services/orders/create_service.rb
module Orders
class CreateService
attr_reader :user, :params, :order
def initialize(user:, params:)
@user = user
@params = params
end
def call
ActiveRecord::Base.transaction do
create_order!
charge_customer!
send_confirmation!
notify_admin!
update_inventory!
end
Result.success(order)
rescue Stripe::CardError => e
Result.failure(e.message)
end
private
def create_order!
@order = user.orders.create!(params)
end
def charge_customer!
Stripe::Charge.create(
amount: order.total_cents,
currency: "usd",
source: params[:stripe_token]
)
end
def send_confirmation!
OrderMailer.confirmation(order).deliver_later
end
def notify_admin!
AdminNotificationJob.perform_later(order.id)
end
def update_inventory!
order.line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
end
end
end
The Controller After Refactoring
class OrdersController < ApplicationController
def create
result = Orders::CreateService.new(
user: current_user,
params: order_params
).call
if result.success?
redirect_to result.value, notice: "Order placed!"
else
flash.now[:alert] = result.error
render :new
end
end
end
The Result Object Pattern
A simple value object for returning success/failure:
class Result
attr_reader :value, :error
def self.success(value)
new(success: true, value: value)
end
def self.failure(error)
new(success: false, error: error)
end
def success?
@success
end
private
def initialize(success:, value: nil, error: nil)
@success = success
@value = value
@error = error
end
end
Place Result in app/lib/result.rb or app/services/result.rb.
Folder Structure
app/services/
├── application_service.rb # Base class (optional)
├── result.rb # Result object
├── orders/
│ ├── create_service.rb
│ ├── cancel_service.rb
│ └── refund_service.rb
└── users/
└── register_service.rb
Optional Base Class
# app/services/application_service.rb
class ApplicationService
def self.call(**args)
new(**args).call
end
def call
raise NotImplementedError
end
end
Then:
class Orders::CreateService < ApplicationService
# ...
end
# Usage
result = Orders::CreateService.call(user: user, params: params)
Naming Conventions
| Convention | Example |
|---|---|
| Namespaced by resource | Orders::CreateService |
| Verb as first module | Orders, Payments |
Ends with Service | ...Service |
| One public method | call |
Testing Service Objects
# spec/services/orders/create_service_spec.rb
RSpec.describe Orders::CreateService do
let(:user) { create(:user) }
let(:product) { create(:product, stock: 10, price_cents: 1000) }
let(:params) do
{
line_items_attributes: [
{ product_id: product.id, quantity: 2 }
],
stripe_token: "tok_visa"
}
end
it "creates an order and charges the customer" do
result = described_class.new(user: user, params: params).call
expect(result).to be_success
expect(result.value.total_cents).to eq(2000)
expect(product.reload.stock).to eq(8)
end
it "rolls back on payment failure" do
allow(Stripe::Charge).to receive(:create).and_raise(Stripe::CardError.new("Declined", "param", "code"))
result = described_class.new(user: user, params: params).call
expect(result).not_to be_success
expect(Order.count).to eq(0)
expect(product.reload.stock).to eq(10)
end
end
Service objects are plain Ruby classes — no framework magic to set up. Mock external services, verify return values and side effects.
When to Use Service Objects
✅ Use when:
- A controller action spans multiple models
- Business logic is reused across controllers, jobs, or rake tasks
- The operation involves external APIs
- You need transactional boundaries spanning multiple records
❌ Don’t use when:
- It’s a simple CRUD operation (just use the controller)
- It’s a single model call (put it on the model)
- You’re just wrapping
Model.createin a service
FAQ
Q: Where do service objects go?
app/services/. Rails autoloads everything under app/.
Q: Should every controller action have a service object? No. Simple index/show/update actions don’t need one. Use services for complex writes with multiple steps.
Q: What about interactors or commands?
Same concept, different naming. Libraries like interactor gem provide structure; plain Ruby works fine for most cases.