Form Objects in Ruby on Rails

In Ruby on Rails, a Form Object is a design pattern used to handle the complexity of forms that don’t map directly to a single model or have unique/complex validation and business logic. It encapsulates the logic related to form processing, validation, and data persistence, often combining attributes from multiple models or handling data that doesn’t fit neatly into the traditional ActiveRecord model structure.

Consider a UsersController that lets you create or edit a user. Typical CRUD. The form to create and edit a User may require a username or email, password, a full name. But then now consider an InvitationsController, which will also create a User, but this form may only require an email address. Our model validations for User will become a little gross with conditional validations, potentially virtual attributes to act as flags for model callbacks. Enter the Form Object.

A Form Object is a new class that sits between the Controller and the Model.

Key Characteristics of a Form Object in Rails:

  • Encapsulation of Form Logic: Form objects centralize the form-related logic, keeping controllers and models cleaner by moving validation and other form-specific methods into a dedicated class.
  • Composite Forms: They are useful when a form interacts with multiple models. For example, a user registration form might need to create both a User and a Profile model.
  • Custom Validations: Form objects can have their own validations independent of the underlying models. This allows for more granular control over the validation process.
  • Data Persistence: While form objects can validate and manipulate data, they usually delegate the actual persistence of data to the relevant models.
  • Reusable Logic: By centralizing form logic, form objects can be reused across different parts of the application, making the codebase more maintainable and DRY (Don’t Repeat Yourself).

Active Model provides features to implement input filtering and validation. We can lean on Active Model to craft our Form Object, called ApplicationForm.

class ApplicationForm
  include ActiveModel::API
  include ActiveModel::Attributes

  class << self

    # This method is used in the controller to create a new instance of the form
    # and populate it with the params from the request.
    # Usage: Lead::LeadGenForm.from(params.require(:lead_lead_gen_form))
    def from(params)
      new(params.permit(attribute_names.map(&:to_sym)))
    end
  end

  def save
    return false unless valid?

    with_transaction do
      submit!
    end
  end

  private

  def with_transaction(&)
    ApplicationRecord.transaction(&)
  end

  def submit!
    raise NotImplementedError
  end
end

We include a couple of ActiveModel concerns.

ActiveModel::API automatically gives us validation support! From the docs:

Includes the required interface for an object to interact with Action Pack and Action View, using different Active Model modules. It includes model name introspections, conversions, translations, and validations. Besides that, it allows you to initialize the object with a hash of attributes, pretty much like Active Record does.

Super useful for us.

We also include ActiveModel::Attributes. Basically, this provides a DSL for defining a form object schema and parameter types.

The Attributes module allows models to define attributes beyond simple Ruby readers and writers. Similar to Active Record attributes, which are typically inferred from the database schema, Active Model Attributes are aware of data types, can have default values, and can handle casting and serialization.

To use Attributes, include the module in your model class and define your attributes using the attribute macro. It accepts a name, a type, a default value, and any other options supported by the attribute type.

The attribute method provided by ActiveModel::Attributes is used to define our form inputs.

class Lead::LeadGenForm < ApplicationForm
  attribute :name, :string
  attribute :email, :string
  attribute :message, :string

  validates :name, :email, :message, presence: true
  validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}

  attr_accessor :lead

  private

  def submit!
    @lead = Lead.new({name: name, email: email, message: message})
    @lead.save!
    LeadsMailer.contact_form_auto_responder(lead: lead).deliver_later
    LeadsMailer.contact_form_notification(lead: lead).deliver_later
  end
end

Now in our Controller we can instantiate a new Form Object and use it in our view! Since the Form Object belongs to the presentation layer they can be used in view templates.

def create
    @lead = Lead::LeadGenForm.from(params.require(:lead_lead_gen_form))

    if @lead.save
      redirect_to :thank_you
    else
      render :new, status: :unprocessable_entity
    end
  end

We can use the Form Object just as we would use a model in our view:

<%= form_with model: @lead, url: lead_gen_path do |form| %>

One thing I particularly like about this pattern is that I can easily write tests for my Form Objects and do not have to juggle as many model callbacks (and deal with that complexity in tests).

Using Form Objects means I can achieve cleaner, more maintainable code, especially in complex form scenarios.