NYC
skills/dchuk/rails_ai_agents/form-object-patterns

form-object-patterns

SKILL.md

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

# 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

  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)

# test/forms/registration_form_test.rb
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 = RegistrationForm.new(
      email: "new-user@example.com",
      password: "password123",
      password_confirmation: "password123",
      company_name: "Acme Inc"
    )

    assert_difference("User.count", 1) { form.save }
  end

  test "#save creates an account" do
    form = RegistrationForm.new(
      email: "new-account@example.com",
      password: "password123",
      password_confirmation: "password123",
      company_name: "Acme Inc"
    )

    assert_difference("Account.count", 1) { form.save }
  end

  test "#save associates user with account" do
    form = RegistrationForm.new(
      email: "assoc@example.com",
      password: "password123",
      password_confirmation: "password123",
      company_name: "Acme Inc"
    )
    form.save
    assert_equal form.user.account, form.account
  end

  test "#save with invalid params returns false" do
    form = RegistrationForm.new(email: "", password: "short")
    assert_not form.save
  end

  test "#save with invalid params does not create records" do
    form = RegistrationForm.new(email: "", password: "short")
    assert_no_difference("User.count") { form.save }
  end

  test "#save with duplicate email returns false" do
    existing = users(:one)
    form = RegistrationForm.new(
      email: existing.email_address,
      password: "password123",
      password_confirmation: "password123",
      company_name: "Acme Inc"
    )

    assert_not form.save
    assert_includes form.errors[:email], "has already been taken"
  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

Test First

# test/forms/event_search_form_test.rb
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: @account, params: { query: "test" })
    assert form.any_filters?
  end

  test "#any_filters? returns false without filters" do
    form = EventSearchForm.new(account: @account, params: {})
    assert_not form.any_filters?
  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

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

# app/forms/wizard/base_form.rb
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

  • Test 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 tests GREEN
Weekly Installs
2
First Seen
7 days ago
Installed on
gemini-cli2
opencode2
antigravity2
codex2
windsurf2
kiro-cli2