Form Objects in Rails 8

Complex forms that span multiple models or require intricate validation logic can quickly turn Rails controllers into unmaintainable messes. The Form Object pattern provides an elegant solution: encapsulate form logic in dedicated classes that handle validation, type coercion, and persistence—keeping controllers focused on HTTP concerns.

The Problem: Controller Bloat and Scattered Validation

Consider a user registration form that creates a User, their Company, and sends a welcome email. Without Form Objects, this logic often ends up scattered across controllers, models, and callbacks. The controller becomes a dumping ground for conditional logic, and testing becomes painful.

Form Objects solve this by creating a single class responsible for:

  • Accepting and coercing form parameters
  • Validating data across multiple models
  • Coordinating persistence in a transaction
  • Providing a clean interface for controllers and views

Building a Base Form Object Class

Rails 8 ships with Active Model, which provides everything needed to build form objects that integrate seamlessly with form helpers and validation. Start by creating a base class that all form objects can inherit from:

# app/forms/application_form.rb
class ApplicationForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations::Callbacks

  def self.model_name
    ActiveModel::Name.new(self, nil, self.name.chomp("Form"))
  end

  def save
    return false unless valid?

    ApplicationRecord.transaction do
      persist!
    end

    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end

  private

  def persist!
    raise NotImplementedError, "Subclasses must implement #persist!"
  end
end

The model_name override allows form_with to generate proper parameter keys. A form object named RegistrationForm will use registration as its param key, making it work naturally with Rails conventions.

Implementing a Complex Registration Form

Now build a registration form that creates both a user and their associated company. The form object handles validation for both models and ensures atomic creation:

# app/forms/registration_form.rb
class RegistrationForm < ApplicationForm
  attribute :email, :string
  attribute :password, :string
  attribute :password_confirmation, :string
  attribute :company_name, :string
  attribute :company_size, :integer, default: 1
  attribute :terms_accepted, :boolean, default: false

  validates :email, presence: true,
                    format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true,
                       length: { minimum: 12 },
                       confirmation: true
  validates :company_name, presence: true,
                           length: { minimum: 2, maximum: 100 }
  validates :company_size, numericality: { greater_than: 0, less_than: 10_000 }
  validates :terms_accepted, acceptance: { accept: true }

  validate :email_uniqueness

  attr_reader :user, :company

  private

  def persist!
    @company = Company.create!(
      name: company_name,
      size: company_size
    )

    @user = User.create!(
      email: email,
      password: password,
      company: @company
    )

    RegistrationMailer.welcome(@user).deliver_later
  end

  def email_uniqueness
    return if email.blank?
    return unless User.exists?(email: email.downcase)

    errors.add(:email, "has already been taken")
  end
end

The attribute declarations provide automatic type coercion. When a form submits company_size as a string "50", it becomes the integer 50. Boolean fields like terms_accepted correctly handle checkbox values like "1" and "0".

Clean Controller Integration

With the form object handling all the complexity, the controller becomes refreshingly simple:

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def new
    @registration = RegistrationForm.new
  end

  def create
    @registration = RegistrationForm.new(registration_params)

    if @registration.save
      start_session_for(@registration.user)
      redirect_to dashboard_path, notice: "Welcome to the platform!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def registration_params
    params.require(:registration).permit(
      :email, :password, :password_confirmation,
      :company_name, :company_size, :terms_accepted
    )
  end
end

The controller knows nothing about Company creation, email sending, or cross-model validation. It simply asks the form object to save and responds accordingly.

View Integration with form_with

Form objects work seamlessly with Rails form helpers. The view remains conventional:

# app/views/registrations/new.html.erb
<%= form_with model: @registration, url: registrations_path do |f| %>
  <% if @registration.errors.any? %>
    
<% @registration.errors.full_messages.each do |message| %>

<%= message %>

<% end %>
<% end %>
<%= f.label :email %> <%= f.email_field :email, autofocus: true %>
<%= f.label :password %> <%= f.password_field :password %>
<%= f.label :password_confirmation %> <%= f.password_field :password_confirmation %>
<%= f.label :company_name %> <%= f.text_field :company_name %>
<%= f.label :company_size %> <%= f.number_field :company_size, min: 1, max: 9999 %>
<%= f.label :terms_accepted %> <%= f.check_box :terms_accepted %>
<%= f.submit "Create Account" %> <% end %>

Field names, error messages, and form submission all work exactly as they would with an Active Record model.

Testing Form Objects in Isolation

One major benefit of form objects: they're simple Ruby classes that can be tested without touching controllers or views:

# spec/forms/registration_form_spec.rb
RSpec.describe RegistrationForm do
  describe "validations" do
    it "requires all fields" do
      form = RegistrationForm.new
      expect(form).not_to be_valid
      expect(form.errors[:email]).to include("can't be blank")
      expect(form.errors[:password]).to include("can't be blank")
      expect(form.errors[:company_name]).to include("can't be blank")
    end

    it "validates password minimum length" do
      form = RegistrationForm.new(password: "short")
      expect(form).not_to be_valid
      expect(form.errors[:password]).to include(/too short/)
    end

    it "validates email uniqueness" do
      create(:user, email: "[email protected]")
      form = RegistrationForm.new(email: "[email protected]")
      form.valid?
      expect(form.errors[:email]).to include("has already been taken")
    end
  end

  describe "#save" do
    let(:valid_params) do
      {
        email: "[email protected]",
        password: "securepassword123",
        password_confirmation: "securepassword123",
        company_name: "Acme Corp",
        company_size: 50,
        terms_accepted: true
      }
    end

    it "creates user and company in transaction" do
      form = RegistrationForm.new(valid_params)

      expect { form.save }.to change(User, :count).by(1)
                          .and change(Company, :count).by(1)
    end

    it "rolls back on failure" do
      form = RegistrationForm.new(valid_params)
      allow(User).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)

      expect { form.save }.not_to change(Company, :count)
    end

    it "enqueues welcome email" do
      form = RegistrationForm.new(valid_params)

      expect { form.save }
        .to have_enqueued_mail(RegistrationMailer, :welcome)
    end
  end
end

Common Patterns and Edge Cases

Form objects can handle updates by accepting an existing record and modifying behavior accordingly. Add an optional record parameter to the initializer and adjust persist! to call update! instead of create! when appropriate.

For forms that don't persist anything—like search filters or contact forms—override the save method entirely to perform the appropriate action, whether that's sending an email or returning filtered results.

When validation requires database access (like uniqueness checks), perform these checks in custom validation methods rather than relying solely on database constraints. This provides better error messages and prevents constraint violations from bubbling up as exceptions.

Summary

Form Objects extract complex form handling into testable, reusable classes. They shine when forms span multiple models, require custom type coercion, or involve business logic beyond simple CRUD. The pattern keeps controllers thin, makes testing straightforward, and provides a clear home for form-specific logic that doesn't belong in Active Record models.

For simpler forms that map directly to a single model, Active Record remains the right choice. Reserve Form Objects for the complex cases where their additional structure pays dividends in maintainability and clarity.

10 claps
← Back to Blog