rubyguides

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:

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.

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