7 Ruby on Rails Service Object Patterns That Keep Your Code Clean and Testable

Learn 7 proven Ruby on Rails service object patterns — from command and orchestrator to observer — that keep your business logic clean, testable, and easy to maintain.

7 Ruby on Rails Service Object Patterns That Keep Your Code Clean and Testable

I remember the first time I opened a controller file in a Rails app and saw two hundred lines of if-else statements mixed with database queries, email sending logic, and permission checks. It was not readable. It was not testable. It was a mess. That day I realized that models and controllers are not the only places to put code. There is a better way.

Service objects changed how I write Ruby on Rails applications. They take business logic that does not belong in models or controllers and give it a home. Over the years I have found seven patterns that help me keep service objects small, predictable, and easy to work with. I want to share them with you, the way I wish someone had explained them to me – slowly, with code, and without fancy words.

The Command Pattern with Result Objects

The first pattern I use in almost every service is the command pattern combined with a result object. The idea is simple. A service does one thing. It either works or it does not. And when it finishes, it tells you what happened in a consistent way.

Before I used this pattern, I would write services that returned true or false. Or I would raise exceptions and catch them in the controller. Both approaches made my code unpredictable. I never knew if a service succeeded or failed just by looking at its return value.

So I created a reusable ApplicationService class. Every service inherits from it. The call method is where the magic happens. Here is the base structure.

class ApplicationService
  def self.call(*args, **kwargs, &block)
    new(*args, **kwargs, &block).call
  end

  def call
    raise NotImplementedError
  end
end

I also defined a Result object. It holds a success flag, an optional data payload, and an optional error message.

class Result
  attr_reader :data, :error

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

  def success?
    @success
  end

  def failure?
    !@success
  end
end

Now every service returns a Result instance. The caller never has to guess what happened. Look at this real example.

class CreateOrderService < ApplicationService
  def initialize(user:, order_params:)
    @user = user
    @order_params = order_params
  end

  def call
    order = Order.new(@order_params.merge(user: @user))
    
    if order.save
      Result.new(success: true, data: order)
    else
      Result.new(success: false, error: order.errors.full_messages)
    end
  end
end

Using it is straightforward.

result = CreateOrderService.call(user: current_user, order_params: params)

if result.success?
  render json: result.data, status: :created
else
  render json: { errors: result.error }, status: :unprocessable_entity
end

I love this pattern because it forces me to think about both success and failure paths. It also makes testing easier. I can stub a service and return any result I want.

The Orchestrator Pattern for Multi‑Step Workflows

Some operations are not a single step. They are a sequence of steps, and each step can fail. When that happens, I need to undo everything that has been done so far. That is where the orchestrator pattern comes in.

I remember building a checkout flow. The steps were validate the order, reserve inventory, process payment, send confirmation email. If the payment failed, I had to release the reserved inventory. If the inventory reservation failed, I had to do nothing – but I still needed to report the failure cleanly.

The orchestrator pattern solves this by executing steps in order and keeping a list of completed steps. If any step raises a StepFailed error, the orchestrator calls a rollback method for each completed step in reverse order.

Here is how I implemented it.

class CheckoutService < ApplicationService
  def initialize(order:, payment_gateway:, inventory_service:)
    @order = order
    @payment_gateway = payment_gateway
    @inventory_service = inventory_service
    @completed_steps = []
  end

  def call
    step(:validate_order) { validate_order }
    step(:reserve_inventory) { reserve_inventory }
    step(:process_payment) { process_payment }
    step(:send_confirmation) { send_confirmation }

    Result.new(success: true, data: @order)
  rescue StepFailed => e
    rollback
    Result.new(success: false, error: e.message)
  end

  private

  def step(name)
    result = yield
    @completed_steps << name
    result
  end

  def rollback
    @completed_steps.reverse_each do |step_name|
      send("rollback_#{step_name}") rescue nil
    end
  end

  def rollback_reserve_inventory
    @inventory_service.release_items(@order.line_items)
  end

  def rollback_process_payment
    @payment_gateway.refund(@order.payment_id)
  end
end

Each step method simply returns the result of a sub‑operation or raises StepFailed with a message if something goes wrong. The rollback methods are defined only for steps that changed state. If validate_order only reads data, I do not need a rollback_validate_order.

This pattern saved me from writing complex transaction blocks that mixed database rollbacks with external API compensations. The orchestrator is explicit about what it does and how it cleans up.

The Policy Object Pattern for Pre‑Execution Checks

Services often need to check if the action is allowed before doing the real work. I used to put those checks directly inside the service. The problem was that the service grew fat with conditionals. Also, testing those conditions required setting up the whole service.

I extracted those checks into a policy object. A policy object answers one question: can this action be performed? It also provides a list of reasons when the answer is no.

Consider cancelling an order. The business rules are: the order must belong to the current user, it must be in a pending state, and it must be within one hour of creation. Here is the policy.

class OrderCancellationPolicy
  def initialize(order, user)
    @order = order
    @user = user
  end

  def can_cancel?
    order_belongs_to_user? && order_is_pending? && within_cancellation_window?
  end

  def reasons
    [].tap do |errors|
      errors << 'Order does not belong to you' unless order_belongs_to_user?
      errors << 'Order cannot be cancelled in its current state' unless order_is_pending?
      errors << 'Cancellation window has expired' unless within_cancellation_window?
    end
  end

  private

  def order_belongs_to_user?
    @order.user_id == @user.id
  end

  def order_is_pending?
    @order.pending?
  end

  def within_cancellation_window?
    @order.created_at > 1.hour.ago
  end
end

Now the service uses the policy at the beginning of call.

class CancelOrderService < ApplicationService
  def initialize(order:, user:)
    @order = order
    @user = user
  end

  def call
    policy = OrderCancellationPolicy.new(@order, @user)

    unless policy.can_cancel?
      return Result.new(success: false, error: policy.reasons)
    end

    @order.update!(status: :cancelled, cancelled_at: Time.current)
    Result.new(success: true, data: @order)
  end
end

The policy object is testable by itself. I can write unit tests for can_cancel? and reasons without touching the service. The service only tests its own logic, which is now minimal. This separation keeps both files small and focused.

The Dependency Injection Pattern for Testability

I used to hardcode external dependencies like the PaymentGateway or Rails.logger inside services. That made tests slow and brittle. Every test would make a real HTTP request to the payment gateway, which was unacceptable.

Dependency injection solves this by passing dependencies as arguments to the initializer. I set sensible defaults for production, and in tests I replace them with mocks or stubs.

Here is the same payment service with injected dependencies.

class ProcessPaymentService < ApplicationService
  def initialize(order:, gateway: PaymentGateway.new, logger: Rails.logger)
    @order = order
    @gateway = gateway
    @logger = logger
  end

  def call
    @logger.info("Processing payment for order #{@order.id}")
    response = @gateway.charge(
      amount: @order.total,
      token: @order.payment_token
    )

    if response.success?
      @order.update!(payment_status: :paid, paid_at: Time.current)
      Result.new(success: true, data: response)
    else
      Result.new(success: false, error: response.error_message)
    end
  end
end

In the test I inject a fake gateway and an in‑memory logger.

let(:fake_gateway) { instance_double('PaymentGateway', charge: double(success?: true)) }
let(:logger) { Logger.new(StringIO.new) }

subject { described_class.new(order: order, gateway: fake_gateway, logger: logger) }

The test runs in milliseconds. No network calls. No side effects. I can verify exactly what call does by checking the result and the order’s updated attributes. Dependency injection made my test suite fast and reliable.

The Query Object Pattern for Complex Data Retrieval

Controllers and views often contain queries that chain multiple scopes and conditions. When I see that, I extract the query into a query object. A query object is a plain Ruby class that returns a collection. It does not modify state. It only fetches data.

I use a base query class that takes an ActiveRecord relation as the starting point.

class OrdersQuery
  def initialize(relation = Order.all)
    @relation = relation
  end

  def recent(limit: 10)
    @relation.order(created_at: :desc).limit(limit)
  end

  def by_user(user)
    @relation.where(user_id: user.id)
  end

  def pending
    @relation.where(status: :pending)
  end

  def overdue(days: 30)
    @relation.where('created_at < ? AND status = ?', days.days.ago, :pending)
  end

  def with_items
    @relation.includes(:line_items)
  end

  def search(term)
    @relation.where('order_number ILIKE :term OR customer_email ILIKE :term', term: "%#{term}%")
  end
end

Query objects compose beautifully. I can chain methods because each returns a relation.

query = OrdersQuery.new(current_user.orders)
@orders = query.recent(limit: 20).pending

The controller stays skinny. The query object is easy to test. I can pass any relation – a scope from a user, an unscoped model, or a test double. I have used this pattern to replace long chains of where and includes that used to live in models.

The Factory Pattern for Complex Object Creation

Some objects need a lot of setup. A single creation involves multiple associations, default values, and conditional logic. Putting that logic in the controller makes it fat. Putting it in the model violates single responsibility. That is why I use a factory service object.

When I create an order, I need to build a shipping address, create line items from a list of products, apply an optional coupon, generate an order number, and calculate totals. The factory service handles all of that.

class OrderFactoryService < ApplicationService
  def initialize(user:, items:, shipping_address:, coupon_code: nil)
    @user = user
    @items = items
    @shipping_address = shipping_address
    @coupon_code = coupon_code
  end

  def call
    order = Order.new(user: @user, status: :draft)
    order.shipping_address = build_address(@shipping_address)
    order.line_items = build_line_items
    order.coupon = apply_coupon if @coupon_code
    order.calculate_totals
    order.generate_order_number

    Result.new(success: order.save, data: order, error: order.errors)
  end

  private

  def build_line_items
    @items.map do |item|
      product = Product.find(item[:product_id])
      OrderLineItem.new(
        product: product,
        quantity: item[:quantity],
        unit_price: product.price,
        currency: product.currency
      )
    end
  end

  def build_address(address_params)
    Address.new(address_params.merge(user: @user))
  end

  def apply_coupon
    coupon = Coupon.find_by(code: @coupon_code)
    return unless coupon&.valid_for?(@user)
    coupon
  end
end

The factory ensures that every order is built consistently. If I change how line items are constructed, I only change one place. Controllers never need to know about currency conversions or address building. This pattern has saved me from duplicate code spread across multiple endpoints.

The Observer Pattern for Side Effects

Services that do one thing should not also send emails, update caches, and push notifications. Those are side effects. I take them out of the service and put them into an observer object.

The observer is a separate class that the service calls after the main action succeeds. Here is an observer for order placement.

class OrderPlacementObserver
  def after_save(order)
    OrderConfirmationMailer.confirm(order).deliver_later
    CacheInvalidator.new.invalidate_order_cache(order)
    AnalyticsTracker.track_event('order_placed', order_id: order.id)
  end
end

The service accepts an observer as an injected dependency.

class PlaceOrderService < ApplicationService
  def initialize(order:, observer: OrderPlacementObserver.new)
    @order = order
    @observer = observer
  end

  def call
    @order.update!(status: :confirmed, confirmed_at: Time.current)
    @observer.after_save(@order)
    Result.new(success: true, data: @order)
  end
end

In tests, I replace the observer with a no‑op object or a spy.

class NullObserver
  def after_save(order); end
end

# In test
let(:observer_spy) { spy('Observer') }
subject { described_class.new(order: order, observer: observer_spy) }

it 'calls the observer' do
  subject.call
  expect(observer_spy).to have_received(:after_save).with(order)
end

The observer pattern keeps the service pure. The service only handles the core business logic. The observer handles everything that should happen as a consequence. This separation makes both classes easier to test and change independently.

Bringing It All Together

These seven patterns are not rules. They are tools. I pick the one that fits the problem. For a simple action with one outcome, I use the command pattern with a result object. When I have multiple steps that need rollback, I use the orchestrator. When permissions get complicated, I reach for a policy object. I inject dependencies to keep tests fast. I use query objects to keep controllers clean. Factory services handle complex creation. And observers manage side effects.

My personal rule is this: a service object should be small enough that I can read it in one sitting. If it grows beyond that, I look for a pattern to split it. The code you write today will be read by someone tomorrow – maybe even yourself six months from now. Make it simple. Make it predictable. Make it boring.

These patterns have helped me do exactly that. They turned my chaotic controllers into clean orchestration layers and made my test suite a joy to run. I hope they help you too.


// Keep Reading

Similar Articles