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) und dry-schema (>= 1.13) bilden das Fundament. Jeder API-Endpoint bekommt einen eigenen Contract. Keine Ausnahme.
  • dry-types (>= 1.7) für domänenspezifische Typen wie Email, UUID oder Money. Wiederverwendbar über das gesamte Projekt.
  • dry-monads (>= 1.6) für explizite Fehlerbehandlung mit Result-Typen. Besonders in Service-Objekten unverzichtbar.

Situativ eingesetzt:

  • dry-struct für Value Objects mit strikter Typisierung.
  • dry-transaction für mehrstufige Geschäftsprozesse (z.B. Bestellabwicklung).

Bewusst weggelassen:

  • dry-system und dry-auto_inject haben 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-configurable setzen 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-types entkoppeln Validierung von Persistenz
  • Contracts validieren pro Endpoint statt pro Modell
  • Eigene Typen (Email, Password, ResourceID) schaffen echte Domänenabstraktionen
  • dry-monads ergä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.