Service Objects in Ruby

· 6 min read · Updated March 31, 2026 · intermediate
ruby service-object design-patterns rails guides

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.

Why Use Service Objects?

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:

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 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")

Naming Conventions

Service objects are typically named after the action they perform:

NameAction
ChargeCustomerCharges a customer’s card
ImportUsersImports users from a CSV
CalculateRefundCalculates a refund amount
PublishArticlePublishes an article and notifies subscribers
ArchiveOrderArchives an order and cleans up related data

The name should describe what happens, not what the object is. RefundCalculator is better than RefundService.

Returning Result Objects

Service objects should not raise exceptions for expected failure cases. Instead, return a result object that describes the outcome:

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

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

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

Raising Exceptions

For truly unexpected errors, let exceptions propagate:

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.

Composing Services

Complex workflows often involve multiple service objects working together. You can compose services by having one service call another:

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

Keep the orchestration at a high level. If you find yourself nesting four or five levels deep, consider a workflow or command object instead.

Service Object Alternatives

Service objects are not the only way to organize business logic. Here are some alternatives:

Plain Ruby Objects

Sometimes a module with a class method is simpler:

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:

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.

ActiveModel::Model

For service objects that need Rails validation:

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.

Testing Service Objects

Service objects are easy to test because they have no Rails dependencies:

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.

Testing with Dependencies

When a service depends on other services, use dependency injection to make testing straightforward:

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

When to Use Service Objects

Service objects work well when:

  • 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:

  • 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