home / skills / thibautbaissac / rails_ai_agents / form-object-patterns
/skills/form-object-patterns
This skill helps you implement robust Rails form objects to manage multi-model, wizard, and non-persisted forms with tests.
npx playbooks add skill thibautbaissac/rails_ai_agents --skill form-object-patternsReview the files below or copy the command above to add this skill to your agents.
---
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 spec (RED)
- [ ] Step 3: Run spec (fails)
- [ ] Step 4: Create form object
- [ ] Step 5: Run spec (GREEN)
- [ ] Step 6: Wire up controller
- [ ] Step 7: Create view form
```
## Project Structure
```
app/
├── forms/
│ ├── application_form.rb # Base class
│ ├── registration_form.rb # Multi-model
│ ├── search_form.rb # Non-persisted
│ └── wizard/
│ ├── base_form.rb
│ ├── step_one_form.rb
│ └── step_two_form.rb
spec/forms/
├── registration_form_spec.rb
└── search_form_spec.rb
```
## Base Form Class
```ruby
# app/forms/application_form.rb
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
# Override in subclasses
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
### Spec First (RED)
```ruby
# spec/forms/registration_form_spec.rb
require 'rails_helper'
RSpec.describe RegistrationForm do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_presence_of(:password) }
it { is_expected.to validate_presence_of(:company_name) }
it { is_expected.to validate_length_of(:password).is_at_least(8) }
end
describe "#save" do
subject(:form) { described_class.new(params) }
context "with valid params" do
let(:params) do
{
email: "[email protected]",
password: "password123",
password_confirmation: "password123",
company_name: "Acme Inc",
phone: "0123456789"
}
end
it "returns true" do
expect(form.save).to be true
end
it "creates a user" do
expect { form.save }.to change(User, :count).by(1)
end
it "creates an account" do
expect { form.save }.to change(Account, :count).by(1)
end
it "associates user with account" do
form.save
expect(form.user.account).to eq(form.account)
end
it "exposes created records" do
form.save
expect(form.user).to be_persisted
expect(form.account).to be_persisted
end
end
context "with invalid params" do
let(:params) { { email: "", password: "short" } }
it "returns false" do
expect(form.save).to be false
end
it "does not create records" do
expect { form.save }.not_to change(User, :count)
end
it "has errors" do
form.save
expect(form.errors).not_to be_empty
end
end
context "with duplicate email" do
let!(:existing_user) { create(:user, email_address: "[email protected]") }
let(:params) do
{
email: "[email protected]",
password: "password123",
password_confirmation: "password123",
company_name: "Acme Inc"
}
end
it "returns false" do
expect(form.save).to be false
end
it "adds error to email" do
form.save
expect(form.errors[:email]).to include("has already been taken")
end
end
end
end
```
### Implementation (GREEN)
```ruby
# app/forms/registration_form.rb
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 = User.create!(
email_address: email,
password: password,
account: @account,
phone: phone
)
end
end
def passwords_match
return if password == password_confirmation
errors.add(:password_confirmation, "doesn't match password")
end
def email_unique
return unless User.exists?(email_address: email&.downcase)
errors.add(:email, "has already been taken")
end
end
```
## Pattern 2: Search/Filter Form
### Spec First
```ruby
# spec/forms/event_search_form_spec.rb
require 'rails_helper'
RSpec.describe EventSearchForm do
let(:account) { create(:account) }
let(:form) { described_class.new(account: account, params: params) }
describe "#results" do
let!(:wedding) { create(:event, account: account, event_type: :wedding, name: "Smith Wedding") }
let!(:corporate) { create(:event, account: account, event_type: :corporate, name: "Tech Conference") }
let!(:other_event) { create(:event, name: "Other") } # Different account
context "without filters" do
let(:params) { {} }
it "returns all account events" do
expect(form.results).to contain_exactly(wedding, corporate)
end
it "excludes other account events" do
expect(form.results).not_to include(other_event)
end
end
context "with type filter" do
let(:params) { { event_type: "wedding" } }
it "filters by type" do
expect(form.results).to contain_exactly(wedding)
end
end
context "with search query" do
let(:params) { { query: "smith" } }
it "searches by name" do
expect(form.results).to contain_exactly(wedding)
end
end
context "with date range" do
let(:params) { { start_date: Date.today, end_date: 1.month.from_now } }
let!(:upcoming) { create(:event, account: account, event_date: 2.weeks.from_now) }
let!(:past) { create(:event, account: account, event_date: 1.week.ago) }
it "filters by date range" do
expect(form.results).to include(upcoming)
expect(form.results).not_to include(past)
end
end
end
describe "#any_filters?" do
context "with filters" do
let(:params) { { query: "test" } }
it "returns true" do
expect(form.any_filters?).to be true
end
end
context "without filters" do
let(:params) { {} }
it "returns false" do
expect(form.any_filters?).to be false
end
end
end
end
```
### Implementation
```ruby
# app/forms/event_search_form.rb
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
# For form select options
def event_type_options
Event.event_types.keys.map { |t| [t.humanize, t] }
end
def status_options
Event.statuses.keys.map { |s| [s.humanize, s] }
end
private
def apply_search(scope)
return scope if query.blank?
scope.where("name ILIKE :q OR description ILIKE :q", q: "%#{sanitize_like(query)}%")
end
def apply_type_filter(scope)
return scope if event_type.blank?
scope.where(event_type: event_type)
end
def apply_status_filter(scope)
return scope if status.blank?
scope.where(status: status)
end
def apply_date_filter(scope)
scope = scope.where("event_date >= ?", start_date) if start_date.present?
scope = scope.where("event_date <= ?", end_date) if end_date.present?
scope
end
def sanitize_like(term)
term.gsub(/[%_]/) { |x| "\\#{x}" }
end
end
```
## Pattern 3: Wizard/Multi-Step Form
### Base Wizard Form
```ruby
# app/forms/wizard/base_form.rb
module Wizard
class BaseForm < ApplicationForm
attribute :wizard_data, :string # JSON storage
def self.steps
raise NotImplementedError
end
def current_step
raise NotImplementedError
end
def next_step
steps = self.class.steps
current_index = steps.index(current_step)
steps[current_index + 1]
end
def previous_step
steps = self.class.steps
current_index = steps.index(current_step)
return nil if current_index.zero?
steps[current_index - 1]
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
```
### Step Forms
```ruby
# app/forms/wizard/event_step_one_form.rb
module Wizard
class EventStepOneForm < BaseForm
attribute :name, :string
attribute :event_type, :string
attribute :event_date, :date
validates :name, presence: true
validates :event_type, presence: true
validates :event_date, presence: true
def self.steps
[:basics, :details, :vendors, :confirmation]
end
def current_step
:basics
end
end
end
# app/forms/wizard/event_step_two_form.rb
module Wizard
class EventStepTwoForm < BaseForm
attribute :description, :string
attribute :guest_count, :integer
attribute :budget_cents, :integer
validates :guest_count, numericality: { greater_than: 0 }, allow_nil: true
def self.steps
[:basics, :details, :vendors, :confirmation]
end
def current_step
:details
end
end
end
```
## Pattern 4: Contact Form (No Persistence)
```ruby
# app/forms/contact_form.rb
class ContactForm < ApplicationForm
attribute :name, :string
attribute :email, :string
attribute :subject, :string
attribute :message, :string
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :subject, presence: true
validates :message, presence: true, length: { minimum: 10 }
def save
return false unless valid?
deliver_email
true
end
private
def deliver_email
ContactMailer.inquiry(
name: name,
email: email,
subject: subject,
message: message
).deliver_later
end
end
```
## Controller Integration
```ruby
# app/controllers/registrations_controller.rb
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
```
## View Integration
```erb
<%# app/views/registrations/new.html.erb %>
<%= form_with model: @form, url: registrations_path do |f| %>
<% if @form.errors.any? %>
<div class="alert alert-error">
<ul>
<% @form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password %>
</div>
<div class="field">
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
</div>
<div class="field">
<%= f.label :company_name %>
<%= f.text_field :company_name %>
</div>
<%= f.submit "Register" %>
<% end %>
```
### Search Form View
```erb
<%# app/views/events/_search_form.html.erb %>
<%= form_with model: @search_form, url: events_path, method: :get, local: true do |f| %>
<div class="flex gap-4">
<%= f.search_field :query, placeholder: "Search events..." %>
<%= f.select :event_type, @search_form.event_type_options, include_blank: "All types" %>
<%= f.select :status, @search_form.status_options, include_blank: "All statuses" %>
<%= f.date_field :start_date %>
<%= f.date_field :end_date %>
<%= f.submit "Search" %>
<% if @search_form.any_filters? %>
<%= link_to "Clear", events_path, class: "btn-secondary" %>
<% end %>
</div>
<% end %>
```
## Checklist
- [ ] Spec written first (RED)
- [ ] Extends `ApplicationForm` or includes `ActiveModel::Model`
- [ ] Attributes declared with types
- [ ] Validations defined
- [ ] `#save` method with transaction (if multi-model)
- [ ] Controller uses form object
- [ ] View uses `form_with model: @form`
- [ ] Error handling in place
- [ ] All specs GREEN
This skill creates form objects for Rails 8.1 projects to handle complex form interactions with a TDD-first workflow. It provides patterns for multi-model registration forms, non-persisted search filters, wizard/multi-step flows, and contact forms that deliver email instead of persisting records. The goal is to keep controllers and models thin while centralizing validation, persistence, and presentation concerns in plain Ruby form objects.
You write specs first (RED), implement a form class that includes ActiveModel behavior or extends a provided ApplicationForm, and iterate until tests pass (GREEN). Form objects declare typed attributes, validations, and encapsulate persistence with a save/persist! contract or return query results for search forms. Controller and view integration use form_with model: @form and expose errors and virtual attributes just like ActiveRecord objects.
Should I always use a form object instead of model validations?
No. Use form objects for cross-model coordination, non-persisted forms, or multi-step flows. For simple single-model CRUD, keep validations on the model.
How do I test failures like duplicate records or transaction rollbacks?
Write specs that assert save returns false, check that counts did not change, and assert errors are present. Simulate existing records with factories to trigger uniqueness or validation errors.