Was passiert, wenn after_create zur Zeitbombe wird?
Jedes Rails-Projekt beginnt harmlos. Ein after_create hier, ein before_save dort. In einer App mit 10 Models ist das kein Problem. Aber bei 50+ Models mit verschachtelten Assoziationen entsteht ein unsichtbares Netz von Seiteneffekten, das niemand mehr vollstaendig ueberblickt.
Ein konkretes Szenario aus einem Legacy-Projekt (Rails 6.1, 87 Models): Ein Entwickler aendert die Validierung im Invoice-Model. Die Tests sind gruen. Im Staging bricht ploetzlich der PDF-Export ab. Warum? Ein after_update im Invoice-Model triggert touch auf Order, das wiederum einen after_touch Callback hat, der den Cache invalidiert, von dem der PDF-Service abhaengt. Drei Ebenen tief, null Sichtbarkeit.
Das ist kein theoretisches Problem. Das ist der Alltag in gewachsenen Rails-Codebases.
Wann Callbacks trotzdem Sinn ergeben
Bevor wir Callbacks komplett verteufeln: Es gibt genau zwei Faelle, in denen sie berechtigt sind.
1. Daten-Normalisierung vor dem Speichern
class User < ApplicationRecord
before_save :normalize_email
private
def normalize_email
self.email = email.strip.downcase
end
end
Das ist eine reine Datentransformation ohne Seiteneffekte. Kein externer Service, keine andere Tabelle betroffen. Hier sind Callbacks die sauberste Loesung.
2. Datenbanknahe Defaults
class ApiToken < ApplicationRecord
before_create :generate_token
private
def generate_token
self.token = SecureRandom.hex(32)
end
end
Auch hier: keine Abhaengigkeiten, keine Kaskade. Der Callback operiert ausschliesslich auf dem eigenen Record.
Alles darueber hinaus, also after_create, after_commit, after_touch mit externen Aufrufen, Assoziationen oder Geschaeftslogik, gehoert nicht in Callbacks.
Das eigentliche Problem: Kontrollfluss durch Seiteneffekte
Warum sind Callbacks jenseits simpler Defaults so problematisch? Drei konkrete Gruende:
Fragmentierter Kontrollfluss. Die Geschaeftslogik eines Prozesses wie “User registrieren” verteilt sich auf User-Model, Profile-Model, Mailer und eventuell einen externen Service. Um den vollstaendigen Ablauf zu verstehen, muss man alle Models und deren Callbacks durchsuchen.
Reihenfolge ist implizit. Kennen Sie aus dem Kopf die Ausfuehrungsreihenfolge von after_validation, after_create, after_save, after_commit? In welcher Reihenfolge feuern mehrere after_create Callbacks im selben Model? (Antwort: in der Reihenfolge der Definition, aber das ist ein Implementierungsdetail, keine Garantie.)
Universelle Ausfuehrung. Ein after_create feuert bei jedem Create, egal ob Registration, Admin-Import, Seed-Skript oder Test-Factory. Oft fuehrt das zu if-Ketten in Callbacks oder zu dem beruechtigten skip_callbacks-Pattern, das die Probleme nur verschiebt.
Vorher: Callback-Netz
class User < ApplicationRecord
before_create :generate_confirmation_token, if: :signed_up_with_email?
after_create :send_confirmation_email, if: :signed_up_with_email?
after_create :register_on_3rd_party_service
after_destroy :remove_from_3rd_party_service
after_create :create_default_profile, if: -> { profile.nil? }
end
class Profile < ApplicationRecord
before_create :generate_slug
after_create :create_empty_portfolio
after_touch -> { user.touch }
end
Fuenf Callbacks in User, drei in Profile. Das ergibt 8 implizite Aktionen, die bei jedem User.create! in einer bestimmten (aber nicht offensichtlichen) Reihenfolge ablaufen. Der Controller sieht dabei harmlos aus:
class AuthenticationController < ApplicationController
def sign_up
@user = User.create!(user_params)
render @user
end
end
Eine Zeile im Controller, 8 versteckte Operationen. Das ist das Problem.
Die Loesung: Explizite Prozesse mit dry-monads 2.0
dry-monads (aktuell Version 1.6.0, Gem dry-monads) bietet mit der Do-Notation und Result-Monaden eine Moeglichkeit, Prozesse als explizite Schrittfolgen zu modellieren.
Die Kernidee: Jede Operation liefert entweder Success(wert) oder Failure(fehler) zurueck. Die Do-Notation (yield) stoppt die Ausfuehrung automatisch beim ersten Failure, ohne dass Exceptions geworfen werden muessen.
Nachher: Expliziter Prozess
# Gemfile
# gem 'dry-monads', '~> 1.6'
require 'dry/monads'
require 'dry/monads/do'
module Authentication
class SignUp
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(params)
data = yield validate(params)
user = yield create_user(data)
profile = yield create_profile(user)
_portfolio = yield create_portfolio(profile)
yield register_on_3rd_party_service(user)
yield send_welcome_email(user)
Success(user)
end
private
def validate(params, contract: SignUpContract.new)
result = contract.call(params)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
def create_user(data)
user = User.create(
**data,
confirmation_token: SecureRandom.urlsafe_base64(32)
)
user.persisted? ? Success(user) : Failure(user.errors)
end
def create_profile(user)
profile = Profile.create(
user: user,
slug: "#{user.first_name}-#{user.last_name}".parameterize
)
profile.persisted? ? Success(profile) : Failure(profile.errors)
end
def create_portfolio(profile)
portfolio = Portfolio.create!(profile: profile)
Success(portfolio)
rescue ActiveRecord::RecordInvalid => e
Failure(e.record.errors)
end
def register_on_3rd_party_service(user, service: Some3rdPartyService.new)
service.register(user)
Success()
rescue StandardError => e
Failure("3rd party registration failed: #{e.message}")
end
def send_welcome_email(user)
UserMailer.welcome(user).deliver_later
Success()
end
end
end
class AuthenticationController < ApplicationController
def sign_up
result = Authentication::SignUp.new.call(user_params.to_h)
case result
in Success(user)
render json: UserSerializer.new(user), status: :created
in Failure(errors)
render json: { errors: errors }, status: :unprocessable_entity
end
end
end
Was hat sich konkret verbessert?
- Ein Blick auf
#callzeigt den gesamten Prozess. Keine versteckten Schritte, keine impliziten Abhaengigkeiten. - Fehlerbehandlung ist eingebaut. Schlaegt
create_profilefehl, wirdregister_on_3rd_party_servicenie aufgerufen. Kein inkonsistenter Zustand. - Der Prozess ist kontextspezifisch. Diese Sign-Up-Logik feuert nur bei der Registration, nicht beim Admin-Import oder in Seeds.
- Testbarkeit. Jede private Methode laesst sich isoliert testen. Der Gesamtprozess laesst sich mit Dependency Injection (siehe Default-Parameter) einfach mocken.
USEOs Einschaetzung: Welche dry-rb Gems lohnen sich wirklich?
Wir setzen bei USEO seit 2019 dry-rb in Kundenprojekten ein. Hier unsere ehrliche Bewertung nach dutzenden Projekten:
Klare Empfehlung:
- dry-monads (1.6.0): Unverzichtbar. Die
Do-Notation allein rechtfertigt die Abhaengigkeit. Wir nutzen es in jedem Rails-Projekt ab mittlerer Komplexitaet. Der Lernaufwand ist ueberschaubar:Success,Failure,yield- mehr braucht man fuer den Anfang nicht. - dry-validation (1.10.0): Deutlich besser als ActiveModel::Validations fuer komplexe Eingaben. Besonders stark bei verschachtelten Params und kontextabhaengigen Regeln.
- dry-struct (1.6.0): Nuetzlich fuer Value Objects und DTOs. Nicht zwingend noetig, aber angenehm.
Situationsabhaengig:
- dry-container + dry-auto_inject: Maechtig, aber erhoeht die Abstraktion erheblich. Lohnt sich erst ab 100+ Service-Klassen. In kleineren Projekten reicht manuelle Dependency Injection ueber Default-Parameter voellig aus.
- dry-types: Gut fuer strenge Typisierung, aber in den meisten Rails-Apps reicht die Kombination aus Validierung und Datenbank-Constraints.
Eher nicht:
- dry-transaction: Offiziell deprecated zugunsten von dry-monads. Nicht mehr verwenden.
- dry-system: Bringt ein komplett eigenes Bootstrapping mit, das mit Rails kollidiert. Der Integrationsaufwand ueberwiegt den Nutzen in 90% der Faelle.
Die wichtigste Erkenntnis: Man muss nicht das gesamte dry-rb Oekosystem adoptieren. dry-monads allein, vielleicht ergaenzt um dry-validation, loest bereits 80% der Callback-Probleme.
Haeufige Einwaende und Antworten
“Das ist mehr Code als vorher.” Ja, es sind mehr Zeilen. Aber die Zeilen sind lesbar, testbar und debugbar. Der Callback-Code war kuerzer, aber die Zeit fuer Debugging und Wartung war um ein Vielfaches hoeher.
“Mein Team kennt dry-rb nicht.” Die Lernkurve fuer dry-monads ist ueberraschend flach. Success und Failure als Return Values, yield zum Verketten. Das versteht jeder Ruby-Entwickler in einer Stunde. Die echte Huerde ist das Umdenken weg von Callbacks, nicht die Bibliothek selbst.
“Wir nutzen einfach Service Objects ohne dry-rb.” Das funktioniert auch. Der Kern der Verbesserung liegt im expliziten Prozessdesign, nicht in der Bibliothek. Aber dry-monads liefert zwei Dinge, die einfache Service Objects nicht bieten: standardisierte Fehlerbehandlung und automatisches Short-Circuiting durch die Do-Notation.
Zusammenfassung
- Callbacks sind fuer reine Datentransformationen akzeptabel (
before_savezur Normalisierung,before_createfuer Defaults). - Callbacks fuer Geschaeftslogik, externe Services oder Assoziationen fuehren zu fragmentierten, schwer wartbaren Prozessen.
dry-monadsermoeglicht explizite Prozessketten mit eingebauter Fehlerbehandlung. DieDo-Notation stoppt beim ersten Fehler automatisch.- Man muss weder Monaden mathematisch verstehen noch das gesamte dry-rb Oekosystem verwenden.
dry-monadsallein reicht als Einstieg. - Die eigentliche Verbesserung ist der Wechsel von impliziten Seiteneffekten zu expliziten, kontextspezifischen Prozessen. Ob mit dry-rb, einfachen Ruby-Objekten oder einer anderen Bibliothek: Expliziter Kontrollfluss skaliert, Callbacks nicht.