RoR Service Objects

Service objects are plain Ruby classes that encapsulate a single business operation. When a controller action or model method grows too complex — calling external APIs, sending emails, processing payments, coordinating multiple models — you extract that logic into a service object. The result is code that is easier to read, test, and reuse.

The Problem Service Objects Solve

FAT CONTROLLER — too much logic in one place:
class RegistrationsController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      WelcomeEmailJob.perform_later(@user.id)
      StripeService.create_customer(@user)
      SlackNotifier.notify("#signups", "New user: #{@user.email}")
      @user.generate_referral_code
      @user.update(onboarding_step: 1)
      redirect_to dashboard_path, notice: "Welcome!"
    else
      render :new
    end
  end
end

This controller does too many things. Testing it requires mocking Stripe, Slack, and mailers. Service objects fix this.

The Service Object Pattern

CLEAN CONTROLLER — delegates to service:
class RegistrationsController < ApplicationController
  def create
    result = UserRegistrationService.new(user_params).call
    if result.success?
      redirect_to dashboard_path, notice: "Welcome!"
    else
      @user = result.user
      render :new
    end
  end
end

SERVICE OBJECT — all the logic in one place:
class UserRegistrationService
  attr_reader :user

  def initialize(params)
    @params = params
  end

  def call
    @user = User.new(@params)
    if @user.save
      run_post_registration_tasks
      Result.new(success: true, user: @user)
    else
      Result.new(success: false, user: @user)
    end
  end

  private

  def run_post_registration_tasks
    WelcomeEmailJob.perform_later(@user.id)
    StripeService.create_customer(@user)
    SlackNotifier.notify("#signups", "New user: #{@user.email}")
    @user.generate_referral_code
    @user.update(onboarding_step: 1)
  end

  Result = Struct.new(:success, :user, keyword_init: true) do
    def success?
      success
    end
  end
end

File Location Convention

app/services/
  user_registration_service.rb
  payment_service.rb
  report_generator_service.rb
  weather_service.rb
  inventory_sync_service.rb

Rails automatically loads files from app/services/ — no require statement needed.

Service Object for Payment Processing

app/services/payment_service.rb

class PaymentService
  def initialize(order, payment_token)
    @order         = order
    @payment_token = payment_token
  end

  def call
    charge = Stripe::Charge.create(
      amount:      (@order.total * 100).to_i,
      currency:    "usd",
      source:      @payment_token,
      description: "Order ##{@order.id}"
    )

    @order.update(
      stripe_charge_id: charge.id,
      status: "paid",
      paid_at: Time.current
    )

    OrderConfirmationMailer.send_confirmation(@order).deliver_later
    Result.new(success: true, charge_id: charge.id)

  rescue Stripe::CardError => e
    Result.new(success: false, error: e.message)
  rescue Stripe::StripeError => e
    Rails.logger.error "Stripe error: #{e.message}"
    Result.new(success: false, error: "Payment processing failed. Please try again.")
  end

  Result = Struct.new(:success, :charge_id, :error, keyword_init: true) do
    def success?
      success
    end
  end
end
Usage in controller:
class OrdersController < ApplicationController
  def process_payment
    @order  = current_user.orders.find(params[:id])
    result  = PaymentService.new(@order, params[:payment_token]).call

    if result.success?
      redirect_to order_path(@order), notice: "Payment successful!"
    else
      flash[:alert] = result.error
      render :checkout
    end
  end
end

Service Object for Data Import

app/services/csv_import_service.rb

class CsvImportService
  attr_reader :errors, :imported_count

  def initialize(file_path)
    @file_path      = file_path
    @errors         = []
    @imported_count = 0
  end

  def call
    CSV.foreach(@file_path, headers: true) do |row|
      import_row(row)
    end
    self
  end

  def success?
    @errors.empty?
  end

  private

  def import_row(row)
    product = Product.new(
      name:  row["name"],
      price: row["price"].to_f,
      sku:   row["sku"]
    )

    if product.save
      @imported_count += 1
    else
      @errors << "Row #{row}: #{product.errors.full_messages.join(", ")}"
    end
  end
end
Usage:
service = CsvImportService.new("/tmp/products.csv").call
if service.success?
  flash[:notice] = "Imported #{service.imported_count} products."
else
  flash[:alert] = service.errors.first(5).join(". ")
end

Testing Service Objects

Service objects are easy to test because they are plain Ruby classes with no HTTP, routing, or view dependencies:

spec/services/payment_service_spec.rb

RSpec.describe PaymentService do
  let(:order) { create(:order, total: 50.00) }
  let(:token) { "tok_visa" }

  describe "#call" do
    context "when payment succeeds" do
      before { allow(Stripe::Charge).to receive(:create).and_return(double(id: "ch_123")) }

      it "marks the order as paid" do
        described_class.new(order, token).call
        expect(order.reload.status).to eq("paid")
      end

      it "returns a successful result" do
        result = described_class.new(order, token).call
        expect(result).to be_success
      end
    end

    context "when card is declined" do
      before { allow(Stripe::Charge).to receive(:create).and_raise(Stripe::CardError.new("Declined", nil)) }

      it "returns a failed result with an error message" do
        result = described_class.new(order, token).call
        expect(result).not_to be_success
        expect(result.error).to include("Declined")
      end
    end
  end
end

Rules for Good Service Objects

  • One service object = one business operation
  • Use a single public method named call
  • Accept dependencies through the initializer (easy to test)
  • Return a result object instead of raising exceptions for expected failures
  • Keep private methods small and single-purpose

Service objects bring order to complex application logic. They make your controllers thin, your models focused, and your business logic testable and reusable across the entire application.

Leave a Comment

Your email address will not be published. Required fields are marked *