Rails Service Objects in 2026: Design Patterns, PORO and Technical Interview Questions

Master Rails service objects with PORO patterns, Result monads, and clean architecture. Includes real interview questions and production-ready code examples for Rails 8.

Rails Service Objects design patterns and clean architecture illustration

Rails service objects extract business logic from controllers and models into Plain Old Ruby Objects (POROs). This pattern keeps Rails applications maintainable as they grow, and interviewers test it frequently to assess a candidate's understanding of clean architecture.

What interviewers expect

A service object encapsulates one business operation. The class has a single public method (typically call), accepts dependencies through its constructor, and returns a predictable result. Candidates who can articulate why this matters, not just how to implement it, stand out.

Why Service Objects Solve Fat Model and Fat Controller Problems

Rails encourages putting logic somewhere. Without guidance, teams push it into models ("fat models, skinny controllers") until ActiveRecord classes balloon to thousands of lines. Others leave it in controllers, making actions impossible to test in isolation.

Service objects offer a third path: domain logic lives in dedicated classes that depend on nothing from Rails except what they explicitly require. This decoupling makes the code testable without loading the full framework, portable across different entry points (controllers, background jobs, console), and readable because each class does one thing.

The Rails Guides don't prescribe service objects, but the community has standardized around the pattern. Rails 8's emphasis on simplicity, with Solid Queue and Solid Cache replacing external dependencies, makes POROs even more attractive: fewer gems, more plain Ruby.

Anatomy of a Production-Ready Service Object

A service object needs three things: a constructor that receives dependencies, a single public call method, and a return type that signals success or failure.

ruby
# app/services/users/create_account.rb
module Users
  class CreateAccount
    def initialize(user_repo: User, mailer: UserMailer)
      @user_repo = user_repo
      @mailer = mailer
    end

    def call(params)
      user = @user_repo.new(params)
      return Result.failure(:validation_failed, user.errors) unless user.valid?

      user.save!
      @mailer.welcome_email(user).deliver_later
      Result.success(user)
    rescue ActiveRecord::RecordNotUnique
      Result.failure(:email_taken, "Email already registered")
    end
  end
end

The constructor accepts user_repo and mailer with default values. Production code uses the defaults; tests inject mocks. The call method validates, persists, sends an email, and wraps every outcome in a Result object.

Building a Minimal Result Class Without Gems

Some teams reach for dry-monads immediately. The gem is solid, but adds a learning curve. A 30-line Result class covers most needs:

ruby
# app/services/result.rb
class Result
  attr_reader :value, :error, :code

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

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

  def self.success(value) = new(success: true, value: value)
  def self.failure(code, error = nil) = new(success: false, code: code, error: error)

  def on_success
    yield(value) if success?
    self
  end

  def on_failure
    yield(code, error) if failure?
    self
  end
end

This Result supports chaining with on_success and on_failure, exposes structured error codes, and requires zero dependencies. Controllers consume it cleanly:

ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    result = Users::CreateAccount.new.call(user_params)

    result
      .on_success { |user| redirect_to user, notice: "Account created" }
      .on_failure { |code, error| render :new, status: :unprocessable_entity }
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :name)
  end
end
When to use dry-monads instead

Teams already using the dry-rb ecosystem, or those who need Do notation for chaining multiple operations, benefit from dry-monads. The gem provides Success, Failure, and Maybe types with pattern matching support in Ruby 3.x.

Naming Conventions That Signal Intent

Two conventions dominate:

StyleExampleWhen to use
Verb phraseCreateAccount, SendInvoice, RefundPaymentCommands that change state
Noun with -orAccountCreator, InvoiceSender, PaymentRefunderLess common, some teams prefer it

The verb phrase style reads naturally: Users::CreateAccount.new.call(params). The -or suffix works but adds syllables without clarity.

Directory structure matters too. Grouping by domain keeps related services together:

text
app/services/
  users/
    create_account.rb
    reset_password.rb
    update_profile.rb
  orders/
    place_order.rb
    cancel_order.rb
    calculate_totals.rb
  result.rb

Ready to ace your Ruby on Rails interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Dependency Injection for Testable Services

Hardcoded dependencies make testing painful. This service cannot be tested without hitting the database and sending real emails:

ruby
# Avoid: hardcoded dependencies
class CreateAccount
  def call(params)
    user = User.create!(params)        # Direct model call
    UserMailer.welcome(user).deliver   # Direct mailer call
  end
end

Injection through the constructor solves this:

ruby
# Better: injectable dependencies
class CreateAccount
  def initialize(user_repo: User, mailer: UserMailer)
    @user_repo = user_repo
    @mailer = mailer
  end

  def call(params)
    user = @user_repo.create!(params)
    @mailer.welcome(user).deliver_later
    Result.success(user)
  end
end

Tests now substitute fakes:

ruby
# spec/services/users/create_account_spec.rb
RSpec.describe Users::CreateAccount do
  let(:fake_repo) { class_double(User) }
  let(:fake_mailer) { class_double(UserMailer) }
  let(:service) { described_class.new(user_repo: fake_repo, mailer: fake_mailer) }

  it "returns success with valid params" do
    user = instance_double(User, valid?: true)
    allow(fake_repo).to receive(:new).and_return(user)
    allow(user).to receive(:save!)
    allow(fake_mailer).to receive_message_chain(:welcome_email, :deliver_later)

    result = service.call(email: "test@example.com", password: "secure123")

    expect(result).to be_success
  end
end

No database, no emails, fast execution. The RSpec documentation covers class_double and instance_double for type-verified mocks.

Interview Questions on Rails Service Objects

These questions appear in mid-level and senior Rails interviews. Prepare concrete answers with code examples.

Q: When should business logic live in a model versus a service object?

Models own validations, associations, scopes, and single-record behavior. Service objects handle operations that span multiple models, require external calls, or need explicit transaction boundaries. A User model validates email format; a CreateAccount service creates the user, sends a welcome email, and provisions default settings.

Q: How do you handle errors in service objects without exceptions?

Return a Result object with success/failure states. This makes error handling explicit in the caller, avoids exception-based control flow, and provides structured error codes for different failure modes. Exceptions remain appropriate for truly exceptional conditions (database down, network failure) rather than business rule violations.

Q: What's the difference between a service object and an interactor?

Interactors (from gems like interactor) are service objects with a specific interface: they receive a context hash, mutate it, and signal failure through context.fail!. Service objects as POROs have no prescribed interface, giving teams more flexibility but less consistency.

Q: How do you test a service object that calls external APIs?

Inject the HTTP client as a dependency. In tests, pass a stub that returns canned responses. Tools like WebMock or VCR can record and replay HTTP interactions, but constructor injection avoids network calls entirely in unit tests.

For more Rails interview preparation, see the Rails interview questions guide covering RSpec and testing patterns.

Railway-Oriented Design for Complex Workflows

Railway-oriented programming treats a workflow as two parallel tracks: success continues forward, failure exits immediately. Each step either advances on the success track or switches to the failure track.

ruby
# app/services/orders/place_order.rb
module Orders
  class PlaceOrder
    def initialize(
      inventory: InventoryService.new,
      payment: PaymentService.new,
      fulfillment: FulfillmentService.new
    )
      @inventory = inventory
      @payment = payment
      @fulfillment = fulfillment
    end

    def call(cart:, payment_method:)
      validate_cart(cart)
        .then { |items| reserve_inventory(items) }
        .then { |reservation| charge_payment(reservation, payment_method) }
        .then { |charge| create_fulfillment(charge) }
    end

    private

    def validate_cart(cart)
      return Result.failure(:empty_cart, "Cart is empty") if cart.items.empty?
      Result.success(cart.items)
    end

    def reserve_inventory(items)
      @inventory.reserve(items)
    end

    def charge_payment(reservation, payment_method)
      result = @payment.charge(reservation.total, payment_method)
      return result if result.failure?

      Result.success({ reservation: reservation, charge: result.value })
    end

    def create_fulfillment(data)
      @fulfillment.create(data[:reservation], data[:charge])
    end
  end
end

The Result class needs a then method that only proceeds on success:

ruby
# Add to app/services/result.rb
def then
  return self if failure?
  yield(value)
end

If inventory reservation fails, payment never runs. If payment fails, fulfillment never runs. Each step returns a Result, and the chain short-circuits on the first failure.

When Service Objects Become an Anti-Pattern

Service objects solve real problems but can proliferate unnecessarily. Watch for these warning signs:

  • Single-line services: If the service just calls one model method, the service adds indirection without value. User.authenticate(email, password) beats AuthenticateUser.new.call(email, password) when the logic is simple.

  • Services that only exist for testing: Injecting dependencies makes testing easier, but creating services solely to wrap model methods for testability suggests the real problem is test setup, not architecture.

  • Anemic services with no logic: Services that just delegate to models without adding behavior are ceremony. The Netguru engineering blog discusses this over-extraction problem.

The alternative: use POROs selectively alongside Concerns for shared model behavior, Value Objects for domain concepts like Money or DateRange, and model methods for single-record operations.

Key Takeaways for Rails Service Object Design

  • Extract to a service object when logic spans multiple models, requires external calls, or needs explicit error handling
  • Use a single public call method that returns a Result object, not raw values or exceptions
  • Inject dependencies through the constructor with sensible defaults for production use
  • Name services with verb phrases like CreateAccount or PlaceOrder that describe the action
  • Organize services by domain (users/, orders/) rather than a flat directory structure
  • Start with a minimal Result class before adding gems like dry-monads
  • Reserve exceptions for infrastructure failures, not business rule violations
  • Avoid creating services for single-line operations that models handle naturally

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Daily challenge

Can you spot the bug in Ruby on Rails?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 20, 2026

Tags

#ruby-on-rails
#design-patterns
#service-objects
#poro
#clean-architecture

Share

Related articles