RoR Service Objects
Service objects are plain Ruby classes that encapsulate a single business operation. When a controller action or model method grows too complex — calling external APIs, sending emails, processing payments, coordinating multiple models — you extract that logic into a service object. The result is code that is easier to read, test, and reuse.
The Problem Service Objects Solve
FAT CONTROLLER — too much logic in one place:
class RegistrationsController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
WelcomeEmailJob.perform_later(@user.id)
StripeService.create_customer(@user)
SlackNotifier.notify("#signups", "New user: #{@user.email}")
@user.generate_referral_code
@user.update(onboarding_step: 1)
redirect_to dashboard_path, notice: "Welcome!"
else
render :new
end
end
end
This controller does too many things. Testing it requires mocking Stripe, Slack, and mailers. Service objects fix this.
The Service Object Pattern
CLEAN CONTROLLER — delegates to service:
class RegistrationsController < ApplicationController
def create
result = UserRegistrationService.new(user_params).call
if result.success?
redirect_to dashboard_path, notice: "Welcome!"
else
@user = result.user
render :new
end
end
end
SERVICE OBJECT — all the logic in one place:
class UserRegistrationService
attr_reader :user
def initialize(params)
@params = params
end
def call
@user = User.new(@params)
if @user.save
run_post_registration_tasks
Result.new(success: true, user: @user)
else
Result.new(success: false, user: @user)
end
end
private
def run_post_registration_tasks
WelcomeEmailJob.perform_later(@user.id)
StripeService.create_customer(@user)
SlackNotifier.notify("#signups", "New user: #{@user.email}")
@user.generate_referral_code
@user.update(onboarding_step: 1)
end
Result = Struct.new(:success, :user, keyword_init: true) do
def success?
success
end
end
end
File Location Convention
app/services/ user_registration_service.rb payment_service.rb report_generator_service.rb weather_service.rb inventory_sync_service.rb
Rails automatically loads files from app/services/ — no require statement needed.
Service Object for Payment Processing
app/services/payment_service.rb
class PaymentService
def initialize(order, payment_token)
@order = order
@payment_token = payment_token
end
def call
charge = Stripe::Charge.create(
amount: (@order.total * 100).to_i,
currency: "usd",
source: @payment_token,
description: "Order ##{@order.id}"
)
@order.update(
stripe_charge_id: charge.id,
status: "paid",
paid_at: Time.current
)
OrderConfirmationMailer.send_confirmation(@order).deliver_later
Result.new(success: true, charge_id: charge.id)
rescue Stripe::CardError => e
Result.new(success: false, error: e.message)
rescue Stripe::StripeError => e
Rails.logger.error "Stripe error: #{e.message}"
Result.new(success: false, error: "Payment processing failed. Please try again.")
end
Result = Struct.new(:success, :charge_id, :error, keyword_init: true) do
def success?
success
end
end
end
Usage in controller:
class OrdersController < ApplicationController
def process_payment
@order = current_user.orders.find(params[:id])
result = PaymentService.new(@order, params[:payment_token]).call
if result.success?
redirect_to order_path(@order), notice: "Payment successful!"
else
flash[:alert] = result.error
render :checkout
end
end
end
Service Object for Data Import
app/services/csv_import_service.rb
class CsvImportService
attr_reader :errors, :imported_count
def initialize(file_path)
@file_path = file_path
@errors = []
@imported_count = 0
end
def call
CSV.foreach(@file_path, headers: true) do |row|
import_row(row)
end
self
end
def success?
@errors.empty?
end
private
def import_row(row)
product = Product.new(
name: row["name"],
price: row["price"].to_f,
sku: row["sku"]
)
if product.save
@imported_count += 1
else
@errors << "Row #{row}: #{product.errors.full_messages.join(", ")}"
end
end
end
Usage:
service = CsvImportService.new("/tmp/products.csv").call
if service.success?
flash[:notice] = "Imported #{service.imported_count} products."
else
flash[:alert] = service.errors.first(5).join(". ")
end
Testing Service Objects
Service objects are easy to test because they are plain Ruby classes with no HTTP, routing, or view dependencies:
spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
let(:order) { create(:order, total: 50.00) }
let(:token) { "tok_visa" }
describe "#call" do
context "when payment succeeds" do
before { allow(Stripe::Charge).to receive(:create).and_return(double(id: "ch_123")) }
it "marks the order as paid" do
described_class.new(order, token).call
expect(order.reload.status).to eq("paid")
end
it "returns a successful result" do
result = described_class.new(order, token).call
expect(result).to be_success
end
end
context "when card is declined" do
before { allow(Stripe::Charge).to receive(:create).and_raise(Stripe::CardError.new("Declined", nil)) }
it "returns a failed result with an error message" do
result = described_class.new(order, token).call
expect(result).not_to be_success
expect(result.error).to include("Declined")
end
end
end
end
Rules for Good Service Objects
- One service object = one business operation
- Use a single public method named
call - Accept dependencies through the initializer (easy to test)
- Return a result object instead of raising exceptions for expected failures
- Keep private methods small and single-purpose
Service objects bring order to complex application logic. They make your controllers thin, your models focused, and your business logic testable and reusable across the entire application.
