Service Objects in Rails — Keeping Controllers Thin Without Losing Your Mind
Fat models, skinny controllers was the mantra for years. Then models got fat and we realized that was wrong too. Business logic crammed into ActiveRecord models means models responsible for database persistence, validations, callbacks, associations, and complex domain logic — all at once. Service objects are the practical answer: plain Ruby classes that hold one piece of business logic, called from controllers, background jobs, or other services. Simple idea. Worth understanding how to do it well.
What a Service Object Is
A service object is a plain Ruby class that encapsulates a single business operation. It typically:
- Takes its dependencies through the constructor
- Exposes one public method (commonly
call) - Returns a result that signals success or failure
- Has no knowledge of HTTP, views, or controller infrastructure
That’s it. No gem required, no base class needed. The pattern is the value.
Example:
# app/services/register_user.rb
class RegisterUser
def initialize(params)
@params = params
end
def call
user = User.new(@params)
if user.save
send_welcome_email(user)
track_signup(user)
Result.new(success: true, user: user)
else
Result.new(success: false, errors: user.errors)
end
end
private
def send_welcome_email(user)
UserMailer.welcome_email(user).deliver_later
end
def track_signup(user)
Analytics.track(user_id: user.id, event: "signup")
end
Result = Struct.new(:success, :user, :errors, keyword_init: true) do
def success? = success
end
end
The controller becomes thin:
Example:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
result = RegisterUser.new(registration_params).call
if result.success?
redirect_to dashboard_path, notice: "Welcome!"
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:user).permit(:name, :email, :password)
end
end
The controller handles HTTP: parse params, call the service, render or redirect. The service handles the business logic: create the user, send email, track event. Each has one job.
The Result Object Pattern
Returning a rich result object rather than a boolean or raising exceptions makes the caller’s code cleaner and more explicit:
Example:
class ProcessPayment
Result = Struct.new(:success, :charge_id, :error_message, keyword_init: true) do
def success? = success
def failure? = !success
end
def initialize(user:, amount:, payment_method_id:)
@user = user
@amount = amount
@payment_method_id = payment_method_id
end
def call
charge = Stripe::Charge.create(
amount: (@amount * 100).to_i,
currency: "usd",
customer: @user.stripe_customer_id,
payment_method: @payment_method_id
)
Result.new(success: true, charge_id: charge.id)
rescue Stripe::CardError => e
Result.new(success: false, error_message: e.message)
end
end
Example:
# In controller or background job
result = ProcessPayment.new(
user: current_user,
amount: cart.total,
payment_method_id: params[:payment_method_id]
).call
if result.success?
Order.create!(charge_id: result.charge_id, user: current_user)
redirect_to confirmation_path
else
flash[:error] = result.error_message
render :checkout
end
The result object makes both outcomes explicit and keeps exception handling in the service where it belongs.
File Organization
Services live in app/services/. Rails autoloads this directory, so no require statements needed.
For larger apps, namespace by domain:
app/services/
users/
register_user.rb
update_profile.rb
deactivate_account.rb
payments/
process_payment.rb
issue_refund.rb
notifications/
send_digest.rb
Example:
# Namespaced
module Payments
class ProcessPayment
# ...
end
end
result = Payments::ProcessPayment.new(...).call
Namespacing keeps app/services/ navigable as the app grows and groups related operations logically.
Testing Services
Services are easy to test because they’re plain Ruby. No HTTP setup, no controller stack, no request/response cycle:
Example:
# spec/services/register_user_spec.rb
RSpec.describe RegisterUser do
describe "#call" do
context "with valid params" do
let(:params) { { name: "Alice", email: "[email protected]", password: "secret123" } }
it "creates a user" do
expect { RegisterUser.new(params).call }.to change(User, :count).by(1)
end
it "returns a successful result" do
result = RegisterUser.new(params).call
expect(result).to be_success
expect(result.user.email).to eq("[email protected]")
end
it "sends a welcome email" do
expect { RegisterUser.new(params).call }
.to have_enqueued_mail(UserMailer, :welcome_email)
end
end
context "with invalid params" do
let(:params) { { name: "", email: "not-an-email", password: "x" } }
it "returns a failure result" do
result = RegisterUser.new(params).call
expect(result).to be_failure
expect(result.errors).not_to be_empty
end
end
end
end
Unit tests on services run fast. No database when you don’t need it. No mocking of the HTTP stack.
Pro-Tip: Keep services focused on one operation — resist the temptation to add
update_and_notifyandupdate_without_notifyto the same class. When a service needs two modes, it’s two services. The “one public method” rule enforces this naturally. If you find yourself adding flags or switches to change whatcalldoes, that’s the signal to split.
What Doesn’t Belong in a Service
Services aren’t a dumping ground for everything that doesn’t fit controllers or models. Query logic belongs in query objects or scopes. Formatting logic belongs in presenters or serializers. Decorating records belongs in decorators.
Service objects are specifically for orchestrating — coordinating multiple operations (save a record, send an email, fire an event, call an API) into a single business operation. If your service is doing one thing that could be a model method, put it in the model.
Conclusion
Service objects solve a real architectural problem without requiring a framework or complex abstraction. They’re plain Ruby classes. They have one job. They’re easy to test, easy to find, and easy to change without touching controllers or models. Start using them when you notice controllers doing more than parsing params and routing to views, or when models are accumulating methods that don’t relate to persistence. The bar is low; the payoff in maintainability is real.
FAQs
Q1: Should every action have a service object?
No. Simple CRUD that maps directly to a model — create a record, update it, delete it — often doesn’t need a service. The signal for a service is multiple operations that need to succeed or fail together, or orchestration of external calls alongside database writes.
Q2: Should I use a gem like dry-monads or interactor for service objects?
Those gems add structure (Result monads, hooks, pipelines) that’s valuable in large codebases. For most apps, plain Ruby classes with a Result struct are sufficient and easier to understand without the gem overhead. Reach for dry-monads when the result handling becomes complex or when you want do notation for chaining fallible operations.
Q3: What’s the difference between a service object and a command object?
Effectively nothing in Rails practice. “Command” implies the Command pattern (with undo/redo semantics); “service” implies domain operations. Most Rails developers use both terms interchangeably for plain Ruby operation classes. Pick one name and be consistent in your codebase.
Q4: Can service objects call other service objects?
Yes, and this is often correct. A PlaceOrder service might call ProcessPayment, UpdateInventory, and SendOrderConfirmation. Compose at the orchestration layer. Just be careful about depth — three levels of service nesting is usually a sign the design needs rethinking.
Q5: Where do I put shared logic that multiple services need?
Extract it to a module and include it, or to a plain Ruby class that services instantiate. Don’t create a BaseService class with shared behavior — inheritance for code reuse in services typically creates more coupling than it’s worth.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀