Service Objects in Ruby — How I Structure Business Logic

by Eric Hanson, Backend Developer at Clean Systems Consulting

The problem they solve

Rails gives you models and controllers. Models accumulate business logic until they're 800-line files with 40 callbacks. Controllers accumulate business logic when developers run out of patience putting it in models. Neither is the right place for multi-step operations that touch multiple models, call external services, and need to fail cleanly at any step.

A service object is a plain Ruby class with a single public method that executes one business operation. No inheritance required. No gem required. The pattern's value is in the constraints it enforces, not in any technical mechanism.

The basic structure

class PlaceOrder
  def initialize(user:, cart:, payment_method:)
    @user           = user
    @cart           = cart
    @payment_method = payment_method
  end

  def call
    validate_cart
    reserve_inventory
    charge_payment
    create_order
    notify_user
  end

  private

  attr_reader :user, :cart, :payment_method

  def validate_cart
    raise InvalidCartError, "Cart is empty" if cart.items.empty?
  end

  def reserve_inventory
    InventoryService.reserve(cart.items)
  end

  def charge_payment
    @charge = PaymentGateway.charge(
      amount: cart.total,
      method: payment_method
    )
  end

  def create_order
    @order = Order.create!(
      user:    user,
      items:   cart.items,
      charge:  @charge
    )
  end

  def notify_user
    OrderMailer.confirmation(@order).deliver_later
  end
end

The caller:

PlaceOrder.new(user: current_user, cart: @cart, payment_method: params[:payment_method]).call

This is the minimal version. It works, it's readable, and it's testable. But it has two problems at scale: error signaling is all exceptions, and there's no structured way to communicate partial results back to the caller.

Result objects instead of bare exceptions

Exceptions are right for unexpected failures. They're wrong for expected business outcomes like "payment declined" or "cart item out of stock" — these aren't exceptional, they're normal paths. When every failure raises, controllers fill up with rescue blocks, and callers can't distinguish between a validation failure and a network timeout without inspecting exception types.

A result object gives you a structured return value:

class Result
  attr_reader :value, :error

  def self.ok(value = nil)
    new(success: true, value: value)
  end

  def self.fail(error)
    new(success: false, error: error)
  end

  def initialize(success:, value: nil, error: nil)
    @success = success
    @value   = value
    @error   = error
  end

  def ok?     = @success
  def failed? = !@success
end

The service returns a Result instead of raising for expected failures:

def call
  return Result.fail("Cart is empty") if cart.items.empty?

  charge = PaymentGateway.charge(amount: cart.total, method: payment_method)
  return Result.fail("Payment declined: #{charge.decline_reason}") unless charge.ok?

  order = Order.create!(user: user, items: cart.items, charge: charge)
  OrderMailer.confirmation(order).deliver_later

  Result.ok(order)
rescue InventoryService::ReservationError => e
  Result.fail("Item unavailable: #{e.message}")
end

The controller:

result = PlaceOrder.new(user: current_user, cart: @cart, payment_method: params[:payment_method]).call

if result.ok?
  redirect_to order_path(result.value)
else
  flash[:error] = result.error
  render :checkout
end

The controller now has one branch, not three rescue blocks. The service has explicit, readable failure paths. Network failures and unexpected exceptions still bubble up as real exceptions — they're not caught here because they warrant intervention, not a flash message.

The class-method shortcut

The .new(...).call pattern is verbose at call sites. A class-method wrapper cleans it up:

class PlaceOrder
  def self.call(...)
    new(...).call
  end

  # rest unchanged
end

# Caller
result = PlaceOrder.call(user: current_user, cart: @cart, payment_method: params[:payment_method])

... (argument forwarding, Ruby 2.7+) passes all arguments through without listing them. This keeps the class-method delegation a one-liner regardless of how the initializer signature changes.

Handling rollbacks across steps

Multi-step operations that mutate state need to clean up on failure. The naive approach is nested rescues everywhere. A cleaner approach is making each step reversible:

def call
  reservation = InventoryService.reserve(cart.items)
  charge      = PaymentGateway.charge(amount: cart.total, method: payment_method)

  unless charge.ok?
    InventoryService.release(reservation)
    return Result.fail("Payment declined")
  end

  order = Order.create!(user: user, items: cart.items, charge: charge)
  Result.ok(order)
rescue => e
  InventoryService.release(reservation) if reservation
  raise
end

For operations entirely within a single database, wrap in ActiveRecord::Base.transaction and let the database handle rollback. For operations that span a database and an external service — charging a card, then writing the order — you cannot get atomicity from a transaction. You need to handle the partial-failure case explicitly: either compensating actions (release the reservation, void the charge) or an idempotent retry queue. A transaction block does not help when the external call already succeeded.

What belongs in a service object and what doesn't

Service objects solve one problem: encapsulating a multi-step business operation that doesn't belong in a model or controller. They're not a catch-all for "logic."

Use a service object for:

  • Multi-model writes that should succeed or fail together
  • Operations that call external services as part of their execution
  • Business processes with multiple named steps that need to be testable in isolation

Don't use a service object for:

  • Single-model CRUD that ActiveRecord handles cleanly
  • Query logic — that belongs in scopes, query objects, or repository objects
  • Transformations with no side effects — a plain method or module function is sufficient

The signal that you've over-applied the pattern is a service object with one step and no branching. CreateUser.call(params) that just calls User.create!(params) adds a layer of indirection with no return on that investment.

Testing

Service objects test cleanly because they're plain Ruby objects with explicit dependencies:

describe PlaceOrder do
  let(:user)           { build_stubbed(:user) }
  let(:cart)           { instance_double(Cart, items: [item], total: 50_00) }
  let(:payment_method) { "pm_test_visa" }

  context "when payment is declined" do
    before do
      allow(PaymentGateway).to receive(:charge).and_return(double(ok?: false, decline_reason: "insufficient funds"))
    end

    it "returns a failed result" do
      result = PlaceOrder.call(user: user, cart: cart, payment_method: payment_method)
      expect(result).to be_failed
      expect(result.error).to include("insufficient funds")
    end
  end
end

No controller, no request spec, no HTTP overhead. The test exercises the business logic directly. Mocking PaymentGateway at the class level is straightforward because the dependency is explicit in the code — not buried in a callback or pulled from a global registry.

The structure that holds up

Keyword arguments in the initializer, private readers, a single public call method, a result object for expected outcomes, real exceptions for unexpected ones. That's the whole pattern. Every team that adopts service objects ends up converging on some version of this after enough PRs arguing about where the logic should live.

The arguments about naming (call vs execute vs perform), file organization (app/services vs app/operations), and result object gems (dry-monads, railway) are real but secondary. Get the boundaries right first — what goes in, what comes out, what constitutes a failure — and the rest is convention.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Laugh at Yourself After a Huge Mistake

We’ve all been there: the code breaks, the email goes to the wrong person, or the deployment wipes out production. Learning to laugh at these moments can save your sanity and even make you a better professional.

Read more

Securing a Spring Boot API Beyond Authentication — OWASP Top 10 in Practice

Authentication is table stakes. The OWASP API Security Top 10 covers the vulnerabilities that survive correct authentication implementation. Here is how each one manifests in Spring Boot and the specific mitigations that address it.

Read more

French Labour Laws Make Full-Time Backend Hires Expensive — Here Is the Smarter Move

You wanted to hire one backend engineer. Your lawyer handed you a fifteen-page explanation of what that actually means under French employment law.

Read more

N+1 Queries in Rails — How I Find and Fix Them for Good

N+1 queries are the most common Rails performance problem and the most consistently underestimated. Here is a systematic approach to finding them, fixing them correctly, and preventing them from coming back.

Read more