Abstrakte Illustration: ein Formular mit mehreren Eingabefeldern wird von einer Zwischenschicht auf drei Datenbanktabellen verteilt

Form-Objekte in Rails: Formulare ohne fette Models

Eine Registrierung, die mehr kann als E-Mail und Passwort: Wer sich anmeldet, legt in einem Schritt ein Konto für seine Firma an, dazu den ersten Benutzer und optional zwei Einladungen an Kollegen. Drei Tabellen, ein Submit-Button. Genau für solche Fälle gibt es Form-Objekte in Rails. Das Formular bekommt eine eigene Klasse, die Eingaben annimmt, prüft und weiterreicht.

Ohne diese Schicht landet die Logik meist im User: ein accepts_nested_attributes_for hier, ein validates ..., if: :registration? dort, dazu ein Callback, der die Firma anlegt. Das läuft. Spätestens bei der zweiten Maske, die denselben User anders prüfen will, wird es unübersichtlich.

Ein Formular, drei Models: wo das Problem anfängt

ActiveRecord-Klassen beschreiben Daten und ihre dauerhaften Regeln. Eine Eingabemaske beschreibt einen Anwendungsfall. Die Zustimmung zu den Nutzungsbedingungen etwa gehört zur Registrierung, in keine Tabelle. Trotzdem steht sie in vielen Projekten als attr_accessor :terms samt Validierung im User.

Typische Anzeichen, dass deine Datenklasse zu viel über Screens weiß:

  • Validierungen mit if: oder on: :registration, die nur an einer Stelle gelten
  • virtuelle Attribute wie terms_accepted oder invite_emails, für die keine Spalte existiert
  • accepts_nested_attributes_for über zwei Ebenen, damit ein einziger Request speichern kann
  • Callbacks wie after_create :create_company, die beim Seeden oder im Test stören

Konkret heißt das: Jede Regel, die nur für einen Screen gilt, macht die Klasse für alle anderen Aufrufer schwerer zu verstehen. Bei 20 Validierungen mit fünf verschiedenen if:-Bedingungen weiß niemand mehr auf Anhieb, welche davon beim Import aus einer CSV-Datei greifen.

Was ein Form-Objekt in Rails ist

Ein Form-Objekt ist eine schlichte Ruby-Klasse, die sich gegenüber Controller und View wie ein ActiveRecord-Objekt verhält, ohne eine eigene Tabelle zu besitzen. Seit Rails 7.0 bündelt ActiveModel::API die nötigen Module: Attributzuweisung, Validierungen, Konvertierung, Naming und Übersetzungen. ActiveModel::Model bindet ActiveModel::API ein und ist der übliche Einstieg. Laut dem Rails Guide zu Active Model lässt sich jede Klasse mit ActiveModel::API direkt mit form_with und anderen Action-View-Helpern verwenden.

Dazu kommt ActiveModel::Attributes. Das Modul gibt dir typisierte Attribute mit Standardwert: Der Wert eines Häkchens kommt als String "1" an und wird als Boolean true gesetzt. Eine Zahl aus einem Textfeld wird zum Integer, ein leeres Zahlenfeld zu nil. Der folgende Code läuft mit Ruby 3.3 und Rails 7.2 oder 8, die Kurzschreibweise email:, password: im Hash gibt es seit Ruby 3.1.

# app/forms/registration_form.rb
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :company_name, :string
  attribute :email, :string
  attribute :password, :string
  attribute :terms_accepted, :boolean, default: false

  validates :company_name, :email, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, length: { minimum: 12 }
  validates :terms_accepted, acceptance: true

  attr_reader :user

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      account = Account.create!(name: company_name)
      @user = account.users.create!(email:, password:)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.record.errors.full_messages.to_sentence)
    false
  end
end

Die Methode save verhält sich wie ihr ActiveRecord-Vorbild: Sie liefert true oder false, und erst wenn alle Validierungen bestehen, wird ein Datensatz gespeichert. In unserem Beispiel läuft das in einer Transaktion, damit nie ein Konto ohne Benutzer in der Datenbank landet.

Der Ordner app/forms ist eine Konvention, keine Vorgabe. Zeitwerk lädt jedes Unterverzeichnis von app/ automatisch, ein Eintrag in der Konfiguration ist nicht nötig. Nur den Server musst du nach dem Anlegen des Ordners einmal neu starten.

Schematische Darstellung: Controller reicht Eingaben an ein Form-Objekt, das Validierung und Transaktion übernimmt und zwei Models speichert

Das Form-Objekt sitzt zwischen Controller und Models und kapselt Validierung und Speichern.

Schritt für Schritt: Controller und View anbinden

Im Controller ändert sich weniger, als du vielleicht erwartest. Statt User.new erzeugst du das Form-Objekt, der Rest sieht aus wie gewohnt.

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def new
    @registration = RegistrationForm.new
  end

  def create
    @registration = RegistrationForm.new(registration_params)

    if @registration.save
      start_new_session_for(@registration.user)
      redirect_to root_path, notice: "Konto angelegt."
    else
      render :new, status: 422
    end
  end

  private

  # Rails 8: params.expect; in Rails 7.2 params.require(...).permit(...)
  def registration_params
    params.expect(registration_form: [:company_name, :email, :password, :terms_accepted])
  end
end

start_new_session_for stammt aus dem Authentifizierungs-Generator von Rails 8. Nutzt du Devise, steht dort sign_in. Der Statuscode 422 ist wichtig, damit Turbo das Formular mit den Fehlermeldungen neu rendert.

Die ERB-View bekommt ein form_with mit explizitem url:. Ältere Projekte verwenden noch form_for, das klappt mit einem Form-Objekt genauso. Ohne url: würde Action View aus dem Klassennamen eine Route registration_forms_path ableiten, die es nicht gibt.

<%= form_with model: @registration, url: registrations_path do |f| %>
  <% @registration.errors.full_messages.each do |message| %>
    <p class="form-error"><%= message %></p>
  <% end %>

  <%= f.label :company_name, "Firma" %>
  <%= f.text_field :company_name %>

  <%= f.label :email, "E-Mail" %>
  <%= f.email_field :email %>

  <%= f.label :password, "Passwort" %>
  <%= f.password_field :password %>

  <%= f.check_box :terms_accepted %>
  <%= f.label :terms_accepted, "Ich akzeptiere die Nutzungsbedingungen" %>

  <%= f.submit "Konto anlegen" %>
<% end %>

Die Feldnamen lauten dann registration_form[email] und so weiter. Die Error Messages werden über errors.full_messages oberhalb der Felder ausgegeben. Fehlerhafte Felder umschließt Action View wie bei ActiveRecord mit <div class="field_with_errors">, dein CSS greift ohne Anpassung. Einen eigenen Text für eine Fehlermeldung legst du mit der Option message: fest, zum Beispiel: Bitte bestätige die Nutzungsbedingungen.

Zwei Details zur Checkbox: f.check_box erzeugt zusätzlich ein verstecktes Feld mit dem Value 0, damit auch ein nicht gesetztes Häkchen einen Wert übermittelt. Und der Acceptance-Validator überspringt standardmäßig nil, weil seine Option allow_nil auf true steht. Deshalb hat terms_accepted im Beispiel den Standardwert false.

Ein Detail, das oft übersehen wird: Die Übersetzungen der Feldnamen liegen bei ActiveModel-Klassen unter dem Schlüssel activemodel, nicht unter activerecord. In config/locales/de.yml steht also activemodel.attributes.registration_form.company_name: "Firma". Ohne diesen Eintrag heißt es in der Fehlermeldung „Company name muss ausgefüllt werden“.

Validierung außerhalb des Models: was wohin gehört

Der größte Gewinn liegt in der Aufteilung der Regeln. Im Form-Objekt stehen dir alle Validatoren aus ActiveModel zur Verfügung, auch die ältere Schreibweise validates_presence_of :email. Die folgende Faustregel hilft beim Sortieren:

Regel Ort Grund
E-Mail eindeutig Model plus Unique-Index gilt immer, auch für Importe und Konsole
Passwort mindestens 12 Zeichen Form-Objekt oder Model hängt davon ab, ob Admins kürzere setzen dürfen
Nutzungsbedingungen akzeptiert Form-Objekt existiert nur bei der Registrierung
Firmenname bei Registrierung Pflicht Form-Objekt später im Profil eventuell optional
Spalte NOT NULL Datenbank letzte Absicherung gegen jeden Schreibweg

Doppelte Prüfungen sind dabei kein Problem. Kontrolliert das Form-Objekt das Format der E-Mail und der User die Eindeutigkeit, bekommt der Nutzer schnelle, verständliche Hinweise, und die Datenbank bleibt konsistent. Im Beispiel oben fängt der rescue-Block den Fall ab, dass create! an einer ActiveRecord-Validierung scheitert, und überträgt die Meldung in errors.

Der Haken liegt woanders: Zwischen valid? und create! kann ein zweiter Request dieselbe Adresse registrieren. Diese Lücke schließt nur der Unique-Index, den du per Migration mit add_index :users, :email, unique: true anlegst. Greift er, folgt ActiveRecord::RecordNotUnique. Willst du dafür eine saubere Meldung, fängst du auch diese Exception im Form-Objekt ab.

Für Abhängigkeiten wie einen Mailer, der die Willkommensmail verschickt, lohnt sich ein Blick auf Dependency Injection in Ruby. Wird der Mailer dem Konstruktor übergeben, bleibt das Form-Objekt im Test ohne Stubs prüfbar.

Verschachtelte Formulare ohne accepts_nested_attributes_for

Jetzt die Einladungen. Die Maske soll bis zu drei E-Mail-Adressen samt Rolle annehmen. fields_for arbeitet auch mit Form-Objekten, sofern zwei Methoden existieren: ein Reader, der die Sammlung liefert, und ein Writer mit der Endung _attributes=. Findet der Form-Builder diesen Writer, benennt er die Felder im Nested-Attributes-Stil.

# app/forms/invitation_fields.rb
class InvitationFields
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :role, :string, default: "member"

  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :role, inclusion: { in: %w[member admin] }
end

# app/forms/team_invitation_form.rb
class TeamInvitationForm
  include ActiveModel::Model

  attr_accessor :team
  validate :invitations_valid

  def invitations
    @invitations ||= Array.new(3) { InvitationFields.new }
  end

  def invitations_attributes=(attributes)
    @invitations = attributes.values
      .reject { |attrs| attrs[:email].blank? }
      .map { |attrs| InvitationFields.new(attrs) }
  end

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      invitations.each { |inv| team.invitations.create!(email: inv.email, role: inv.role) }
    end
    true
  end

  private

  def invitations_valid
    invitations.each.with_index(1) do |invitation, number|
      next if invitation.valid?

      invitation.errors.each do |error|
        errors.add(:base, "Einladung #{number}: #{error.full_message}")
      end
    end
  end
end

Im ERB-Template reicht f.fields_for :invitations do |inv| ... end, darin je ein Feld für E-Mail und Rolle. Die Werte erlaubst du mit params.require(:team_invitation_form).permit(invitations_attributes: [:email, :role]), permit versteht die nummerierten Schlüssel "0", "1", "2", die der Builder erzeugt. Zeilen, in denen keine Adresse angegeben ist, fliegen beim Zuweisen raus. Das ersetzt reject_if: :all_blank, und leere Einträge tauchen nicht als Fehlermeldung auf.

accepts_nested_attributes_for oder Form-Objekt?

Das eingebaute Makro hat seine Berechtigung. Für ein simples Elternteil mit Kindern, etwa eine Bestellung mit Positionen, spart es Code und bringt mit allow_destroy, reject_if und limit drei nützliche Optionen mit. Die Unterschiede im Überblick:

Kriterium accepts_nested_attributes_for Form-Objekt
Code-Aufwand eine Zeile in der ActiveRecord-Klasse eigene Klasse, 30 bis 80 Zeilen
Ort der Regeln in der Datenklasse, gilt überall in der Maske, gilt nur dort
Mehrere Screens für dieselben Daten schwierig, if:-Bedingungen nötig je Anwendungsfall eine Klasse
Löschen per Checkbox eingebaut über _destroy und allow_destroy: true selbst bauen
Daten ohne Tabelle (AGB, Captcha) virtuelle Attribute eigene Felder der Klasse
Testbarkeit nur mit Datenbank Validierung ohne Datenbank testbar
Infografik: Vergleich von accepts_nested_attributes_for und Form-Objekt nach Aufwand, Ort der Regeln, Löschen und Testbarkeit

accepts_nested_attributes_for und Form-Objekt im direkten Vergleich.

Meine Empfehlung: Bearbeitet ein Screen genau einen Datensatz mit seinen direkten Kindern und bringt keine eigenen Regeln mit, bleib beim Makro. Braucht er Regeln, die nirgendwo sonst gelten, oder schreibt er in mehrere unabhängige Tabellen, ist das Form-Objekt die sauberere Wahl.

Reform und dry-validation: wann sich ein Gem lohnt

Für die meisten Projekte reicht ActiveModel. Zwei Gems tauchen in Diskussionen trotzdem regelmäßig auf.

Reform

Reform stammt aus dem Trailblazer-Umfeld. Eine Reform-Klasse deklariert property und collection, bekommt ein bestehendes ActiveRecord-Objekt übergeben und arbeitet in drei Schritten: validate(params), dann sync oder save. Die Rails-Anbindung liefert das separate Gem reform-rails. Laut RubyGems stammt die aktuelle Version 2.6.2 des Kern-Gems vom Mai 2022, reform-rails erhielt mit 0.3.1 im August 2025 zuletzt ein Update. Wer Reform neu einführt, sollte das bei der Planung berücksichtigen.

dry-validation

dry-validation ist kein Formular-Gem. Es validiert Eingaben über Contracts mit Schema und Regeln, die aktuelle Version 1.11.1 erschien im Januar 2025. Aufgerufen wird ein Contract über seine Methode call, der du den Hash mit den Eingaben übergibst. Zurück kommt ein Result mit success? und errors.to_h, aber kein Objekt, das form_with versteht. Die Fehler musst du selbst in ActiveModel::Errors übertragen. Stark ist dry-validation bei JSON-Payloads ohne HTML-Formular, etwa an API-Endpoints, wie sie im Artikel über Representer für REST-APIs beschrieben sind.

Meine Wahl für neue Projekte

Ich würde mit einer kleinen ApplicationForm-Basisklasse auf ActiveModel::Model starten. Sie hat keine zusätzliche Abhängigkeit, jeder Rails-Entwickler kennt die API, und ein Wechsel zu einem Gem bleibt jederzeit möglich.

Häufige Fragen zu Form-Objekten

Wo lege ich Form-Objekte ab?

In app/forms. Der Ordner wird automatisch geladen. Klassennamen enden üblicherweise auf Form.

Wie teste ich ein Form-Objekt?

Validierungen ohne Datenbank: RegistrationForm.new(email: "x").valid? reicht. Für save brauchst du einen Test mit Datenbank, der prüft, dass nach einem Fehler keine halbe Firma ohne Benutzer übrig bleibt.

Funktioniert das auch beim Bearbeiten?

Ja. Für ein Update übergibst du dem Formular den vorhandenen Datensatz, liest die Werte im Konstruktor aus und schreibst sie in save per update! zurück. Damit form_with dann PATCH statt POST verwendet, muss persisted? den Wert true liefern. Diese Methode überschreibst du im Form-Objekt.

Brauche ich für jede Seite ein Form-Objekt?

Nein. Ein Formular, das nur title und body eines Artikels ändert, bearbeitest du direkt mit der ActiveRecord-Klasse. Titel und Text liegen ohnehin genau so in der Tabelle. Form-Objekte lohnen sich dort, wo Eingabemaske und Datenbankschema auseinanderlaufen.

Und die Ausgabe?

Das Form-Objekt kümmert sich um Eingaben. Für die Darstellung von Daten gibt es eigene Muster, die im Beitrag zu Helpern und View-Models in Rails beschrieben sind. Weitere Artikel zur Rails-Architektur findest du in der Rubrik Technik.

Click to rate this post!
[Total: 0 Average: 0]

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert