Service objects in Rails: a hands-on guide for engineers
Your Rails controller just grew from 20 lines to 80. The model callbacks are firing in ways nobody predicted. Business logic is scattered across three different files and the team is hesitant to touch any of it. This is where service objects become invaluable—they're not a Rails convention, but a proven pattern that keeps your codebase maintainable as workflows grow in complexity.
A service object is a plain Ruby class that encapsulates business logic, lives outside your model or controller, and typically handles a single responsibility. It's the middle ground between cramming logic into models and leaving it floating in controller actions. Over day 42 of a Rails development journey, we've reached the point where understanding service objects separates junior code from production-ready systems.
When to use—Workflows that touch many models
Service objects shine when you have a workflow that spans multiple models or integrates external systems. A classic example: processing an order. This isn't just creating an Order record. You need to:
- Validate inventory across multiple SKUs
- Calculate taxes and shipping
- Reserve stock
- Charge a payment processor
- Send a confirmation email
- Log the transaction for auditing
Each step touches a different part of your system. Without a service object, your controller becomes a 200-line god method, or you scatter logic across model callbacks that trigger unpredictably. A service object gives this workflow a single, testable home.
Consider another real-world scenario: bulk user imports. You're accepting a CSV, validating records, creating or updating users, assigning them to teams, and notifying relevant admins. This isn't model logic—it's orchestration. A service object handles it cleanly.
class ProcessOrderService
def initialize(order_params, user)
@order_params = order_params
@user = user
end
def call
order = Order.new(@order_params)
return failure("Inventory unavailable") unless check_inventory
return failure("Payment declined") unless process_payment(order)
order.save!
reserve_stock(order)
send_confirmation_email(order)
success(order)
end
private
def check_inventory
# Check stock across line items
end
def process_payment(order)
# Call Stripe/payment gateway
end
endThe controller then becomes simple:
def create
result = ProcessOrderService.new(order_params, current_user).call
if result.success?
redirect_to result.data, notice: "Order placed"
else
render :new, alert: result.error
end
endYour controller doesn't need to know how payment is processed or inventory is checked. It just calls the service and handles the response. This makes the controller's intent crystal clear.
Shape—A class with a call method
The convention is straightforward: a service object is a class with a single public method, almost always named call. Some teams use execute or perform, but call is the Rails idiom and lets you invoke it as ServiceClass.new(...).call or even ServiceClass.call(...) if you add a class method.
The initialization method accepts the data your service needs. Dependencies like loggers, payment clients, or external APIs can be injected here or pulled from Rails environment. Here's a typical structure:
class SendInvitationService
def initialize(event, recipient_email, invited_by_user)
@event = event
@recipient_email = recipient_email
@invited_by_user = invited_by_user
end
def call
# Orchestration happens here
validate_recipient
create_invitation
send_email
log_action
end
private
def validate_recipient
# Business logic
end
def create_invitation
# Create or update records
end
def send_email
# Queue a job or call mailer
end
def log_action
# Audit trail
end
endKeep service classes focused. One responsibility per class. If your service object has 15 private methods, you've probably bundled too much. Extract smaller services and call them from your primary service instead.
Where should service files live? Create a app/services directory. Rails autoloads it by default. Name files to match the class: ProcessOrderService lives in app/services/process_order_service.rb. For large teams, nest them by domain: app/services/payments/charge_card_service.rb and app/services/inventory/reserve_stock_service.rb.
Return results—Success or failure objects
A service's return value matters. Returning true or false loses context about why something failed. Returning the object itself is ambiguous—did it succeed or not? The cleanest approach is a result object that encapsulates success, failure, and the data or error message.
You can build a simple result class:
class ServiceResult
attr_reader :data, :error
def initialize(success:, data: nil, error: nil)
@success = success
@data = data
@error = error
end
def success?
@success
end
def failure?
!@success
end
endThen your service returns it:
class ProcessOrderService
def call
order = Order.new(@order_params)
unless check_inventory
return ServiceResult.new(success: false, error: "Inventory unavailable")
end
unless process_payment(order)
return ServiceResult.new(success: false, error: "Payment declined")
end
order.save!
ServiceResult.new(success: true, data: order)
end
private
def success(data)
ServiceResult.new(success: true, data: data)
end
def failure(error)
ServiceResult.new(success: false, error: error)
end
endMany teams use gems like dry-monads or result to standardize this. But for most codebases, a lightweight result object is enough. The key is consistency: every service method always returns the same type of result. Your caller knows what to expect and can reliably check result.success? and access result.data or result.error.
This pattern also makes error handling explicit. Callers must decide how to handle failures—they can't accidentally ignore them like they might with a boolean return.
Do not overdo it—Simple logic belongs in models
Not everything needs a service object. If your logic is straightforward, belongs to a single model, and doesn't orchestrate across multiple systems, keep it in the model. Overusing services creates unnecessary indirection.
Ask yourself: Does this logic involve multiple models or external systems? If no, it probably belongs in the model as a method or scope. Calculating a user's age from their birthdate? Model method. Validating an email format? Model validation. Checking if an order is overdue? Model scope or instance method.
class User < ApplicationRecord
def age
Date.today.year - birthdate.year
end
def send_reminder
# Simple: involves one model, one action
ReminderMailer.send_later(self)
end
endBut coordinating a complex user onboarding flow that creates accounts, sends emails, provisions API keys, and adds them to a CRM? That's a service:
class OnboardUserService
def initialize(user_attributes, team)
@user_attributes = user_attributes
@team = team
end
def call
user = User.create!(@user_attributes)
@team.users << user
provision_api_key(user)
sync_to_crm(user)
send_welcome_email(user)
success(user)
end
endThe line is fuzzy, but the principle is clear: services orchestrate; models encapsulate single-entity logic. As your codebase grows, this distinction keeps complexity manageable.
Common mistakes
- Creating services for trivial logic. A service that only creates one record or runs one validation adds no value. It's overhead without benefit.
- Making services stateful. Avoid instance variables that change during
call. Keep your service predictable: initialize once, call once, get a result. If you need multiple steps with changing state, you're probably orchestrating multiple services, not writing one. - Putting all business logic in services and leaving models anemic. Models should still contain their domain logic. Services orchestrate between models, not replace them.
- Services that don't return consistent results. If some paths return an object, others return true/false, and still others return nil, your caller code becomes defensive and ugly. Always return a result object.
- Services that talk to too many external systems without clear boundaries. If a service calls five different APIs and has ten error paths, it's too big. Break it into smaller services, each handling one integration.
Key takeaways
- Service objects encapsulate multi-model workflows and external integrations, keeping controllers and models clean.
- Follow the convention: a class with an
initializeand acallmethod, placed inapp/services. - Always return a consistent result object that indicates success or failure with relevant data or error messages.
- Use services for orchestration; keep simple, single-model logic in your models.
- Test services independently, without instantiating controllers or running the full stack—they're fast to test and easy to reason about.
- Name services after the action they perform:
ProcessOrderService,SendInvitationService,SyncUserToCrmService.






Comments
Loading comments…