Warum ActiveRecord-Validierungen in grossen Projekten scheitern
Ruby on Rails setzt seit 18 Jahren auf das MVC-Muster. Für kleine Apps und Prototypen funktioniert das hervorragend. Doch in Projekten mit 50+ Modellen, bedingten Validierungen und Microservice-Architekturen zeigen sich die Grenzen schnell.
Das Problem ist strukturell: ActiveRecord-Modelle übernehmen zu viele Aufgaben gleichzeitig. Validierungen, Callbacks, Scopes, Beziehungen und Geschäftslogik landen alle in derselben Klasse. Wer schon einmal ein User-Modell mit 400 Zeilen debuggen musste, weiss genau, wovon wir sprechen.
Ein typisches Beispiel:
class User < ApplicationRecord
include ModuleWithAdditionalValidations
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true, uniqueness: true, format: { with: Const::EMAIL_RE }
validates :terms_of_service, acceptance: true
end
Auf den ersten Blick sieht das sauber aus. Doch in der Praxis kommen bedingte Validierungen hinzu, Callbacks feuern bei jedem save, und plötzlich validiert das Modell Daten für Kontexte, die es gar nicht kennen sollte.
Was dry-rb anders macht
dry-rb ist kein Framework, sondern ein Baukasten aus einzeln einsetzbaren Gems. Jede Bibliothek löst ein spezifisches Problem, ohne dass man das gesamte Paket installieren muss.
Der Kerngedanke: Validierung wird von der Persistenz entkoppelt. Daten werden vor dem Speichern geprüft, nicht während dem Speichern. Das klingt trivial, verändert aber das gesamte Anwendungsdesign.
USEO’s Take: Welche Gems wir tatsächlich nutzen
Nach über drei Jahren Einsatz in Kundenprojekten hat sich bei uns ein klares Bild ergeben:
Täglich im Einsatz:
dry-validation(>= 1.10) unddry-schema(>= 1.13) bilden das Fundament. Jeder API-Endpoint bekommt einen eigenen Contract. Keine Ausnahme.dry-types(>= 1.7) für domänenspezifische Typen wieEmail,UUIDoderMoney. Wiederverwendbar über das gesamte Projekt.dry-monads(>= 1.6) für explizite Fehlerbehandlung mitResult-Typen. Besonders in Service-Objekten unverzichtbar.
Situativ eingesetzt:
dry-structfür Value Objects mit strikter Typisierung.dry-transactionfür mehrstufige Geschäftsprozesse (z.B. Bestellabwicklung).
Bewusst weggelassen:
dry-systemunddry-auto_injecthaben wir in zwei Projekten ausprobiert und wieder entfernt. Der Dependency-Injection-Container bringt in Rails-Projekten mehr Komplexität als Nutzen, weil Rails bereits einen eigenen Autoloading-Mechanismus mitbringt.dry-configurablesetzen wir nicht ein. Rails’ eigene Konfigurationsmechanismen reichen für unsere Zwecke aus.
Schritt für Schritt: Validierung mit dry-validation umbauen
Vorher: Alles im Modell
# app/models/user.rb
class User < ApplicationRecord
include ModuleWithAdditionalValidations
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true, uniqueness: true,
format: { with: Const::EMAIL_RE }
validates :terms_of_service, acceptance: true
# Plus: Callbacks, Scopes, Beziehungen, Methoden...
end
Probleme: Validierungen feuern bei jedem save-Aufruf, auch bei internen Updates. Bedingte Validierungen (if:, unless:) machen den Code schnell unlesbar. Tests müssen immer das gesamte Modell aufsetzen.
Nachher: Validierung pro Anfrage
Zunächst definieren wir wiederverwendbare Typen mit dry-types:
# app/types.rb
module Types
include Dry::Types()
DowncasedStrippedString = String.constructor(->(s) { s.strip.downcase })
ResourceID = String.constrained(format: Const::UUID_RE)
Email = DowncasedStrippedString.constrained(format: Const::EMAIL_RE)
Password = String.constrained(format: Const::PASSWORD_RE)
end
Das Types-Modul ist mehr als eine Sammlung von Regex-Prüfungen. Es definiert echte Datentypen mit eingebauter Transformation. Email etwa normalisiert den Wert automatisch (Whitespace entfernen, Kleinbuchstaben), bevor die Formatprüfung greift. Diese Typen sind über alle Contracts hinweg wiederverwendbar.
Dann erstellen wir einen Contract für den Registrierungs-Endpoint:
# app/contracts/sign_up_contract.rb
class SignUpContract < Dry::Validation::Contract
params do
optional(:id).filled(Types::ResourceID)
optional(:first_name).filled(Types::Coercible::String)
optional(:last_name).filled(Types::Coercible::String)
required(:email).filled(Types::Email)
required(:password).filled(Types::Password)
required(:terms_of_service).filled(Types::Bool, eql?: true)
end
# Zusätzliche Regeln, die über Schema-Prüfungen hinausgehen
rule(:email) do
if values[:email] && User.exists?(email: values[:email])
key.failure("ist bereits vergeben")
end
end
end
Der Unterschied zur ActiveRecord-Variante: Die uniqueness-Prüfung ist jetzt eine explizite Regel im Contract statt einer impliziten Modellvalidierung. Das macht klar, wann diese Prüfung stattfindet.
Das Modell wird radikal schlanker:
# app/models/user.rb
class User < ApplicationRecord
end
Integration im Controller
class AuthenticationController < ApplicationController
def sign_up(contract: SignUpContract.new)
result = contract.call(params.to_unsafe_h)
if result.failure?
render json: { errors: result.errors.to_h }, status: :unprocessable_entity
return
end
user = User.create!(result.to_h)
render json: UserSerializer.new(user), status: :created
end
end
In unseren Projekten nutzen wir statt der direkten Controller-Integration meist einen Service-Objekt mit dry-monads:
class SignUp
include Dry::Monads[:result, :do]
def call(params)
validated = yield validate(params)
user = yield persist(validated)
Success(user)
end
private
def validate(params)
result = SignUpContract.new.call(params)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
def persist(data)
Success(User.create!(data))
rescue ActiveRecord::RecordInvalid => e
Failure(e.message)
end
end
Dieses Muster trennt Validierung, Persistenz und Fehlerbehandlung sauber voneinander. Der Controller wird zum reinen Dispatcher.
Worauf man achten muss
Der Umstieg auf anfragespezifische Validierung hat Konsequenzen für die Datenintegrität. Ohne Modellvalidierungen gibt es kein Sicherheitsnetz bei direkten create- oder update-Aufrufen. Das heisst:
- Datenbank-Constraints sind Pflicht. NOT-NULL-Constraints, Unique-Indizes und Check-Constraints gehören in die Migration. Die Datenbank ist die letzte Verteidigungslinie.
- Jeder Endpoint braucht einen Contract. Vergessene Validierungen fallen erst in Produktion auf. Wir setzen bei USEO einen RSpec-Matcher ein, der prüft, ob jeder schreibende Endpoint einen zugewiesenen Contract hat.
- Mehr Contracts bedeuten mehr Code. Für ein Modell mit fünf Endpoints entstehen fünf Contracts. Das ist gewollt, da jeder Endpoint unterschiedliche Daten akzeptiert, und expliziter als eine Modellvalidierung, die alles auf einmal abdecken soll.
Zusammenfassung
Anfragespezifische Validierung mit dry-rb ersetzt den monolithischen Validierungsansatz von Rails durch ein präziseres, testbareres System. Die wichtigsten Punkte:
dry-validation+dry-typesentkoppeln Validierung von Persistenz- Contracts validieren pro Endpoint statt pro Modell
- Eigene Typen (
Email,Password,ResourceID) schaffen echte Domänenabstraktionen dry-monadsergänzt das Muster mit expliziter Fehlerbehandlung- Datenbank-Constraints bleiben als Sicherheitsnetz unverzichtbar
Der Einstieg lohnt sich besonders bei wachsenden Projekten, in denen bedingte Modellvalidierungen zum Wartungsalptraum werden. Mehr Details zu den einzelnen Gems gibt es auf der dry-rb Hauptseite.