Rails Service Objects: Patterns That Actually Work in Production
Your Rails controllers are getting fat. Your models know too much. You’ve read that service objects are the answer, but every blog post shows a different pattern and none of them address what happens when your service needs to call another service that calls a third service and something fails halfway through.
Here’s what actually works after running service objects in production Rails apps (Rails 7.1+ and 8.0) for years.
What a Service Object Actually Is
A service object is a plain Ruby class responsible for a single business operation. It takes input, does one thing, and returns a result. That’s it.
The key word is operation, not thing. UserCreator is a service. UserHelper is not — that’s just a junk drawer with a class name.
# app/services/users/create.rb
module Users
class Create
def initialize(params:, invited_by: nil)
@params = params
@invited_by = invited_by
end
def call
user = User.new(@params)
ActiveRecord::Base.transaction do
user.save!
Onboarding::SendWelcomeEmail.new(user: user).call
track_referral(user) if @invited_by
end
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors)
end
private
def track_referral(user)
Referrals::Track.new(referrer: @invited_by, referred: user).call
end
end
end
This handles user creation, welcome email, and referral tracking in one transaction. The controller stays thin:
class UsersController < ApplicationController
def create
result = Users::Create.new(params: user_params).call
if result.success?
redirect_to dashboard_path
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
end
The Result Object Pattern
Every service needs to communicate success or failure. Returning true/false works until you need error messages. Raising exceptions works until you need to handle expected failures gracefully. A simple result object handles both:
# app/services/result.rb
class Result
attr_reader :value, :errors
def initialize(success:, value: nil, errors: nil)
@success = success
@value = value
@errors = errors
end
def success? = @success
def failure? = !@success
def self.success(value = nil)
new(success: true, value: value)
end
def self.failure(errors)
new(success: false, errors: Array(errors))
end
end
Thirty lines. No gems. You own the code. I’ve seen teams install dry-monads or interactor for this and spend more time learning the gem API than writing business logic.
If you do want a gem, dry-monads is solid for teams comfortable with functional patterns. But start simple. You can always add complexity when you actually need it.
Naming and File Organization
Put services in app/services/, namespaced by domain:
app/services/
├── result.rb
├── users/
│ ├── create.rb
│ ├── deactivate.rb
│ └── merge_accounts.rb
├── orders/
│ ├── place.rb
│ ├── cancel.rb
│ └── refund.rb
└── reports/
├── generate_monthly.rb
└── export_csv.rb
Name services with verbs, not nouns. Orders::Place, not OrderPlacer. Users::Deactivate, not UserDeactivationService. The Service suffix is redundant — everything in app/services/ is a service.
Rails autoloading picks up app/services/ by default in Rails 7+ with Zeitwerk. No configuration needed.
When to Extract a Service Object
Not everything needs a service. Here’s the decision framework I use:
Extract to a service when:
- The operation involves multiple models or external systems
- The logic doesn’t belong to any single model
- The same operation is triggered from multiple places (controller, background job, console)
- The operation has complex error handling or rollback logic
Keep it in the model when:
- It’s a single-model validation or callback
- It’s a query scope
- It’s a simple calculation on model attributes
Keep it in the controller when:
- It’s just parameter filtering and a single model save
- There’s no business logic beyond CRUD
The worst service objects I’ve seen are ones that wrap User.create(params) in a class. That’s not abstraction — it’s indirection.
Error Handling That Doesn’t Collapse
The tricky part is service composition — when services call other services. Here’s what goes wrong and how to fix it.
Problem: Exception Cascading
# This breaks badly when SendWelcomeEmail raises
module Users
class Create
def call
ActiveRecord::Base.transaction do
user.save!
Onboarding::SendWelcomeEmail.new(user: user).call # raises!
end # transaction rolls back
Result.success(user)
rescue StandardError => e
Result.failure(e.message) # swallows everything
end
end
end
Rescuing StandardError swallows bugs. Rescuing nothing means callers deal with raw exceptions. The fix:
def call
user = User.new(@params)
ActiveRecord::Base.transaction do
user.save!
email_result = Onboarding::SendWelcomeEmail.new(user: user).call
raise ActiveRecord::Rollback unless email_result.success?
end
if user.persisted?
Result.success(user)
else
Result.failure("User creation failed — email delivery issue")
end
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
end
Only rescue exceptions you expect. Let bugs bubble up. Use ActiveRecord::Rollback for controlled transaction failures.
Problem: Non-Critical Failures Blocking Critical Operations
Sometimes a service calls another service that isn’t essential. A welcome email failing shouldn’t prevent user creation:
def call
user = User.new(@params)
ActiveRecord::Base.transaction do
user.save!
end
# Outside the transaction — non-critical
begin
Onboarding::SendWelcomeEmail.new(user: user).call
rescue => e
Rails.logger.error("Welcome email failed for user #{user.id}: #{e.message}")
ErrorTracker.notify(e)
end
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
end
Or better — push non-critical work to a background job:
def call
user = User.new(@params)
user.save!
OnboardingMailer.welcome(user).deliver_later
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
end
Testing Service Objects
Services are easy to test because they’re plain Ruby. No controller context, no view rendering:
# test/services/users/create_test.rb
require "test_helper"
class Users::CreateTest < ActiveSupport::TestCase
test "creates user with valid params" do
result = Users::Create.new(
params: { email: "test@example.com", name: "Test User", password: "secure123" }
).call
assert result.success?
assert_equal "test@example.com", result.value.email
end
test "returns failure with invalid params" do
result = Users::Create.new(params: { email: "" }).call
assert result.failure?
assert_includes result.errors.first, "Email"
end
test "rolls back everything on failure" do
assert_no_difference "User.count" do
Users::Create.new(params: { email: "bad" }).call
end
end
test "tracks referral when invited_by is present" do
referrer = users(:alice)
assert_difference "Referral.count" do
Users::Create.new(
params: { email: "new@example.com", name: "New", password: "secure123" },
invited_by: referrer
).call
end
end
end
Test the public interface: call with inputs, assert on the result. Don’t test private methods directly.
The .call Convention
You’ll see three patterns in the wild:
# Instance method (my preference)
Users::Create.new(params: params).call
# Class method delegating to instance
Users::Create.call(params: params)
# Proc-style with .()
Users::Create.(params: params)
I use instance methods because constructor injection makes dependencies explicit and testable. The class method shorthand works fine too — pick one and be consistent across your codebase.
If you want the class method sugar:
module Callable
extend ActiveSupport::Concern
class_methods do
def call(...)
new(...).call
end
end
end
Include it in your services and you get both options.
Real-World Complexity: Order Processing
Here’s a production-style service that handles a multi-step order flow:
# app/services/orders/place.rb
module Orders
class Place
def initialize(cart:, payment_method:, user:)
@cart = cart
@payment_method = payment_method
@user = user
end
def call
return Result.failure("Cart is empty") if @cart.empty?
return Result.failure("Insufficient inventory") unless inventory_available?
order = nil
ActiveRecord::Base.transaction do
order = build_order
order.save!
reserve_inventory!(order)
charge = process_payment!(order)
order.update!(payment_reference: charge.id)
end
# Post-transaction side effects
OrderMailer.confirmation(order).deliver_later
Analytics.track("order_placed", order_id: order.id, total: order.total)
Result.success(order)
rescue Payments::CardDeclined => e
Result.failure("Payment declined: #{e.message}")
rescue Inventory::ReservationFailed => e
Result.failure("Item unavailable: #{e.message}")
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
end
private
def inventory_available?
@cart.line_items.all? { |item| item.product.stock >= item.quantity }
end
def build_order
Order.new(
user: @user,
line_items: @cart.line_items.map(&:dup),
total: @cart.total,
status: :pending
)
end
def reserve_inventory!(order)
order.line_items.each do |item|
Inventory::Reserve.new(product: item.product, quantity: item.quantity).call
end
end
def process_payment!(order)
Payments::Charge.new(
amount: order.total,
payment_method: @payment_method,
description: "Order ##{order.id}"
).call
end
end
end
Each step is its own method or service. The transaction wraps the critical path. Side effects (email, analytics) happen after the transaction commits. Specific exceptions map to user-facing error messages.
Common Mistakes
The God Service. A service that does ten things isn’t a service — it’s a procedure wearing a class costume. If your call method is over 30 lines, break it up.
Testing internals. Don’t mock reserve_inventory! and process_payment! in isolation. Test the service end-to-end. Use VCR or WebMock for external APIs, but test the integration.
Returning mixed types. If call sometimes returns a User, sometimes returns nil, and sometimes raises — pick a pattern. The Result object eliminates this entire class of bugs.
Skipping the transaction. If your service writes to multiple tables, wrap it in a transaction. I’ve seen production databases with orphaned records because someone forgot this.
FAQ
Should I use a gem like Interactor or Trailblazer for service objects?
For most Rails apps, plain Ruby classes with a Result object are enough. Interactor adds a context object and hooks (before/after/around) that create coupling between steps. Trailblazer is a full architectural framework — useful if you’re building something complex, but heavy for typical CRUD apps. Start with plain classes. Introduce a gem only when you hit specific pain that the gem solves.
How do service objects relate to Rails concerns?
They solve different problems. Concerns share behavior across models (like Taggable or Searchable). Service objects encapsulate business operations that span multiple models or external systems. A concern shouldn’t call a service, and a service shouldn’t include a concern. Keep them separate.
Should service objects be called from model callbacks?
No. Model callbacks should handle model-internal consistency (setting defaults, maintaining counter caches). Calling services from callbacks creates hidden side effects that make debugging painful and testing fragile. Trigger services from controllers or background jobs where the intent is explicit.
How do I handle authorization in service objects?
Pass the current user as an argument and check permissions early:
def call
return Result.failure("Not authorized") unless @user.can_manage_orders?
# ... rest of the operation
end
Don’t reach for Current.user inside services — it creates an implicit dependency that breaks in background jobs and tests. Explicit arguments are always easier to reason about.
What’s the difference between a service object and a form object?
A form object handles input validation and coercion for a specific form. A service object handles business logic for a specific operation. They can work together: the controller passes validated form data to a service. If your service is doing a lot of validation, extract a form object using ActiveModel::Model.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation