or run

tessl search
Log in

form-object-patterns

tessl install github:ThibautBaissac/rails_ai_agents --skill form-object-patterns

github.com/ThibautBaissac/rails_ai_agents

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.

Review Score

93%

Validation Score

11/16

Implementation Score

92%

Activation Score

100%

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

ScenarioUse Form Object?
Single model CRUDNo (use model)
Multi-model creationYes
Complex validations across modelsYes
Search/filter formsYes
Wizard/multi-step formsYes
API params transformationYes
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

# 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)

# 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: "user@example.com",
          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: "taken@example.com") }
      let(:params) do
        {
          email: "taken@example.com",
          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)

# 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

# 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

# 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

# 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

# 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)

# 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

# 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

<%# 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

<%# 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