Rails Service Objects: Patronen Die Echt Werken in Productie
Je Rails controllers worden te dik. Je models weten te veel. Je hebt gelezen dat service objects de oplossing zijn, maar elk blogartikel toont een ander patroon en geen ervan behandelt wat er gebeurt wanneer je service een andere service aanroept die een derde service aanroept en halverwege iets misgaat.
Dit is wat daadwerkelijk werkt na jarenlang service objects in productie Rails-apps (Rails 7.1+ en 8.0) te draaien.
Wat een Service Object Eigenlijk Is
Een service object is een gewone Ruby-class die verantwoordelijk is voor één bedrijfsoperatie. Het neemt input, doet één ding, en geeft een resultaat terug. Dat is alles.
Het sleutelwoord is operatie, niet ding. UserCreator is een service. UserHelper niet — dat is gewoon een rommella met een classnaam.
# app/services/users/create.rb
module Users
class Create
def initialize(params:, invited_by: nil)
@params = params
@invited_by = invited_by
end
def call
user = User.new(@params)
ActiveRecord::Base.transaction do
user.save!
Onboarding::SendWelcomeEmail.new(user: user).call
track_referral(user) if @invited_by
end
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors)
end
private
def track_referral(user)
Referrals::Track.new(referrer: @invited_by, referred: user).call
end
end
end
Dit handelt gebruikersaanmaak, welkomstmail en referral-tracking af in één transactie. De controller blijft dun:
class UsersController < ApplicationController
def create
result = Users::Create.new(params: user_params).call
if result.success?
redirect_to dashboard_path
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
end
Het Result Object Patroon
Elke service moet succes of falen communiceren. true/false retourneren werkt tot je foutmeldingen nodig hebt. Exceptions gooien werkt tot je verwachte fouten netjes wilt afhandelen. Een simpel result object lost beide op:
# app/services/result.rb
class Result
attr_reader :value, :errors
def initialize(success:, value: nil, errors: nil)
@success = success
@value = value
@errors = errors
end
def success? = @success
def failure? = !@success
def self.success(value = nil)
new(success: true, value: value)
end
def self.failure(errors)
new(success: false, errors: Array(errors))
end
end
Dertig regels. Geen gems. De code is van jou. Ik heb teams gezien die dry-monads of interactor installeerden hiervoor en meer tijd besteedden aan het leren van de gem-API dan aan het schrijven van bedrijfslogica.
Naamgeving en Bestandsorganisatie
Zet services in app/services/, genamespaced per domein:
app/services/
├── result.rb
├── users/
│ ├── create.rb
│ ├── deactivate.rb
│ └── merge_accounts.rb
├── orders/
│ ├── place.rb
│ ├── cancel.rb
│ └── refund.rb
└── reports/
├── generate_monthly.rb
└── export_csv.rb
Noem services met werkwoorden, niet zelfstandige naamwoorden. Orders::Place, niet OrderPlacer. Users::Deactivate, niet UserDeactivationService. Het Service-achtervoegsel is overbodig — alles in app/services/ is een service.
Rails autoloading pikt app/services/ standaard op in Rails 7+ met Zeitwerk. Geen configuratie nodig.
Wanneer een Service Object Extraheren
Niet alles heeft een service nodig. Dit is het besliskader dat ik gebruik:
Extraheer naar een service wanneer:
- De operatie meerdere models of externe systemen omvat
- De logica niet bij één enkel model hoort
- Dezelfde operatie vanuit meerdere plekken wordt aangeroepen (controller, achtergrondtaak, console)
- De operatie complexe foutafhandeling of rollback-logica heeft
Houd het in het model wanneer:
- Het een validatie of callback voor één model is
- Het een query scope is
- Het een simpele berekening op modelattributen is
Houd het in de controller wanneer:
- Het alleen parameterfiltering en een enkele model-save is
- Er geen bedrijfslogica is buiten CRUD
De slechtste service objects die ik heb gezien zijn degene die User.create(params) in een class wrappen. Dat is geen abstractie — dat is indirectie.
Foutafhandeling Die Niet Instort
Het lastige deel is service-compositie — wanneer services andere services aanroepen.
Probleem: Exceptions Die Doorvallen
Alleen de exceptions rescuen die je verwacht. Laat bugs omhoog bubbelen. Gebruik ActiveRecord::Rollback voor gecontroleerde transactiefouten.
Probleem: Niet-Kritieke Fouten Blokkeren Kritieke Operaties
Soms roept een service een andere service aan die niet essentieel is. Een welkomstmail die faalt zou gebruikersaanmaak niet moeten tegenhouden. Duw niet-kritiek werk naar een achtergrondtaak:
def call
user = User.new(@params)
user.save!
OnboardingMailer.welcome(user).deliver_later
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
end
Service Objects Testen
Services zijn makkelijk te testen omdat het gewone Ruby is. Geen controller-context, geen view-rendering:
# test/services/users/create_test.rb
require "test_helper"
class Users::CreateTest < ActiveSupport::TestCase
test "creates user with valid params" do
result = Users::Create.new(
params: { email: "test@example.com", name: "Test User", password: "secure123" }
).call
assert result.success?
assert_equal "test@example.com", result.value.email
end
test "returns failure with invalid params" do
result = Users::Create.new(params: { email: "" }).call
assert result.failure?
assert_includes result.errors.first, "Email"
end
end
Test de publieke interface: call met inputs, assert op het resultaat. Test geen private methods direct.
Veelvoorkomende Fouten
De God Service. Een service die tien dingen doet is geen service — het is een procedure in een class-kostuum. Als je call-methode meer dan 30 regels is, splits het op.
Internals testen. Mock reserve_inventory! en process_payment! niet apart. Test de service end-to-end.
Gemixte returntypes. Als call soms een User retourneert, soms nil, en soms een exception gooit — kies een patroon. Het Result object elimineert deze hele klasse van bugs.
De transactie overslaan. Als je service naar meerdere tabellen schrijft, wrap het in een transactie.
Veelgestelde Vragen
Moet ik een gem zoals Interactor of Trailblazer gebruiken voor service objects?
Voor de meeste Rails-apps zijn gewone Ruby-classes met een Result object genoeg. Interactor voegt een context-object en hooks toe die koppeling creëren tussen stappen. Trailblazer is een volledig architectuurframework — nuttig voor complexe projecten, maar zwaar voor typische CRUD-apps. Begin simpel.
Hoe verhouden service objects zich tot Rails concerns?
Ze lossen verschillende problemen op. Concerns delen gedrag tussen models (zoals Taggable of Searchable). Service objects kapselen bedrijfsoperaties in die meerdere models of externe systemen omvatten. Een concern zou geen service moeten aanroepen, en een service zou geen concern moeten includen.
Moeten service objects vanuit model-callbacks worden aangeroepen?
Nee. Model-callbacks horen modelinterne consistentie te bewaken. Services aanroepen vanuit callbacks creëert verborgen bijeffecten die debugging pijnlijk en testen fragiel maken. Trigger services vanuit controllers of achtergrondtaken waar de intentie expliciet is.
Hoe ga ik om met autorisatie in service objects?
Geef de huidige gebruiker mee als argument en controleer permissies vroeg. Gebruik geen Current.user binnen services — het creëert een impliciete afhankelijkheid die breekt in achtergrondtaken en tests.
Wat is het verschil tussen een service object en een form object?
Een form object handelt inputvalidatie en -coercie af voor een specifiek formulier. Een service object handelt bedrijfslogica af voor een specifieke operatie. Ze kunnen samenwerken: de controller geeft gevalideerde formulierdata door aan een service.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation