RailsConf 2021: Missing Guide to Service Objects in Rails - Riaz Virani
Warum Service Objects so beliebt sind (und oft falsch eingesetzt werden)
Service Objects gehören zu den meistdiskutierten Patterns in der Rails-Community. Fast jedes Tutorial empfiehlt sie ab dem ersten Controller, der mehr als fünf Zeilen hat. Das Problem: Die meisten Artikel zeigen triviale Beispiele und ignorieren die realen Tradeoffs.
In der Praxis sehen wir bei USEO regelmässig Rails-Projekte, in denen Service Objects die Codebasis nicht vereinfacht, sondern fragmentiert haben. 200 kleine Klassen in app/services/, keine davon wiederverwendbar, jede mit einer einzigen call-Methode, die drei Zeilen Code enthält.
USEO’s Take: Service Objects lösen ein reales Problem, aber erst ab einer bestimmten Komplexitätsstufe. Für Projekte unter 20 Models reicht oft eine saubere Aufteilung zwischen Model Concerns, Controller-Methoden und gelegentlichen Plain Old Ruby Objects (POROs). Service Objects einzuführen, bevor die Schmerzen da sind, ist Premature Abstraction.
Das eigentliche Problem: Wohin mit Geschäftslogik?
Rails gibt keine klare Antwort auf die Frage, wo Geschäftslogik hingehört, die weder ins Model noch in den Controller passt. Die MVC-Architektur hat diese Lücke bewusst offen gelassen.
Typische Symptome, die nach einer Lösung rufen:
- Fat Controllers: Aktionen mit 30+ Zeilen, die Validierung, API-Calls und E-Mail-Versand mischen
- God Models: Ein
User-Model mit 800 Zeilen und Methoden für Billing, Notifications und Reporting - Callback-Hölle:
after_save-Chains, die unvorhersehbare Seiteneffekte auslösen
Service Objects sind eine mögliche Antwort. Aber nicht die einzige.
Alternativen, die oft übersehen werden
Bevor du app/services/ anlegst, prüfe diese Optionen:
| Pattern | Wann sinnvoll | Beispiel |
|---|---|---|
| Model Concern | Logik gehört zum Model, aber wird zu gross | Billable, Searchable |
| Form Object | Validierung über mehrere Models hinweg | RegistrationForm mit User + Company |
| Query Object | Komplexe Datenbankabfragen kapseln | OverdueInvoicesQuery |
| Policy Object | Autorisierungslogik | ProjectAccessPolicy |
| Value Object | Immutable Datenstrukturen | Money, DateRange |
| Service Object | Orchestrierung mehrerer Schritte mit Seiteneffekten | ProcessSubscriptionRenewal |
USEO’s Take: Wir greifen zum Service Object erst, wenn eine Operation mindestens zwei der folgenden Merkmale hat: (1) sie koordiniert mehrere Models, (2) sie hat externe Seiteneffekte (API, E-Mail, Queue), (3) sie muss transaktional ablaufen. Alles andere lässt sich besser mit spezifischeren Patterns lösen.
Benennung: Der Name ist die Dokumentation
Ein Service Object, dessen Name nicht sofort klar macht, was es tut, hat seinen Zweck verfehlt.
Bewährt in der Praxis:
# Gut: Verb + Kontext + Zweck
ProcessPaymentRefund
SendOrderConfirmationEmail
SyncInventoryWithWarehouse
ImportUsersFromCsv
# Schlecht: Vage, generisch, unklar
UserManager
DataProcessor
HandleSubscription
OrderService
Drei Regeln, die bei USEO gelten:
- Immer ein Verb am Anfang.
Create,Process,Send,Calculate,Sync,Import. KeinManager,Handler,Helper. - Ein Name pro Aktion. Wenn du
Createfür “anlegen” nutzt, verwende nicht auchBuildoderGeneratedafür. - Der Name verrät den Scope.
RegisterUserstattCreateUser, wenn der Service Account-Erstellung, E-Mail-Versand und Welcome-Nachricht umfasst.
Struktur: Weniger ist mehr
Die minimale Variante
Für die meisten Fälle reicht eine einfache Klasse:
class ProcessPaymentRefund
def initialize(order:, reason:)
@order = order
@reason = reason
end
def call
validate_refund_eligibility!
refund = create_refund
notify_customer(refund)
update_order_status
ServiceResult.success(data: refund)
rescue RefundNotEligible => e
ServiceResult.failure(error: e.message)
rescue Stripe::StripeError => e
ServiceResult.failure(error: "Payment provider error: #{e.message}")
end
private
attr_reader :order, :reason
def validate_refund_eligibility!
raise RefundNotEligible, "Order too old" if order.created_at < 30.days.ago
raise RefundNotEligible, "Already refunded" if order.refunded?
end
def create_refund
Stripe::Refund.create(
payment_intent: order.stripe_payment_intent_id,
reason: reason
)
end
def notify_customer(refund)
OrderMailer.refund_confirmation(order, refund).deliver_later
end
def update_order_status
order.update!(status: :refunded, refunded_at: Time.current)
end
end
Klassenmethode als Shortcut
Ein häufiges Pattern: Eine .call-Klassenmethode, die das Objekt instanziiert:
class ProcessPaymentRefund
def self.call(...)
new(...).call
end
# ... rest bleibt gleich
end
# Nutzung:
ProcessPaymentRefund.call(order: order, reason: :customer_request)
Verzeichnisstruktur nach Projektgrösse
Klein (< 15 Services): Flache Struktur
app/services/
├── create_user_account.rb
├── process_payment_refund.rb
└── send_welcome_email.rb
Mittel (15-50 Services): Domänen-Ordner
app/services/
├── orders/
│ ├── process_payment.rb
│ └── calculate_total.rb
├── users/
│ ├── register.rb
│ └── deactivate.rb
└── shared/
└── send_notification.rb
Gross (50+ Services): Hier lohnt es sich, über Gems wie packwerk nachzudenken, die echte Modulgrenzen in Rails-Monolithen erzwingen.
Ergebnisobjekte: Wie der Service kommuniziert
Das ServiceResult-Pattern
Ein simples, aber robustes Ergebnisobjekt:
class ServiceResult
attr_reader :data, :error, :metadata
def self.success(data: nil, metadata: {})
new(success: true, data: data, metadata: metadata)
end
def self.failure(error:, metadata: {})
new(success: false, error: error, metadata: metadata)
end
def initialize(success:, data: nil, error: nil, metadata: {})
@success = success
@data = data
@error = error
@metadata = metadata
end
def success? = @success
def failure? = !@success
end
Im Controller:
def create
result = ProcessPaymentRefund.call(order: @order, reason: params[:reason])
if result.success?
redirect_to order_path(@order), notice: "Rückerstattung erfolgreich"
else
flash.now[:alert] = result.error
render :show
end
end
Wann reicht ein Boolean?
Für einfache Operationen ohne Rückgabedaten ist ein Boolean völlig ausreichend:
class DeactivateUser
def self.call(user:)
return false if user.admin?
user.update(active: false, deactivated_at: Time.current)
end
end
Kein Grund, hier ein Result-Objekt zu erzwingen.
Wann lohnt sich dry-monads?
Das Gem dry-monads bietet Success und Failure als echte Monaden mit bind, fmap und value_or:
class CreateOrder
include Dry::Monads[:result, :do]
def call(params)
values = yield validate(params)
order = yield persist(values)
yield send_confirmation(order)
Success(order)
end
private
def validate(params)
result = OrderSchema.call(params)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
def persist(values)
order = Order.create(values)
order.persisted? ? Success(order) : Failure(order.errors.full_messages)
end
def send_confirmation(order)
OrderMailer.confirmation(order).deliver_later
Success()
end
end
USEO’s Take: dry-monads lohnt sich ab ca. 30+ Service Objects und bei Workflows mit drei oder mehr sequentiellen Schritten, bei denen jeder Schritt fehlschlagen kann. Für kleinere Projekte ist die Lernkurve im Team den Nutzen nicht wert. Wir setzen es bei etwa der Hälfte unserer Projekte ein, bevorzugen aber die Do-Notation gegenüber manuellen bind-Chains.
Interactor Gem: Die strukturierte Alternative
Das interactor Gem bietet Service Objects mit eingebautem Context und Rollback:
class PlaceOrder
include Interactor::Organizer
organize ValidateInventory, ChargeCustomer, FulfillOrder, SendConfirmation
end
class ChargeCustomer
include Interactor
def call
result = PaymentGateway.charge(context.amount, context.card_token)
context.charge_id = result.id
rescue PaymentGateway::Error => e
context.fail!(message: e.message)
end
def rollback
PaymentGateway.refund(context.charge_id) if context.charge_id
end
end
USEO’s Take: Interactor ist nützlich für lineare Workflows mit Rollback-Bedarf (z.B. E-Commerce-Checkout). Die Organizer-Komposition ist elegant. Aber Vorsicht: Der context ist ein offenes Objekt ohne Typensicherheit. Bei grösseren Projekten bevorzugen wir dry-monads mit expliziten Parametern.
Fehlerbehandlung: Vorhersagbar statt defensiv
Fehlertypen unterscheiden
Nicht jeder Fehler ist gleich. Unterscheide zwischen:
# Erwartete Business-Fehler: Mit Result-Objekt zurückgeben
ServiceResult.failure(error: "Bestellung kann nicht storniert werden")
# Unerwartete technische Fehler: Exception werfen lassen
# (Wird von Rails Error-Reporting aufgefangen)
raise ActiveRecord::ConnectionNotEstablished
Validierung vor Seiteneffekten
Validiere alle Eingaben, bevor du Seiteneffekte auslöst:
class TransferMoney
def call
validate! # Zuerst: Eingaben prüfen
debit_source! # Dann: Seiteneffekte
credit_target!
send_notification
ServiceResult.success
rescue ValidationError => e
ServiceResult.failure(error: e.message)
end
private
def validate!
raise ValidationError, "Betrag muss positiv sein" unless amount.positive?
raise ValidationError, "Sender hat kein Guthaben" unless source.balance >= amount
raise ValidationError, "Sender und Empfänger identisch" if source == target
end
end
Testen: Was wirklich getestet werden muss
Service Objects sind einfach testbar, weil sie isolierte Einheiten sind. Aber auch hier gibt es Prioritäten:
RSpec.describe ProcessPaymentRefund do
let(:order) { create(:order, :paid, created_at: 5.days.ago) }
describe ".call" do
context "bei erstattungsfähiger Bestellung" do
it "erstellt Stripe-Refund und aktualisiert den Status" do
stripe_refund = instance_double(Stripe::Refund, id: "re_123")
allow(Stripe::Refund).to receive(:create).and_return(stripe_refund)
result = described_class.call(order: order, reason: :customer_request)
expect(result).to be_success
expect(order.reload.status).to eq("refunded")
expect(Stripe::Refund).to have_received(:create).with(
payment_intent: order.stripe_payment_intent_id,
reason: :customer_request
)
end
end
context "bei bereits erstatteter Bestellung" do
let(:order) { create(:order, :refunded) }
it "gibt Fehler zurück ohne Stripe zu kontaktieren" do
result = described_class.call(order: order, reason: :customer_request)
expect(result).to be_failure
expect(result.error).to include("Already refunded")
expect(Stripe::Refund).not_to have_received(:create)
end
end
context "bei Stripe-Fehler" do
before do
allow(Stripe::Refund).to receive(:create)
.and_raise(Stripe::StripeError.new("Network error"))
end
it "gibt strukturierten Fehler zurück" do
result = described_class.call(order: order, reason: :customer_request)
expect(result).to be_failure
expect(result.error).to include("Payment provider error")
end
end
end
end
Was du testen solltest:
- Happy Path mit korrektem Ergebnis und Seiteneffekten
- Jeden Fehlerpfad einzeln
- Grenzfälle (leere Eingaben, Null-Werte, Extremwerte)
- Dass Seiteneffekte nicht ausgelöst werden, wenn Validierung fehlschlägt
Was du nicht testen musst:
- Private Methoden direkt (teste über die öffentliche Schnittstelle)
- Triviale Delegation an andere Objekte
Wann Service Objects Overkill sind
Nicht jede Logik braucht ein Service Object. Hier konkrete Schwellenwerte:
| Situation | Besser geeignet |
|---|---|
| Einfaches CRUD (create/update/destroy) | Controller-Aktion direkt |
| Logik betrifft nur ein Model | Model-Methode oder Concern |
| Nur Validierung über mehrere Models | Form Object |
| Nur Datenbankabfrage | Query Object / Scope |
| Unter 5 Zeilen Logik | Inline im Controller |
Ein Anti-Pattern aus der Praxis:
# Das ist kein Service Object, das ist eine verkleidete Model-Methode
class ActivateUser
def self.call(user)
user.update(active: true)
end
end
# Besser direkt am Model:
class User < ApplicationRecord
def activate!
update!(active: true)
end
end
Checkliste für gute Service Objects
- Name beginnt mit einem Verb und beschreibt die gesamte Operation
- Genau eine öffentliche Methode (
call) - Abhängigkeiten über Konstruktor injiziert
- Validierung vor Seiteneffekten
- Konsistentes Return-Pattern im Projekt
- Tests für Happy Path und alle Fehlerpfäde
- Weniger als 100 Zeilen (sonst aufteilen)