Your Rails controller has 200 lines. Your model has 15 callbacks. Business logic is scattered across both, and every change breaks something unexpected. Service objects solve this by extracting business logic into plain Ruby classes with a single public interface.

RailsConf 2021: Missing Guide to Service Objects in Rails - Riaz Virani

Why extract business logic into service objects?

Controllers should parse params and return responses. Models should persist data and enforce constraints. Everything else belongs somewhere else.

Service objects give you:

  • Isolated testing without loading the full Rails stack
  • Reusable workflows across controllers, background jobs, and rake tasks
  • Explicit dependencies instead of hidden coupling through callbacks
  • Focused debugging since each service handles one operation

Without service objects, a typical Rails app evolves into “fat models” or “fat controllers.” Both patterns make changes risky and testing slow.

How should you name and organize service objects?

Names should be verb-first and action-oriented. The name tells you exactly what the service does:

BadGoodWhy
UserManagerCreateUserAccountSpecific action, not a god object
DataProcessorImportCSVTransactionsDescribes the actual operation
HandleSubscriptionRenewMonthlySubscriptionScoped to one workflow

Naming rules

  • Start with a verb: Create, Process, Send, Calculate, Validate
  • Be consistent: if you use Create for one service, don’t use Build for similar operations
  • Match scope to name: RegisterUser is better than CreateUser if the service also sends a welcome email and sets up defaults

Directory structure

For small apps, a flat app/services/ directory works. For larger codebases, organize by domain:

app/services/
  orders/
    process_payment.rb
    calculate_total.rb
    apply_discount.rb
  users/
    create_account.rb
    send_welcome_email.rb
  shared/
    generate_pdf.rb
    send_notification.rb

Use namespaces like Orders::ProcessPayment rather than deeply nested folders. Avoid creating a directory for a single service file.

What does a well-structured service object look like?

Every service should expose one public method. The Rails convention is call:

class ProcessPayment
  def initialize(payment_gateway, logger: Rails.logger)
    @payment_gateway = payment_gateway
    @logger = logger
  end

  def call(order, payment_details)
    validate_payment_details!(payment_details)
    charge = @payment_gateway.charge(order.total, payment_details.token)
    order.update!(payment_id: charge.id, status: :paid)

    Result.new(success: true, charge_id: charge.id)
  rescue PaymentGateway::CardDeclined => e
    Result.new(success: false, error: "Card declined", code: :card_declined)
  rescue PaymentGateway::InsufficientFunds => e
    Result.new(success: false, error: "Insufficient funds", code: :insufficient_funds)
  end

  private

  def validate_payment_details!(details)
    raise ArgumentError, "Token required" if details.token.blank?
  end
end

Key principles:

  • Dependencies in the constructor, operation params in call
  • Private methods break down the main operation into readable steps
  • Errors caught inside the service, not leaked to controllers

For a cleaner API, add a class-level shortcut:

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

# Usage
ProcessPayment.call(gateway, order, payment_details)

How do you enforce single responsibility?

A service that does too much is just a callback chain with extra steps. The test: can you describe what the service does in one sentence without using “and”?

Bad: ProcessOrder that validates inventory, charges payment, sends confirmation email, and updates analytics.

Good: Four focused services composed together:

class PlaceOrder
  def call(order)
    Inventory::ReserveItems.call(order.line_items)
    Payments::ChargeCard.call(order)
    Notifications::SendOrderConfirmation.call(order)
    Analytics::TrackPurchase.call(order)

    Result.new(success: true, order: order)
  rescue Inventory::OutOfStock => e
    Result.new(success: false, error: e.message)
  end
end

Each service can be tested, reused, and modified independently. When payment rules change, you touch only Payments::ChargeCard.

Avoid action-parameter services

Instead of:

ManageUser.call(user, action: "activate")
ManageUser.call(user, action: "suspend")

Create dedicated services: ActivateUser and SuspendUser. Each gets its own validation, error handling, and tests.

Which return pattern should you use?

PatternProsConsBest for
BooleanSimple, zero overheadNo error detailsSimple validations
Custom ResultStructured, includes data + errorsExtra class to maintainMost production services
HashQuick to implementNo structure, typo-pronePrototypes
dry-monadsElegant chaining, railway-orientedLearning curveComplex pipelines
class Result
  attr_reader :data, :error, :code

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

  def success? = @success
  def failure? = !@success
end

Usage in controllers:

result = CreateProject.call(project_params)

if result.success?
  redirect_to result.data, notice: "Project created"
else
  flash.now[:alert] = result.error
  render :new
end

Monadic patterns with dry-monads

For teams comfortable with functional patterns, dry-monads enables clean pipelines:

class CreateInvoice
  include Dry::Monads[:result, :do]

  def call(params)
    validated = yield validate(params)
    invoice = yield persist(validated)
    yield send_notification(invoice)

    Success(invoice)
  end
end

Pick one pattern and use it consistently across your entire codebase. Mixing patterns creates confusion.

How should you test service objects?

Test the call method. Mock external dependencies. Cover happy path, edge cases, and error conditions.

RSpec.describe CreateInvoice do
  let(:valid_params) do
    { customer_id: 1, amount: 100.50, due_date: Date.current + 30.days }
  end

  describe "#call" do
    context "with valid parameters" do
      it "creates an invoice and returns success" do
        result = described_class.new.call(valid_params)

        expect(result).to be_success
        expect(result.data).to be_persisted
        expect(result.data.amount).to eq(100.50)
      end
    end

    context "with missing customer" do
      it "returns failure with error message" do
        result = described_class.new.call(valid_params.merge(customer_id: nil))

        expect(result).to be_failure
        expect(result.error).to include("Customer is required")
      end
    end

    context "when external API fails" do
      before do
        allow(payment_gateway).to receive(:charge)
          .and_raise(PaymentGateway::Timeout)
      end

      it "returns failure with timeout error" do
        result = described_class.new.call(valid_params)

        expect(result).to be_failure
        expect(result.code).to eq(:timeout)
      end
    end
  end
end

Testing guidelines:

  • Descriptive test names: it "creates invoice and sends confirmation email" instead of it "works"
  • Shared examples for common patterns (all services return Result objects)
  • Mock external calls (APIs, email delivery) to keep tests fast and deterministic
  • Test error handling explicitly since that is where production bugs hide

Practical Implementation: The USEO Approach

Over 13 years maintaining and evolving the Yousty platform (Switzerland’s largest apprenticeship marketplace), we developed strong opinions about service object architecture.

Lesson 1: Start with conventions, not abstractions

In Yousty’s early years, we experimented with BaseService classes, custom DSLs, and elaborate result monads. Most of that added friction without proportional value. What stuck was a simple convention:

  • Every service lives in app/services/{domain}/
  • Every service has one public call method returning a Result
  • Dependencies are injected through the constructor

No gems. No magic. Just consistent patterns that every developer on the team can follow from day one.

Lesson 2: Service objects are refactoring targets, not starting points

When we built the Triptrade MVP, we deliberately started with straightforward controller logic. Only when patterns emerged (duplicate booking validation, complex pricing calculations) did we extract service objects.

Premature extraction creates services that model your current assumptions rather than actual domain boundaries. Wait until you have at least two callers or the logic exceeds 15-20 lines before extracting.

Lesson 3: Composition over orchestration

The biggest mistake we see in client codebases is “orchestrator” services that coordinate 5-10 other services in sequence. These become fragile and hard to test.

Instead, we use a pipeline approach:

class Apprenticeships::Apply
  def call(application)
    result = Validations::CheckEligibility.call(application)
    return result if result.failure?

    result = Documents::ProcessAttachments.call(application)
    return result if result.failure?

    Notifications::NotifyEmployer.call(application)
  end
end

Each step is independently testable. Failures short-circuit cleanly. No 200-line orchestrator method.

Lesson 4: Error boundaries matter more than error handling

In long-running systems, the services that cause the most trouble are not the ones with bugs. They are the ones where errors from one domain leak into another. We enforce strict error boundaries: a payment service never raises inventory exceptions. Each service catches its own domain errors and returns a Result.

This discipline is what keeps a 13-year-old codebase maintainable.

FAQs

How do service objects help make Ruby on Rails applications easier to maintain?

Service objects isolate business logic into focused classes. Controllers stay thin, models stay clean, and each piece of logic has exactly one home. When a bug report comes in about invoice creation, you go straight to CreateInvoice and its tests.

How should I structure service objects for code clarity?

Name services with action verbs (CreateUser, ProcessPayment). Each service handles one responsibility through a single call method. Organize by domain in app/services/. Use consistent return patterns across all services.

How do service objects improve error handling in Rails?

Service objects catch and manage errors internally, returning structured Result objects. Controllers never deal with raw exceptions from business logic. This creates consistent error responses and eliminates duplicate error-handling code across controllers.