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:oderon: :registration, die nur an einer Stelle gelten - virtuelle Attribute wie
terms_acceptedoderinvite_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.

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 |

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.




