Complex forms that span multiple models or require intricate validation logic can quickly turn Rails controllers into unmaintainable messes. The Form Object pattern provides an elegant solution: encapsulate form logic in dedicated classes that handle validation, type coercion, and persistence—keeping controllers focused on HTTP concerns.
The Problem: Controller Bloat and Scattered Validation
Consider a user registration form that creates a User, their Company, and sends a welcome email. Without Form Objects, this logic often ends up scattered across controllers, models, and callbacks. The controller becomes a dumping ground for conditional logic, and testing becomes painful.
Form Objects solve this by creating a single class responsible for:
- Accepting and coercing form parameters
- Validating data across multiple models
- Coordinating persistence in a transaction
- Providing a clean interface for controllers and views
Building a Base Form Object Class
Rails 8 ships with Active Model, which provides everything needed to build form objects that integrate seamlessly with form helpers and validation. Start by creating a base class that all form objects can inherit from:
# app/forms/application_form.rb
class ApplicationForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations::Callbacks
def self.model_name
ActiveModel::Name.new(self, nil, self.name.chomp("Form"))
end
def save
return false unless valid?
ApplicationRecord.transaction do
persist!
end
true
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
private
def persist!
raise NotImplementedError, "Subclasses must implement #persist!"
end
endThe model_name override allows form_with to generate proper parameter keys. A form object named RegistrationForm will use registration as its param key, making it work naturally with Rails conventions.
Implementing a Complex Registration Form
Now build a registration form that creates both a user and their associated company. The form object handles validation for both models and ensures atomic creation:
# app/forms/registration_form.rb
class RegistrationForm < ApplicationForm
attribute :email, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :company_name, :string
attribute :company_size, :integer, default: 1
attribute :terms_accepted, :boolean, default: false
validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true,
length: { minimum: 12 },
confirmation: true
validates :company_name, presence: true,
length: { minimum: 2, maximum: 100 }
validates :company_size, numericality: { greater_than: 0, less_than: 10_000 }
validates :terms_accepted, acceptance: { accept: true }
validate :email_uniqueness
attr_reader :user, :company
private
def persist!
@company = Company.create!(
name: company_name,
size: company_size
)
@user = User.create!(
email: email,
password: password,
company: @company
)
RegistrationMailer.welcome(@user).deliver_later
end
def email_uniqueness
return if email.blank?
return unless User.exists?(email: email.downcase)
errors.add(:email, "has already been taken")
end
endThe attribute declarations provide automatic type coercion. When a form submits company_size as a string "50", it becomes the integer 50. Boolean fields like terms_accepted correctly handle checkbox values like "1" and "0".
Clean Controller Integration
With the form object handling all the complexity, the controller becomes refreshingly simple:
# 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_session_for(@registration.user)
redirect_to dashboard_path, notice: "Welcome to the platform!"
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:registration).permit(
:email, :password, :password_confirmation,
:company_name, :company_size, :terms_accepted
)
end
endThe controller knows nothing about Company creation, email sending, or cross-model validation. It simply asks the form object to save and responds accordingly.
View Integration with form_with
Form objects work seamlessly with Rails form helpers. The view remains conventional:
# app/views/registrations/new.html.erb
<%= form_with model: @registration, url: registrations_path do |f| %>
<% if @registration.errors.any? %>
<% end %>
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
<%= f.label :company_name %>
<%= f.text_field :company_name %>
<%= f.label :company_size %>
<%= f.number_field :company_size, min: 1, max: 9999 %>
<%= f.label :terms_accepted %>
<%= f.check_box :terms_accepted %>
<%= f.submit "Create Account" %>
<% end %>Field names, error messages, and form submission all work exactly as they would with an Active Record model.
Testing Form Objects in Isolation
One major benefit of form objects: they're simple Ruby classes that can be tested without touching controllers or views:
# spec/forms/registration_form_spec.rb
RSpec.describe RegistrationForm do
describe "validations" do
it "requires all fields" do
form = RegistrationForm.new
expect(form).not_to be_valid
expect(form.errors[:email]).to include("can't be blank")
expect(form.errors[:password]).to include("can't be blank")
expect(form.errors[:company_name]).to include("can't be blank")
end
it "validates password minimum length" do
form = RegistrationForm.new(password: "short")
expect(form).not_to be_valid
expect(form.errors[:password]).to include(/too short/)
end
it "validates email uniqueness" do
create(:user, email: "[email protected]")
form = RegistrationForm.new(email: "[email protected]")
form.valid?
expect(form.errors[:email]).to include("has already been taken")
end
end
describe "#save" do
let(:valid_params) do
{
email: "[email protected]",
password: "securepassword123",
password_confirmation: "securepassword123",
company_name: "Acme Corp",
company_size: 50,
terms_accepted: true
}
end
it "creates user and company in transaction" do
form = RegistrationForm.new(valid_params)
expect { form.save }.to change(User, :count).by(1)
.and change(Company, :count).by(1)
end
it "rolls back on failure" do
form = RegistrationForm.new(valid_params)
allow(User).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
expect { form.save }.not_to change(Company, :count)
end
it "enqueues welcome email" do
form = RegistrationForm.new(valid_params)
expect { form.save }
.to have_enqueued_mail(RegistrationMailer, :welcome)
end
end
endCommon Patterns and Edge Cases
Form objects can handle updates by accepting an existing record and modifying behavior accordingly. Add an optional record parameter to the initializer and adjust persist! to call update! instead of create! when appropriate.
For forms that don't persist anything—like search filters or contact forms—override the save method entirely to perform the appropriate action, whether that's sending an email or returning filtered results.
When validation requires database access (like uniqueness checks), perform these checks in custom validation methods rather than relying solely on database constraints. This provides better error messages and prevents constraint violations from bubbling up as exceptions.
Summary
Form Objects extract complex form handling into testable, reusable classes. They shine when forms span multiple models, require custom type coercion, or involve business logic beyond simple CRUD. The pattern keeps controllers thin, makes testing straightforward, and provides a clear home for form-specific logic that doesn't belong in Active Record models.
For simpler forms that map directly to a single model, Active Record remains the right choice. Reserve Form Objects for the complex cases where their additional structure pays dividends in maintainability and clarity.