Service Objects in Ruby: Extract Business Logic from Controllers
Service objects are plain Ruby objects that encapsulate a single piece of business logic. They are part of a broader pattern sometimes called the Service Layer, and they exist to solve a common problem: fat controllers and bloated models.
Instead of dumping all your logic into a controller action or an ActiveRecord model, you extract specific operations into dedicated objects. Each service object does one thing, and does it well.
This makes the flow of an application easier to follow. Controllers can focus on request and response work, models can focus on persistence, and service objects can own the business rule that sits in between. When a task has several steps that belong together, the service object gives that work a clear home.
Why extract logic into services?
Rails applications tend to accumulate logic in the wrong places. A typical controller action might validate input, query the database, process data, send an email, and update cache, all in one place. Over time this becomes impossible to test and difficult to change.
Service objects push this logic down into objects that are easy to reason about:
That separation matters because the controller now reads like a coordinator instead of a script with many moving parts. The business rule has one place to live, and the surrounding web code becomes much easier to scan.
class ChargeCustomer
def initialize(customer, payment_params)
@customer = customer
@payment_params = payment_params
end
def call
return failure("Customer has no payment method") unless @customer.payment_method
charge = PaymentGateway.charge(@customer.payment_method, amount)
if charge.successful?
@customer.orders.create!(total: amount)
Success.new(charge)
else
failure(charge.error_message)
end
end
private
attr_reader :customer, :payment_params
def amount
payment_params[:amount]
end
end
The ChargeCustomer class takes the customer and payment details as constructor arguments, then exposes a single call method that runs the business logic and returns a result. The controller does not need to know about payment gateways, order creation, or error formatting, it only needs to check whether the result was successful and decide what view to render next.
The example keeps the business rule in one place, away from the controller. That makes it easier to reuse the same charge logic in a background job, a console script, or another request path that needs the same behavior.
The controller becomes a thin wrapper:
class OrdersController < ApplicationController
def create
result = ChargeCustomer.new(current_user, order_params).call
if result.success?
redirect_to result.order, notice: "Order placed!"
else
@error = result.error
render :new
end
end
end
Structuring a service object
The most important rule: single responsibility. A service object should do one thing. If you find yourself writing “and” in the method name, split it up.
The callable interface
The most common pattern is a call method. This makes the object respond to .call, which feels like a function and works well with Rails pipelines:
class ImportUsers
def initialize(csv_file)
@csv_file = csv_file
end
def call
rows.each do |row|
User.find_or_create_by!(email: row[:email]) do |user|
user.name = row[:name]
user.role = row[:role]
end
end
end
private
attr_reader :csv_file
def rows
CSV.read(csv_file, headers: true, header_converters: :symbol)
end
end
# Usage
ImportUsers.new("users.csv").call
You can also include Callable to make the interface explicit:
module Callable
def call(*args)
new(*args).call
end
end
class ImportUsers
include Callable
def initialize(csv_file)
@csv_file = csv_file
end
def call
# ...
end
end
ImportUsers.call("users.csv")
The call interface keeps the object simple to invoke from a controller, job, or console script. You do not need a separate public method for each code path, which helps the service stay focused on one business action.
naming conventions
Service objects are typically named after the action they perform:
| Name | Action |
|---|---|
ChargeCustomer | Charges a customer’s card |
ImportUsers | Imports users from a CSV |
CalculateRefund | Calculates a refund amount |
PublishArticle | Publishes an article and notifies subscribers |
ArchiveOrder | Archives an order and cleans up related data |
The name should describe what happens, not what the object is. RefundCalculator is better than RefundService.
Good names reduce the mental work for the reader. If the class name already tells you the action, you do not need to open the file just to find out what job the object performs.
returning result objects
Service objects should not raise exceptions for expected failure cases. Instead, return a result object that describes the outcome:
That lets the caller decide how to react without treating every failed business rule as an exception. A missing payment method, for example, is often an expected outcome that should be reported cleanly instead of blowing up the whole flow.
class Result
attr_reader :data, :error
def initialize(data: nil, error: nil)
@data = data
@error = error
end
def success?
error.nil?
end
def failure?
!success?
end
end
The Result class is intentionally minimal. It only needs two fields, data and error, and a boolean check so the caller can branch on success or failure. That keeps the pattern lightweight enough to use consistently across every service object in the application.
Using this pattern in a service:
class ChargeCustomer
def initialize(customer, amount)
@customer = customer
@amount = amount
end
def call
return failure("No payment method") unless @customer.payment_method
charge = PaymentGateway.charge(@customer.payment_method, @amount)
if charge.succeeded?
@customer.orders.create!(total: @amount)
Success.new(data: { order: @customer.orders.last })
else
failure(charge.error_message)
end
end
private
attr_reader :customer, :amount
def failure(message)
Result.new(error: message)
end
end
The failure helper wraps the error message in a Result object, keeping the call method concise. Every code path in call produces a Result, so the caller never has to check for nil or handle raw error strings differently from structured errors.
This makes the controller logic clean and explicit:
result = ChargeCustomer.new(user, 5000).call
if result.success?
@order = result.data[:order]
redirect_to @order
else
flash[:error] = result.error
redirect_to checkout_path
end
The controller can now check the result and decide whether to render an error or continue on to the next screen. That keeps the branching close to the web layer while the service object stays focused on the business rule itself.
raising exceptions
For truly unexpected errors, let exceptions propagate:
This is the right place for bugs, missing infrastructure, and other conditions that the caller cannot reasonably recover from. The service object should not hide those problems, because the exception is often the fastest way to surface them during development and monitoring.
class ProcessPayment
def call
gateway = PaymentGateway.new
response = gateway.charge(payment_params)
unless response.success?
raise PaymentError, "Gateway returned: #{response.error_code}"
end
response
end
end
Use exceptions when something is genuinely wrong and the caller cannot reasonably handle it. Use result objects for expected failure paths.
That distinction keeps the code honest. If a problem is part of normal business flow, return a result. If it points to a broken assumption or an unavailable system, let the exception bubble up.
composing services
Complex workflows often involve multiple service objects working together. You can compose services by having one service call another:
This is where service objects start to feel like building blocks. One object can handle the small, focused task while a higher-level object coordinates the sequence and decides what happens next.
class RegisterUser
def initialize(user_params)
@user_params = user_params
end
def call
create_user_result = CreateUser.call(user_params)
return create_user_result if create_user_result.failure?
SendWelcomeEmail.call(create_user_result.data[:user])
create_user_result
end
private
attr_reader :user_params
end
The RegisterUser service coordinates two smaller services without knowing their internal details. It only cares about the result of each step and whether to continue or abort. That separation means you can test CreateUser and SendWelcomeEmail independently while still having a single entry point for the full registration flow.
Keep the orchestration at a high level. If you find yourself nesting four or five levels deep, consider a workflow or command object instead.
The goal is to keep the composition readable. If the service starts to look like a mini application framework, the boundaries have probably grown too wide.
Service object alternatives
Service objects are not the only way to organize business logic. Here are some alternatives:
Sometimes a plain module or a framework-backed object reads more naturally. The right choice depends on how much behavior you need and how much structure the surrounding code already provides.
plain ruby objects
Sometimes a module with a class method is simpler:
This approach can be a nice middle ground when you want a callable object without the extra ceremony of a full service class. It is still plain Ruby, and it can still be tested the same way.
module UserImporter
def self.call(csv_path)
new(csv_path).call
end
def initialize(csv_path)
@csv_path = csv_path
end
def call
CSV.read(@csv_path, headers: true).each do |row|
User.find_or_create_by!(email: row[:email])
end
end
end
UserImporter.call("users.csv")
interactors
The interactor gem formalizes the service object pattern with a clean interface and built-in rollback support:
That is useful when your application has many steps that should succeed or fail together. The gem adds structure, but it also adds a little more abstraction, so it is most helpful when the workflow is genuinely complex.
class CreateOrder
include Interactor
def call
order = Order.create!(context.order_params)
context.order = order
rescue ActiveRecord::RecordInvalid
context.fail!(message: "Order could not be created")
end
end
# Usage
result = CreateOrder.call(order_params: { user_id: 1, total: 100 })
if result.success?
puts result.order
else
puts result.message
end
Interactors add overhead but provide a consistent interface across your codebase and automatic transaction handling.
If your team likes a stronger convention around multi-step operations, an interactor can be worth the extra layer. If not, a small service object may be simpler to maintain.
activemodel::model
For service objects that need Rails validation:
This is a good fit when the service accepts user input and should validate that input before it starts doing work. It gives you familiar Rails validation helpers without forcing the class to inherit from a database-backed model.
class ProcessRefund
include ActiveModel::Model
attr_accessor :order, :amount, :reason
validates :order, presence: true
validates :amount, numericality: { greater_than: 0 }
def call
return failure("Invalid input") unless valid?
refund = RefundProcessor.execute(order, amount)
SendRefundConfirmation.call(order.user, refund)
Success.new(data: { refund: refund })
end
end
This gives you access to valid?, errors, and save without inheriting from ApplicationRecord.
That can make forms and wizard-style flows easier to organize, especially when the logic is not really a database record at all.
testing service objects
Service objects are easy to test because they have no Rails dependencies:
The lack of framework coupling is a big part of the appeal. You can feed the class plain Ruby doubles, inspect the result, and keep the test focused on the behavior that matters.
RSpec.describe ChargeCustomer do
describe "#call" do
it "returns success when charge goes through" do
customer = double("Customer", payment_method: "pm_123", orders: double("orders"))
allow(customer.orders).to receive(:create!).and_return(double("order", id: 42))
allow(PaymentGateway).to receive(:charge).and_return(
double(successful?: true, id: "ch_123")
)
result = described_class.new(customer, 1000).call
expect(result.success?).to be true
end
it "returns failure when customer has no payment method" do
customer = double("Customer", payment_method: nil, orders: double("orders"))
result = described_class.new(customer, 1000).call
expect(result.failure?).to be true
expect(result.error).to include("No payment method")
end
end
end
You can use plain doubles and stubs. No database, no controller tests needed. Each service object is a unit.
That isolation is what makes service objects attractive in the first place. A small unit with a clear call boundary is easier to reason about than a long controller action with hidden side effects.
testing with dependencies
When a service depends on other services, use dependency injection to make testing straightforward:
This is a good reminder that service objects and dependency injection usually work together. The more the service depends on collaborators, the more useful it becomes to inject them from the outside in tests and production.
class ProcessSubscription
def initialize(user:, subscription_updater: SubscriptionUpdater, mailer: UserMailer)
@user = user
@subscription_updater = subscription_updater
@mailer = mailer
end
def call
result = @subscription_updater.call(user: @user)
if result.success?
@mailer.send_welcome(@user)
end
result
end
end
In tests, pass test doubles:
RSpec.describe ProcessSubscription do
it "sends welcome email on success" do
user = build(:user)
fake_updater = -> { Success.new }
fake_mailer = double("mailer")
described_class.new(user: user, subscription_updater: fake_updater, mailer: fake_mailer).call
expect(fake_mailer).to have_received(:send_welcome).with(user)
end
end
In tests, pass test doubles:
The test double only needs the methods the service actually calls. That keeps the test lightweight and makes the dependency contract obvious.
when to use service objects
Service objects work well when:
These are the situations where a dedicated object usually earns its keep. If the work is mostly orchestration, validation, or one-off business logic that does not belong cleanly in a model or controller, a service object is often the cleanest place for it.
- A controller action needs multiple database writes or external API calls
- Logic is reused across multiple controllers
- You want to test complex business rules in isolation
- Your models are becoming hard to navigate
Avoid service objects for:
Not every piece of logic needs a separate class. Very small bits of behavior can stay in the model, helper, or controller if that keeps the code shorter and easier to follow.
Service objects are most valuable when they reduce clutter and name a business process clearly. If they start adding ceremony without clarifying the flow, the code may be better off staying simpler.
- One-off logic used in a single controller action and nowhere else
- Pure data transformations that belong in a model method
- Simple validations that can use
ActiveModel::Model
See Also
- Dependency Injection in Ruby — How to pass dependencies into service objects cleanly
- Struct — Lightweight Data Objects in Ruby — Using Struct for simple data containers in your services
- Ruby Value Objects — Immutable data objects that complement service objects
- method_missing and respond_to_missing? — Dynamic method handling for service object patterns