| name | form-object-patterns |
| description | Creates form objects for complex form handling with TDD. Use when building multi-model forms, search forms, wizard forms, or when user mentions form objects, complex forms, virtual models, or non-persisted forms. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
Form Object Patterns for Rails 8
Overview
Form objects encapsulate complex form logic:
- Multi-model forms (user + profile + address)
- Search/filter forms (non-persisted)
- Wizard/multi-step forms
- Virtual attributes with validation
- Decoupled from ActiveRecord models
When to Use Form Objects
| Scenario | Use Form Object? |
|---|
| Single model CRUD | No (use model) |
| Multi-model creation | Yes |
| Complex validations across models | Yes |
| Search/filter forms | Yes |
| Wizard/multi-step forms | Yes |
| API params transformation | Yes |
| Contact forms (no persistence) | Yes |
TDD Workflow
Form Object Progress:
- [ ] Step 1: Define form requirements
- [ ] Step 2: Write form object test (RED)
- [ ] Step 3: Run test (fails)
- [ ] Step 4: Create form object
- [ ] Step 5: Run test (GREEN)
- [ ] Step 6: Wire up controller
- [ ] Step 7: Create view form
Base Form Class
class ApplicationForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
def self.model_name
ActiveModel::Name.new(self, nil, name.chomp("Form"))
end
def persisted?
false
end
def save
return false unless valid?
persist!
true
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
private
def persist!
raise NotImplementedError
end
end
Pattern 1: Multi-Model Registration Form
Test First (RED)
require "test_helper"
class RegistrationFormTest < ActiveSupport::TestCase
test "validates presence of email" do
form = RegistrationForm.new(email: "")
assert_not form.valid?
assert_includes form.errors[:email], "can't be blank"
end
test "validates presence of password" do
form = RegistrationForm.new(password: "")
assert_not form.valid?
assert_includes form.errors[:password], "can't be blank"
end
test "validates password minimum length" do
form = RegistrationForm.new(password: "short")
assert_not form.valid?
assert form.errors[:password].any? { |e| e.include?("too short") }
end
test "#save with valid params returns true" do
form = RegistrationForm.new(
email: "user@example.com",
password: "password123",
password_confirmation: "password123",
company_name: "Acme Inc"
)
assert form.save
end
test "#save creates a user" do
form = .new(
,
,
,
)
assert_difference(, ) { form.save }
test
form = .new(
,
,
,
)
assert_difference(, ) { form.save }
test
form = .new(
,
,
,
)
form.save
assert_equal form.user.account, form.account
test
form = .new( , )
assert_not form.save
test
form = .new( , )
assert_no_difference() { form.save }
test
existing = users()
form = .new(
existing.email_address,
,
,
)
assert_not form.save
assert_includes form.errors[],
Implementation (GREEN)
class RegistrationForm < ApplicationForm
attribute :email, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :company_name, :string
attribute :phone, :string
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 8 }
validates :password_confirmation, presence: true
validates :company_name, presence: true
validate :passwords_match
validate :email_unique
attr_reader :user, :account
private
def persist!
ActiveRecord::Base.transaction do
@account = Account.create!(name: company_name)
@user = .create!(
email,
password,
,
phone
)
password == password_confirmation
errors.add(, )
.exists?( email&.downcase)
errors.add(, )
Pattern 2: Search/Filter Form
Test First
require "test_helper"
class EventSearchFormTest < ActiveSupport::TestCase
setup do
@account = accounts(:one)
end
test "#results returns all account events without filters" do
form = EventSearchForm.new(account: @account, params: {})
results = form.results
results.each do |event|
assert_equal @account.id, event.account_id
end
end
test "#results excludes other account events" do
form = EventSearchForm.new(account: @account, params: {})
other_event = events(:other_account)
assert_not_includes form.results, other_event
end
test "#results filters by event_type" do
form = EventSearchForm.new(account: @account, params: { event_type: "wedding" })
form.results.each do |event|
assert_equal "wedding", event.event_type
end
end
test "#any_filters? returns true with filters" do
form = EventSearchForm.new(account: , { })
assert form.any_filters?
test
form = .new( , {})
assert_not form.any_filters?
Implementation
class EventSearchForm < ApplicationForm
attribute :query, :string
attribute :event_type, :string
attribute :status, :string
attribute :start_date, :date
attribute :end_date, :date
attr_reader :account
def initialize(account:, params: {})
@account = account
super(params)
end
def results
scope = account.events
scope = apply_search(scope)
scope = apply_type_filter(scope)
scope = apply_status_filter(scope)
scope = apply_date_filter(scope)
scope.order(event_date: :desc)
end
def any_filters?
[query, event_type, status, start_date, end_date].any?(&:present?)
end
private
def apply_search(scope)
return scope if query.blank?
scope.where("name LIKE :q OR description LIKE :q", q: "%#{sanitize_like(query)}%")
end
def apply_type_filter(scope)
scope event_type.blank?
scope.where( event_type)
()
scope status.blank?
scope.where( status)
()
scope = scope.where(, start_date) start_date.present?
scope = scope.where(, end_date) end_date.present?
scope
()
term.gsub() { || }
Pattern 3: Wizard/Multi-Step Form
module Wizard
class BaseForm < ApplicationForm
def self.steps
raise NotImplementedError
end
def current_step
raise NotImplementedError
end
def first_step?
current_step == self.class.steps.first
end
def last_step?
current_step == self.class.steps.last
end
def progress_percentage
steps = self.class.steps
((steps.index(current_step) + 1).to_f / steps.size * 100).round
end
end
end
Controller Integration
class RegistrationsController < ApplicationController
allow_unauthenticated_access
def new
@form = RegistrationForm.new
end
def create
@form = RegistrationForm.new(registration_params)
if @form.save
start_new_session_for(@form.user)
redirect_to dashboard_path, notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:registration).permit(
:email, :password, :password_confirmation,
:company_name, :phone
)
end
end
Checklist