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:

PatternWann sinnvollBeispiel
Model ConcernLogik gehört zum Model, aber wird zu grossBillable, Searchable
Form ObjectValidierung über mehrere Models hinwegRegistrationForm mit User + Company
Query ObjectKomplexe Datenbankabfragen kapselnOverdueInvoicesQuery
Policy ObjectAutorisierungslogikProjectAccessPolicy
Value ObjectImmutable DatenstrukturenMoney, DateRange
Service ObjectOrchestrierung mehrerer Schritte mit SeiteneffektenProcessSubscriptionRenewal

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:

  1. Immer ein Verb am Anfang. Create, Process, Send, Calculate, Sync, Import. Kein Manager, Handler, Helper.
  2. Ein Name pro Aktion. Wenn du Create für “anlegen” nutzt, verwende nicht auch Build oder Generate dafür.
  3. Der Name verrät den Scope. RegisterUser statt CreateUser, 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:

  1. Happy Path mit korrektem Ergebnis und Seiteneffekten
  2. Jeden Fehlerpfad einzeln
  3. Grenzfälle (leere Eingaben, Null-Werte, Extremwerte)
  4. 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:

SituationBesser geeignet
Einfaches CRUD (create/update/destroy)Controller-Aktion direkt
Logik betrifft nur ein ModelModel-Methode oder Concern
Nur Validierung über mehrere ModelsForm Object
Nur DatenbankabfrageQuery Object / Scope
Unter 5 Zeilen LogikInline 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)

Verwandte Artikel