Service Objects in Ruby
Service objects are plain Ruby objects that encapsulate a single piece of business logic. They are part of a broader pattern sometimes called the Service Layer, and they exist to solve a common problem: fat controllers and bloated models.
Instead of dumping all your logic into a controller action or an ActiveRecord model, you extract specific operations into dedicated objects. Each service object does one thing, and does it well.
Why Use Service Objects?
Rails applications tend to accumulate logic in the wrong places. A typical controller action might validate input, query the database, process data, send an email, and update cache, all in one place. Over time this becomes impossible to test and difficult to change.
Service objects push this logic down into objects that are easy to reason about:
class ChargeCustomer
def initialize(customer, payment_params)
@customer = customer
@payment_params = payment_params
end
def call
return failure("Customer has no payment method") unless @customer.payment_method
charge = PaymentGateway.charge(@customer.payment_method, amount)
if charge.successful?
@customer.orders.create!(total: amount)
Success.new(charge)
else
failure(charge.error_message)
end
end
private
attr_reader :customer, :payment_params
def amount
payment_params[:amount]
end
end
The controller becomes a thin wrapper:
class OrdersController < ApplicationController
def create
result = ChargeCustomer.new(current_user, order_params).call
if result.success?
redirect_to result.order, notice: "Order placed!"
else
@error = result.error
render :new
end
end
end
Structuring a Service Object
The most important rule: single responsibility. A service object should do one thing. If you find yourself writing “and” in the method name, split it up.
The Callable Interface
The most common pattern is a call method. This makes the object respond to .call, which feels like a function and works well with Rails pipelines:
class ImportUsers
def initialize(csv_file)
@csv_file = csv_file
end
def call
rows.each do |row|
User.find_or_create_by!(email: row[:email]) do |user|
user.name = row[:name]
user.role = row[:role]
end
end
end
private
attr_reader :csv_file
def rows
CSV.read(csv_file, headers: true, header_converters: :symbol)
end
end
# Usage
ImportUsers.new("users.csv").call
You can also include Callable to make the interface explicit:
module Callable
def call(*args)
new(*args).call
end
end
class ImportUsers
include Callable
def initialize(csv_file)
@csv_file = csv_file
end
def call
# ...
end
end
ImportUsers.call("users.csv")
Naming Conventions
Service objects are typically named after the action they perform:
| Name | Action |
|---|---|
ChargeCustomer | Charges a customer’s card |
ImportUsers | Imports users from a CSV |
CalculateRefund | Calculates a refund amount |
PublishArticle | Publishes an article and notifies subscribers |
ArchiveOrder | Archives an order and cleans up related data |
The name should describe what happens, not what the object is. RefundCalculator is better than RefundService.
Returning Result Objects
Service objects should not raise exceptions for expected failure cases. Instead, return a result object that describes the outcome:
class Result
attr_reader :data, :error
def initialize(data: nil, error: nil)
@data = data
@error = error
end
def success?
error.nil?
end
def failure?
!success?
end
end
Using this pattern in a service:
class ChargeCustomer
def initialize(customer, amount)
@customer = customer
@amount = amount
end
def call
return failure("No payment method") unless @customer.payment_method
charge = PaymentGateway.charge(@customer.payment_method, @amount)
if charge.succeeded?
@customer.orders.create!(total: @amount)
Success.new(data: { order: @customer.orders.last })
else
failure(charge.error_message)
end
end
private
attr_reader :customer, :amount
def failure(message)
Result.new(error: message)
end
end
This makes the controller logic clean and explicit:
result = ChargeCustomer.new(user, 5000).call
if result.success?
@order = result.data[:order]
redirect_to @order
else
flash[:error] = result.error
redirect_to checkout_path
end
Raising Exceptions
For truly unexpected errors, let exceptions propagate:
class ProcessPayment
def call
gateway = PaymentGateway.new
response = gateway.charge(payment_params)
unless response.success?
raise PaymentError, "Gateway returned: #{response.error_code}"
end
response
end
end
Use exceptions when something is genuinely wrong and the caller cannot reasonably handle it. Use result objects for expected failure paths.
Composing Services
Complex workflows often involve multiple service objects working together. You can compose services by having one service call another:
class RegisterUser
def initialize(user_params)
@user_params = user_params
end
def call
create_user_result = CreateUser.call(user_params)
return create_user_result if create_user_result.failure?
SendWelcomeEmail.call(create_user_result.data[:user])
create_user_result
end
private
attr_reader :user_params
end
Keep the orchestration at a high level. If you find yourself nesting four or five levels deep, consider a workflow or command object instead.
Service Object Alternatives
Service objects are not the only way to organize business logic. Here are some alternatives:
Plain Ruby Objects
Sometimes a module with a class method is simpler:
module UserImporter
def self.call(csv_path)
new(csv_path).call
end
def initialize(csv_path)
@csv_path = csv_path
end
def call
CSV.read(@csv_path, headers: true).each do |row|
User.find_or_create_by!(email: row[:email])
end
end
end
UserImporter.call("users.csv")
Interactors
The interactor gem formalizes the service object pattern with a clean interface and built-in rollback support:
class CreateOrder
include Interactor
def call
order = Order.create!(context.order_params)
context.order = order
rescue ActiveRecord::RecordInvalid
context.fail!(message: "Order could not be created")
end
end
# Usage
result = CreateOrder.call(order_params: { user_id: 1, total: 100 })
if result.success?
puts result.order
else
puts result.message
end
Interactors add overhead but provide a consistent interface across your codebase and automatic transaction handling.
ActiveModel::Model
For service objects that need Rails validation:
class ProcessRefund
include ActiveModel::Model
attr_accessor :order, :amount, :reason
validates :order, presence: true
validates :amount, numericality: { greater_than: 0 }
def call
return failure("Invalid input") unless valid?
refund = RefundProcessor.execute(order, amount)
SendRefundConfirmation.call(order.user, refund)
Success.new(data: { refund: refund })
end
end
This gives you access to valid?, errors, and save without inheriting from ApplicationRecord.
Testing Service Objects
Service objects are easy to test because they have no Rails dependencies:
RSpec.describe ChargeCustomer do
describe "#call" do
it "returns success when charge goes through" do
customer = double("Customer", payment_method: "pm_123", orders: double("orders"))
allow(customer.orders).to receive(:create!).and_return(double("order", id: 42))
allow(PaymentGateway).to receive(:charge).and_return(
double(successful?: true, id: "ch_123")
)
result = described_class.new(customer, 1000).call
expect(result.success?).to be true
end
it "returns failure when customer has no payment method" do
customer = double("Customer", payment_method: nil, orders: double("orders"))
result = described_class.new(customer, 1000).call
expect(result.failure?).to be true
expect(result.error).to include("No payment method")
end
end
end
You can use plain doubles and stubs. No database, no controller tests needed. Each service object is a unit.
Testing with Dependencies
When a service depends on other services, use dependency injection to make testing straightforward:
class ProcessSubscription
def initialize(user:, subscription_updater: SubscriptionUpdater, mailer: UserMailer)
@user = user
@subscription_updater = subscription_updater
@mailer = mailer
end
def call
result = @subscription_updater.call(user: @user)
if result.success?
@mailer.send_welcome(@user)
end
result
end
end
In tests, pass test doubles:
RSpec.describe ProcessSubscription do
it "sends welcome email on success" do
user = build(:user)
fake_updater = -> { Success.new }
fake_mailer = double("mailer")
described_class.new(user: user, subscription_updater: fake_updater, mailer: fake_mailer).call
expect(fake_mailer).to have_received(:send_welcome).with(user)
end
end
When to Use Service Objects
Service objects work well when:
- A controller action needs multiple database writes or external API calls
- Logic is reused across multiple controllers
- You want to test complex business rules in isolation
- Your models are becoming hard to navigate
Avoid service objects for:
- One-off logic used in a single controller action and nowhere else
- Pure data transformations that belong in a model method
- Simple validations that can use
ActiveModel::Model
See Also
- Dependency Injection in Ruby — How to pass dependencies into service objects cleanly
- Struct — Lightweight Data Objects in Ruby — Using Struct for simple data containers in your services