home / skills / thibautbaissac / rails_ai_agents / form-object-patterns

form-object-patterns skill

/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-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
14.0 KB
---
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

Overview

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.

How this skill works

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.

When to use it

  • Building a single user flow that touches multiple models (create user + account + profile)
  • Implementing search or filter UIs where results are non-persisted queries
  • Designing wizard or multi-step forms that persist progress or validate per-step
  • Creating contact or API-facing forms with virtual attributes and email delivery
  • When you want to follow TDD: write specs for form behavior before implementation

Best practices

  • Write form specs first: validate attributes, save behavior, and edge cases before coding
  • Extend a shared ApplicationForm to centralize persisted? behavior, save pattern, and error handling
  • Keep persistence in a private persist! method and wrap multi-model writes in a transaction
  • Expose created or found records (e.g., form.user, form.account, form.results) for controller use
  • Provide helper methods for view presentation (options arrays, any_filters?) and sanitize inputs for queries

Example use cases

  • RegistrationForm that creates an Account and User in one transaction and exposes created records
  • EventSearchForm that accepts account and params, returns scoped results and supports date/type filters
  • Wizard::BaseForm with step navigation, progress percentage, and per-step validation classes
  • ContactForm that validates input and delivers an email asynchronously without persisting a record

FAQ

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.