# 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. - Published: 2026-08-20 - Updated: 2026-08-20 - Author: Anthony Fillion-Maillet - Tags: ruby-on-rails, design-patterns, service-objects, poro, clean-architecture - Reading time: 9 min --- 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](https://guides.rubyonrails.org/) 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](https://dry-rb.org/gems/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](https://dry-rb.org/gems/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.rb ``` ## 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](https://rspec.info/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](https://github.com/collectiveidea/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](https://github.com/bblimke/webmock) or [VCR](https://github.com/vcr/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](/technologies/ruby-on-rails/interview-questions/rails-testing-rspec) 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](https://www.netguru.com/blog/service-objects-in-rails) discusses this over-extraction problem. The alternative: use POROs selectively alongside [Concerns](https://api.rubyonrails.org/classes/ActiveSupport/Concern.html) for shared model behavior, [Value Objects](https://martinfowler.com/bliki/ValueObject.html) 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/ruby-on-rails/rails-service-objects-design-patterns-poro-interview-questions