Rails Service Objects in 2026: Design Patterns, PORO en Technische Sollicitatievragen

Service Objects in Rails extraheren businesslogica uit controllers en models naar Plain Old Ruby Objects (POROs). Dit pattern houdt Rails-applicaties onderhoudbaar en wordt vaak getest in technische sollicitatiegesprekken.

Rails Service Objects in 2026: Design Patterns, PORO en Technische Sollicitatievragen

Service Objects in Rails extraheren businesslogica uit controllers en models naar Plain Old Ruby Objects (POROs). Dit pattern houdt Rails-applicaties onderhoudbaar terwijl ze groeien, en interviewers testen het frequent om het begrip van een kandidaat van clean architecture te beoordelen.

Wat interviewers verwachten

Een Service Object kapselt één businessoperatie in. De klasse heeft één publieke methode (typisch call), accepteert dependencies via de constructor en retourneert een voorspelbaar resultaat. Kandidaten die kunnen uitleggen waarom dit belangrijk is – niet alleen hoe het te implementeren – vallen op.

Waarom Service Objects Fat Model en Fat Controller problemen oplossen

Rails moedigt aan om logica ergens te plaatsen. Zonder begeleiding duwen teams het in models ("fat models, skinny controllers") totdat ActiveRecord-klassen uitgroeien tot duizenden regels. Anderen laten het in controllers, waardoor actions onmogelijk geïsoleerd te testen zijn.

Service Objects bieden een derde weg: domeinlogica leeft in dedicated klassen die van niets uit Rails afhangen behalve wat ze expliciet nodig hebben. Deze ontkoppeling maakt de code testbaar zonder het volledige framework te laden, portable over verschillende ingangspunten (controllers, background jobs, console) en leesbaar omdat elke klasse één ding doet.

De Rails Guides schrijven geen Service Objects voor, maar de community heeft zich op het pattern gestandardiseerd. Rails 8 benadrukt eenvoud met Solid Queue en Solid Cache die externe dependencies vervangen, wat POROs nog aantrekkelijker maakt: minder gems, meer pure Ruby.

Anatomie van een productie-ready Service Object

Een Service Object heeft drie dingen nodig: een constructor die dependencies ontvangt, één publieke call-methode en een return type dat succes of falen signaleert.

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

De constructor accepteert user_repo en mailer met standaardwaarden. Productiecode gebruikt de defaults; tests injecteren mocks. De call-methode valideert, persisteert, verstuurt een e-mail en wikkelt elk resultaat in een Result-object.

Een minimale Result-klasse bouwen zonder gems

Sommige teams grijpen direct naar dry-monads. De gem is solide, maar voegt een leercurve toe. Een 30-regels Result-klasse dekt de meeste behoeften:

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

Deze Result-klasse ondersteunt chaining met on_success en on_failure, exposeert gestructureerde foutcodes en vereist geen dependencies. Controllers consumeren het schoon:

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
Wanneer dry-monads gebruiken

Teams die al het dry-rb ecosysteem gebruiken, of teams die Do-notatie nodig hebben voor het chainen van meerdere operaties, profiteren van dry-monads. De gem biedt Success, Failure en Maybe types met pattern matching ondersteuning in Ruby 3.x.

Naamgevingsconventies die intentie signaleren

Twee conventies domineren:

StijlVoorbeeldWanneer gebruiken
WerkwoordzinCreateAccount, SendInvoice, RefundPaymentCommando's die state wijzigen
Zelfstandig naamwoord met -orAccountCreator, InvoiceSender, PaymentRefunderMinder gebruikelijk, sommige teams prefereren het

De werkwoordzinstijl leest natuurlijk: Users::CreateAccount.new.call(params). Het -or suffix werkt maar voegt lettergrepen toe zonder duidelijkheid.

Directorystructuur is ook belangrijk. Groeperen per domein houdt gerelateerde services bij elkaar:

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

Klaar om je Ruby on Rails gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Dependency Injection voor testbare services

Hardcoded dependencies maken testen pijnlijk. Deze service kan niet getest worden zonder de database te raken en echte e-mails te versturen:

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

Injectie via de constructor lost dit op:

ruby
# Beter: injecteerbare 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 substitueren nu 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

Geen database, geen e-mails, snelle uitvoering. De RSpec-documentatie behandelt class_double en instance_double voor type-geverifieerde mocks.

Sollicitatievragen over Rails Service Objects

Deze vragen verschijnen in mid-level en senior Rails-interviews. Bereid concrete antwoorden voor met codevoorbeelden.

V: Wanneer moet businesslogica in een model versus een Service Object leven?

Models bezitten validaties, associaties, scopes en single-record gedrag. Service Objects handelen operaties af die meerdere models overspannen, externe aanroepen vereisen of expliciete transactiegrenzen nodig hebben. Een User-model valideert het e-mailformaat; een CreateAccount-service maakt de gebruiker aan, verstuurt een welkomst-e-mail en provisioneert standaardinstellingen.

V: Hoe ga je om met fouten in Service Objects zonder exceptions?

Retourneer een Result-object met success/failure states. Dit maakt foutafhandeling expliciet in de aanroeper, vermijdt exception-gebaseerde control flow en biedt gestructureerde foutcodes voor verschillende faalscenario's. Exceptions blijven gepast voor echt uitzonderlijke condities (database down, netwerkfout) in plaats van businessregelschendingen.

V: Wat is het verschil tussen een Service Object en een Interactor?

Interactors (van gems zoals interactor) zijn Service Objects met een specifieke interface: ze ontvangen een context-hash, muteren deze en signaleren falen via context.fail!. Service Objects als POROs hebben geen voorgeschreven interface, wat teams meer flexibiliteit maar minder consistentie geeft.

V: Hoe test je een Service Object dat externe APIs aanroept?

Injecteer de HTTP-client als dependency. In tests, geef een stub door die voorgedefinieerde responses retourneert. Tools zoals WebMock of VCR kunnen HTTP-interacties opnemen en afspelen, maar constructor-injectie vermijdt netwerkaanroepen volledig in unit tests.

Voor meer Rails-sollicitatievoorbereiding, zie de Rails sollicitatievragen gids over RSpec en testing patterns.

Railway-Oriented Design voor complexe workflows

Railway-oriented programming behandelt een workflow als twee parallelle sporen: succes gaat vooruit, falen stopt onmiddellijk. Elke stap gaat ofwel verder op het successpoor of schakelt naar het faalspoor.

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

De Result-klasse heeft een then-methode nodig die alleen doorgaat bij succes:

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

Als de inventarisreservering faalt, draait de betaling nooit. Als de betaling faalt, draait de fulfillment nooit. Elke stap retourneert een Result, en de keten breekt af bij de eerste fout.

Wanneer Service Objects een anti-pattern worden

Service Objects lossen echte problemen op maar kunnen onnodig prolifereren. Let op deze waarschuwingssignalen:

  • Single-line services: Als de service alleen één model-methode aanroept, voegt de service indirectie toe zonder waarde. User.authenticate(email, password) is beter dan AuthenticateUser.new.call(email, password) wanneer de logica eenvoudig is.

  • Services die alleen voor testen bestaan: Dependencies injecteren maakt testen makkelijker, maar services maken alleen om model-methodes te wrappen voor testbaarheid suggereert dat het echte probleem de test setup is, niet de architectuur.

  • Anemische services zonder logica: Services die alleen delegeren naar models zonder gedrag toe te voegen zijn ceremonie. De Netguru engineering blog bespreekt dit over-extractie probleem.

Het alternatief: gebruik POROs selectief naast Concerns voor gedeeld model-gedrag, Value Objects voor domeinconcepten zoals Money of DateRange, en model-methodes voor single-record operaties.

Kernpunten voor Rails Service Object Design

  • Extraheer naar een Service Object wanneer logica meerdere models overspant, externe aanroepen vereist of expliciete foutafhandeling nodig heeft
  • Gebruik één publieke call-methode die een Result-object retourneert, geen ruwe waarden of exceptions
  • Injecteer dependencies via de constructor met verstandige defaults voor productiegebruik
  • Benoem services met werkwoordzinnen zoals CreateAccount of PlaceOrder die de actie beschrijven
  • Organiseer services per domein (users/, orders/) in plaats van een platte directorystructuur
  • Begin met een minimale Result-klasse voordat gems zoals dry-monads worden toegevoegd
  • Reserveer exceptions voor infrastructuurfalen, niet voor businessregelschendingen
  • Vermijd het maken van services voor single-line operaties die models natuurlijk afhandelen

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Dagelijkse challenge

Zie jij de bug in Ruby on Rails?

Een echt codefragment, een verborgen bug, één poging per dag. Zonder account uit te proberen.

Anthony Fillion-Maillet

Geschreven door

Anthony Fillion-Maillet

Oprichter van SharpSkill

Al meer dan 10 jaar fullstack-ontwikkelaar. Hij leidt SharpSkill en staat in voor alles wat hier verschijnt.

Bijgewerkt op 20 augustus 2026

Delen

Gerelateerde artikelen