As Rails applications grow, controllers become bloated and models turn into thousand-line monsters. The solution isn't a new framework—it's a pattern that Rails developers have refined over years: service objects. This guide covers battle-tested patterns for building service objects that remain maintainable as applications scale.
The Problem: Fat Controllers and Bloated Models
Consider a user registration flow that needs to create a user, send a welcome email, set up default preferences, notify the sales team, and track analytics. Stuffing all of this into a controller action or model callback creates code that's difficult to test, impossible to reuse, and painful to modify.
Service objects solve this by encapsulating a single business operation into a dedicated class. Each service has one job, accepts explicit inputs, and returns predictable outputs.
The Basic Service Object Pattern
A well-structured service object follows a simple contract: it has a single public method, accepts dependencies explicitly, and returns a result that indicates success or failure.
# app/services/application_service.rb
class ApplicationService
def self.call(...)
new(...).call
end
def call
raise NotImplementedError, "#{self.class} must implement #call"
end
private
def success(data = nil)
Result.new(success: true, data: data, error: nil)
end
def failure(error)
Result.new(success: false, data: nil, error: error)
end
Result = Data.define(:success, :data, :error) do
def success? = success
def failure? = !success
end
endThis base class uses Ruby 3.2+'s Data.define for an immutable result object. The .call class method provides a clean interface without needing to manually instantiate services throughout the codebase.
Implementing a Real Service: User Registration
Here's how a user registration service looks using this pattern:
# app/services/users/registration_service.rb
module Users
class RegistrationService < ApplicationService
def initialize(params:, ip_address: nil)
@params = params
@ip_address = ip_address
end
def call
user = User.new(user_params)
ActiveRecord::Base.transaction do
unless user.save
return failure(user.errors.full_messages.join(", "))
end
create_default_preferences(user)
schedule_welcome_email(user)
notify_sales_team(user) if user.company_email?
track_signup(user)
end
success(user)
rescue ActiveRecord::RecordInvalid => e
failure(e.message)
rescue StandardError => e
Rails.error.report(e)
failure("Registration failed. Please try again.")
end
private
attr_reader :params, :ip_address
def user_params
params.slice(:email, :password, :password_confirmation, :name)
end
def create_default_preferences(user)
UserPreference.create!(
user: user,
email_notifications: true,
timezone: "UTC"
)
end
def schedule_welcome_email(user)
UserMailer.welcome(user).deliver_later(wait: 5.minutes)
end
def notify_sales_team(user)
SalesNotificationJob.perform_later(user_id: user.id)
end
def track_signup(user)
Analytics::TrackEventJob.perform_later(
event: "user_signed_up",
user_id: user.id,
properties: { ip_address: ip_address }
)
end
end
endThe controller becomes remarkably simple:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
result = Users::RegistrationService.call(
params: registration_params,
ip_address: request.remote_ip
)
if result.success?
sign_in(result.data)
redirect_to dashboard_path, notice: "Welcome aboard!"
else
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:user).permit(:email, :password, :password_confirmation, :name)
end
endComposing Services for Complex Operations
Real business logic often requires orchestrating multiple operations. Rather than creating monolithic services, compose smaller services together:
# app/services/orders/checkout_service.rb
module Orders
class CheckoutService < ApplicationService
def initialize(cart:, payment_params:, user:)
@cart = cart
@payment_params = payment_params
@user = user
end
def call
return failure("Cart is empty") if cart.empty?
ActiveRecord::Base.transaction do
order = create_order
return failure(order.errors.full_messages.join(", ")) unless order.persisted?
payment_result = process_payment(order)
return payment_result if payment_result.failure?
inventory_result = reserve_inventory(order)
if inventory_result.failure?
refund_payment(payment_result.data)
return inventory_result
end
finalize_order(order, payment_result.data)
success(order.reload)
end
rescue StandardError => e
Rails.error.report(e)
failure("Checkout failed. Your card was not charged.")
end
private
attr_reader :cart, :payment_params, :user
def create_order
Order.create(
user: user,
line_items_attributes: cart.items.map(&:to_line_item_params),
total_cents: cart.total_cents
)
end
def process_payment(order)
Payments::ProcessService.call(
order: order,
payment_params: payment_params
)
end
def reserve_inventory(order)
Inventory::ReservationService.call(order: order)
end
def refund_payment(payment)
Payments::RefundService.call(payment: payment)
end
def finalize_order(order, payment)
order.update!(payment: payment, status: :confirmed)
OrderConfirmationJob.perform_later(order_id: order.id)
cart.clear!
end
end
endEach sub-service (Payments::ProcessService, Inventory::ReservationService) follows the same pattern, making them independently testable and reusable across different contexts.
Testing Service Objects
Service objects shine in testing because they have explicit inputs and outputs with no hidden dependencies:
# spec/services/users/registration_service_spec.rb
RSpec.describe Users::RegistrationService do
describe ".call" do
let(:valid_params) do
{
email: "[email protected]",
password: "secure_password_123",
password_confirmation: "secure_password_123",
name: "Test User"
}
end
context "with valid params" do
it "creates a user and returns success" do
result = described_class.call(params: valid_params)
expect(result).to be_success
expect(result.data).to be_a(User)
expect(result.data).to be_persisted
end
it "creates default preferences" do
expect {
described_class.call(params: valid_params)
}.to change(UserPreference, :count).by(1)
end
it "schedules a welcome email" do
expect {
described_class.call(params: valid_params)
}.to have_enqueued_mail(UserMailer, :welcome)
end
end
context "with invalid params" do
let(:invalid_params) { valid_params.merge(email: "invalid") }
it "returns failure with error message" do
result = described_class.call(params: invalid_params)
expect(result).to be_failure
expect(result.error).to include("Email")
end
it "does not create any records" do
expect {
described_class.call(params: invalid_params)
}.not_to change(User, :count)
end
end
end
endCommon Mistakes to Avoid
Avoid services that do too much. If a service name includes "and" (like CreateUserAndSendEmailAndNotifyAdmin), it's doing too much. Break it into smaller, composable services.
Don't hide dependencies. Pass collaborators explicitly rather than instantiating them inside the service. This makes testing straightforward and dependencies visible.
Always return a result object. Returning true/false or raising exceptions for flow control makes calling code fragile. The result object pattern provides consistent handling across all services.
Keep services stateless. Services should not maintain state between calls. Each .call invocation should be independent.
Organizing Services in Rails 8
A clean directory structure keeps services discoverable:
app/services/
├── application_service.rb
├── users/
│ ├── registration_service.rb
│ ├── password_reset_service.rb
│ └── profile_update_service.rb
├── orders/
│ ├── checkout_service.rb
│ └── cancellation_service.rb
├── payments/
│ ├── process_service.rb
│ └── refund_service.rb
└── inventory/
└── reservation_service.rbRails 8 autoloads everything in app/ subdirectories, so no configuration is needed. The namespace modules (like Users, Orders) keep related services grouped and prevent naming collisions.
Summary
Service objects provide a proven pattern for managing complex business logic in Rails applications. By following a consistent structure—single public method, explicit inputs, result objects—services become easy to write, test, and maintain. Start extracting services when controller actions exceed a dozen lines or when model callbacks grow unwieldy. The initial investment in structure pays dividends as applications scale.