How to Use Service Objects in Rails to Keep Controllers Thin

Prerequisites

  • Rails 6+ application
  • Understanding of MVC pattern and Active Record

The Problem

A typical Rails controller that does too much:

class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    @order.user = current_user

    if @order.save
      # Charge the customer
      Stripe::Charge.create(
        amount: @order.total_cents,
        currency: "usd",
        source: params[:stripe_token]
      )

      # Send confirmation email
      OrderMailer.confirmation(@order).deliver_now

      # Notify admin
      AdminNotificationJob.perform_later(@order.id)

      # Update inventory
      @order.line_items.each do |item|
        item.product.decrement!(:stock, item.quantity)
      end

      redirect_to @order, notice: "Order placed!"
    else
      render :new
    end
  end
end

This controller knows about payments, emails, jobs, and inventory. It violates the Single Responsibility Principle and is painful to test.

The Solution: Service Objects

Service objects encapsulate a single business operation. They live in app/services/ and follow a simple convention.

# app/services/orders/create_service.rb
module Orders
  class CreateService
    attr_reader :user, :params, :order

    def initialize(user:, params:)
      @user = user
      @params = params
    end

    def call
      ActiveRecord::Base.transaction do
        create_order!
        charge_customer!
        send_confirmation!
        notify_admin!
        update_inventory!
      end
      Result.success(order)
    rescue Stripe::CardError => e
      Result.failure(e.message)
    end

    private

    def create_order!
      @order = user.orders.create!(params)
    end

    def charge_customer!
      Stripe::Charge.create(
        amount: order.total_cents,
        currency: "usd",
        source: params[:stripe_token]
      )
    end

    def send_confirmation!
      OrderMailer.confirmation(order).deliver_later
    end

    def notify_admin!
      AdminNotificationJob.perform_later(order.id)
    end

    def update_inventory!
      order.line_items.each do |item|
        item.product.decrement!(:stock, item.quantity)
      end
    end
  end
end

The Controller After Refactoring

class OrdersController < ApplicationController
  def create
    result = Orders::CreateService.new(
      user: current_user,
      params: order_params
    ).call

    if result.success?
      redirect_to result.value, notice: "Order placed!"
    else
      flash.now[:alert] = result.error
      render :new
    end
  end
end

The Result Object Pattern

A simple value object for returning success/failure:

class Result
  attr_reader :value, :error

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

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

  def success?
    @success
  end

  private

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

Place Result in app/lib/result.rb or app/services/result.rb.

Folder Structure

app/services/
├── application_service.rb    # Base class (optional)
├── result.rb                 # Result object
├── orders/
│   ├── create_service.rb
│   ├── cancel_service.rb
│   └── refund_service.rb
└── users/
    └── register_service.rb

Optional Base Class

# app/services/application_service.rb
class ApplicationService
  def self.call(**args)
    new(**args).call
  end

  def call
    raise NotImplementedError
  end
end

Then:

class Orders::CreateService < ApplicationService
  # ...
end

# Usage
result = Orders::CreateService.call(user: user, params: params)

Naming Conventions

ConventionExample
Namespaced by resourceOrders::CreateService
Verb as first moduleOrders, Payments
Ends with Service...Service
One public methodcall

Testing Service Objects

# spec/services/orders/create_service_spec.rb
RSpec.describe Orders::CreateService do
  let(:user) { create(:user) }
  let(:product) { create(:product, stock: 10, price_cents: 1000) }
  let(:params) do
    {
      line_items_attributes: [
        { product_id: product.id, quantity: 2 }
      ],
      stripe_token: "tok_visa"
    }
  end

  it "creates an order and charges the customer" do
    result = described_class.new(user: user, params: params).call

    expect(result).to be_success
    expect(result.value.total_cents).to eq(2000)
    expect(product.reload.stock).to eq(8)
  end

  it "rolls back on payment failure" do
    allow(Stripe::Charge).to receive(:create).and_raise(Stripe::CardError.new("Declined", "param", "code"))

    result = described_class.new(user: user, params: params).call

    expect(result).not_to be_success
    expect(Order.count).to eq(0)
    expect(product.reload.stock).to eq(10)
  end
end

Service objects are plain Ruby classes — no framework magic to set up. Mock external services, verify return values and side effects.

When to Use Service Objects

✅ Use when:

  • A controller action spans multiple models
  • Business logic is reused across controllers, jobs, or rake tasks
  • The operation involves external APIs
  • You need transactional boundaries spanning multiple records

❌ Don’t use when:

  • It’s a simple CRUD operation (just use the controller)
  • It’s a single model call (put it on the model)
  • You’re just wrapping Model.create in a service

FAQ

Q: Where do service objects go? app/services/. Rails autoloads everything under app/.

Q: Should every controller action have a service object? No. Simple index/show/update actions don’t need one. Use services for complex writes with multiple steps.

Q: What about interactors or commands? Same concept, different naming. Libraries like interactor gem provide structure; plain Ruby works fine for most cases.

References


Advertisement