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 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.
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.
# 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
endThe 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:
# 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
endThis Result supports chaining with on_success and on_failure, exposes structured error codes, and requires zero dependencies. Controllers consume it cleanly:
# 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
endTeams 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:
| Style | Example | When to use |
|---|---|---|
| Verb phrase | CreateAccount, SendInvoice, RefundPayment | Commands that change state |
| Noun with -or | AccountCreator, InvoiceSender, PaymentRefunder | Less 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:
app/services/
users/
create_account.rb
reset_password.rb
update_profile.rb
orders/
place_order.rb
cancel_order.rb
calculate_totals.rb
result.rbReady 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:
# Avoid: hardcoded dependencies
class CreateAccount
def call(params)
user = User.create!(params) # Direct model call
UserMailer.welcome(user).deliver # Direct mailer call
end
endInjection through the constructor solves this:
# 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
endTests now substitute fakes:
# 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
endNo 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.
# 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
endThe Result class needs a then method that only proceeds on success:
# Add to app/services/result.rb
def then
return self if failure?
yield(value)
endIf 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)beatsAuthenticateUser.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
callmethod 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
CreateAccountorPlaceOrderthat 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.
Can you spot the bug in Ruby on Rails?
One real snippet, one hidden bug, one attempt a day. No account needed to try.

Written by
Anthony Fillion-MailletFounder of SharpSkill
Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.
Updated on August 20, 2026
Tags
Share
Related articles

Rails Background Jobs in 2026: Sidekiq vs Good Job and Interview Questions
Compare Sidekiq, Good Job, and Solid Queue for Rails background jobs. Learn when to choose each option and prepare for technical interview questions about Active Job.

Rails Active Storage in 2026: File Uploads, S3 Integration and Interview Questions
Master Rails Active Storage for file uploads with S3 and direct uploads. Complete tutorial with code examples and common interview questions about file handling in Ruby on Rails.

Rails Credentials and Secrets in 2026: Secure Environment Variables Management
Master Rails 8 credentials system for secure secret management. Learn encrypted credentials, environment-specific keys, deployment strategies, and best practices for API keys and database passwords.