Form Objects, the missing abstraction in Rails

Stop putting the right code in the wrong place!

It’s easy to get carried away using Rails scaffolding, but sometimes a Plain Old Ruby Object (PORO) is more than appropriate. And Form Objects are very good at keeping models lean, controllers sharp, and grouping related code together in one object that’s easy to test.

Tags : Rails Design Pattern

Published: September 18, 2024

Form Object basics

Form Objects get their name from their main use case: being the backend to a frontend form.
This can be a filtering mechanism backed by complex queries, or a registration form which needs specific validations and creates multiple records for instance.

In these canonical examples, we notice the need for an abstraction because:

  • a model, its validations or callbacks grow out of hand,
  • a controller action interacts with several models,
  • queries, scopes or a group of related scopes coalesce in a model,
  • testing becomes difficult, as more objects need to be instanciated or persisted.

While not actually deadly sins, these code smells indicate that we need to keep the code under control to prevent code rot —and contagion.

Form objects encapsulate form-related logic and validations. They typically act as an intermediary between the view and one or more models, handling data processing and validation before eventually persisting to the database.

Setting up Form Objects in a Rails project

The convention is to put Form Objects in an app/forms folder. This will make it easy for new devs to see how they differ from regular models, and where to add new form objects. Thanks to Rails autoloading, classes in that folder will automatically be available in controllers and elsewhere.

It’s also a good idea to create a base ApplicationForm class, from which all form objects in your app will inherit, as per Rails conventions. This base class would need to let you manage attributes and state, add validations, keep changes atomic, etc. You can either use a gem for that, or build it yourself to avoid adding a new dependency.

Using a gem

The Form Object category in the Ruby Toolbox lists several projects. I haven’t used Reform, Virtus or Dry-validation, but I would probably avoid them since they are no longer being actively maintained, as they will probably break when Ruby or Rails syntax evolves.

One of the most-used is Makandra’s ActiveType, but I’m uneasy seeing that they overwrite ActiveRecord’s attribute method.

My personal experience with YAAF was successful, though I had to augment it with nesting methods —which come natively in ActiveType. This was easily done using a base ApplicationForm from which my forms inherited macros.

If you choose not to use a gem, you’ll need to build the foundation for managing attributes and state, performing atomic changes, and building scopes.

Managing attributes and state

Form Objects often need to validate params or attribute values. Fortunately, Rails makes this easy to achieve just by including ActiveModel::Model.
After adding this line in a plain Ruby class, we can now declare attributes which will be typecast, as well as validations and callbacks, all using the familiar Rails syntax.

It’s also considered good practice to add a Rails-like public interface (save/save!, create/create!, etc) to form objects. There’s no need for controllers to know that the model it interacts with is a form object or a regular model.
By implementing a Rails-like interface, you may even be able to extract a Form Object from an existing model, without needing to change a single line of code in the associated controller.

Making sure changes are atomic

A frequent requirement in a user registration workflow is to create a user and associated account, and send a welcome email. Should the user or account creation fail, the welcome email should not be sent or enqueued.

Rails provides transaction blocks, which automatically rollback if any error is raised during the block. You can use the generic ActiveRecord::Base.transaction, or be more class-specific: Foo.transaction do … end. This last syntax is more readable —and more future-proof, now that Rails apps may have more than one database.

Beware that jobs enqueued inside a transaction used to be enqueued immediately, and could run before the transaction was committed, or pass through even if the transaction was eventually rolled back!

Complex queries

If a Form Object needs to filter records, it’s best to allow chaining, pagination, etc by returning an ActiveRecord::Relation instead of an array. An easy way to make sure you’re returning such a relation is to have an internal scope, created using Foo.all in the case of a model called Foo, and augment it in each method. You can also leverage the extending Rails helper to add methods to an existing scope.

Demo time

Now that we’ve covered the basics, let’s apply the Form Object pattern to refactor and improve code.

Example legacy code

Imagine that the registration process mentioned earlier happened to use the following code:

# app/models/user.rb
class User < ApplicationRecord
  attr_accessor :company_name

  belongs_to :company

  validates :email, presence: true
  validates :password, presence: true, length: { minimum: 8 }
  validates :company_name, presence: true, on: :create

  before_create :set_company
  after_create :send_slack_notification

  def set_company
    self.company ||= Company.find_or_create_by(name: company_name)
  end

  private

  def send_slack_notification
    SlackNotifier.self_congratulate
  end
end

# app/controllers/users_controller.rb
def UsersController < ApplicationController
  # POST /users
  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.with(user: @user).welcome.send_later
      redirect_to dashboard_path
    else
      render :new
    end
  end
end

Here are two of the most salient problems:

  • Setting the company pollutes the model (adding an accessor, a validation, callbacks, and private methods), though it is only needed when creating a user.
  • Two different actions are triggered when a user signs up: a welcome email, and a Slack notification, each from a different code location. Should a batch of users need to be imported from the command line or a file, the Slack notifications would come flying, but none of the new users would receive a welcome email.

Adding tests

We need to make sure our code is properly covered by tests before refactoring, adding features or fixing bugs. This will also help us make sure we understand the problem and add just enough code to perform the task we set out to tackle.

Since we’ll move the responsibility of sending notifications to the Form Object, we’ll only need unit tests. To make sure the test only exercises the object we’re interested in, we’ll mock the external dependency on Slack Notifier. Ideally, we should mock persisting the user, which would greatly speed up the test runs, but I’ll leave that out in this article.

Rspec.describe UserRegistrationForm do
  describe "#save" do
    let(:params) do
      {
        user: {
          email: Faker::Internet.email,
          password: Faker::Alphanumeric.alphanumeric(number: 10)
        },
        company_name: Faker::Company.name
      }
    end
    let!(:slack_notifier) { instance_double("SlackNotifier", self_congratulate: true) }
    subject(:registration) { UserRegistrationForm.new(params) }

    context "when params contains valid values" do
      it "returns true" do
        expect(registration.save).to eq true
      end
      it "enqueues a welcome email" do
        expect { registration.save }.to have_enqueued_mail(UserMailer, :welcome)
      end
      it "sends a Slack notification" do
        registration.save
        expect(slack_notifier).to have_received(:self_congratulate)
      end
    end

    context "when params contains invalid values" do
      let(:params) { {} }
      it "returns false" do
        expect(registration.save).to eq false
      end
      it "doesn't enqueue a welcome email" do
        expect { registration.save }.not_to have_enqueued_mail(UserMailer, :welcome)
      end
      it "doesn't send a Slack notification" do
        registration.save
        expect(slack_notifier).not_to have_received(:self_congratulate)
      end
    end
  end
end

Refactoring using a Form Object

If you’re new to Form Objects, I recommend installing the YAAF gem to get avoid reinventing the wheel. Run bundle add yaaf, then create a base ApplicationForm inheriting from YAAF::Form.
In case of invalid input, the YAAF gem promotes errors, so calling .errors on the form object returns the errors from the underlying models, all with the appropriate keys, ready to be displayed in the view.

Now let’s extract the user-creation code into a form object. We need to find a name for it first, and UserRegistrationForm seems both accurate and self-explanatory.
Next, we’ll move registration-specific code from the User model and controller into this new class.

Here’s what we could arrive at:

# app/models/user.rb
class User < ApplicationRecord
  belongs_to :company

  validates :email, presence: true
  validates :password, presence: true, length: { minimum: 8 }
end

# app/controllers/users_controller.rb
def UsersController < ApplicationController
  # POST /users
  def create
    @user = UserRegistrationForm.new(user_params)
    if @user.save
      redirect_to dashboard_path
    else
      render :new
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :company_name)
  end
end

# app/forms/application_form.rb
def ApplicationForm < YAAF::Form
  # Added for consistency
  def new_record?
    true
  end
end

# app/forms/user_registration_form.rb
def UserRegistrationForm < ApplicationForm
  attr_accessor :user_attributes
  attr_accessor :company_name

  before_validation :set_company
  after_commit :send_notifications

  def initialize(attributes)
    company_name = attributes.delete(:company_name)
    super(user_attributes: attributes)
    @models = [user]
  end

  private

  def set_company
    user.company = Company.find_or_create_by(name: company_name)
  end

  def send_notifications
    UserMailer.with(user:).welcome.send_later
    SlackNotifier.self_congratulate
  end
end

Conclusion

While it may be tempting to follow the RESTful routing convention and create resources for each HTTP endpoint, some concepts don’t map to models or database tables. Even though Rails doesn’t actively encourage using Form Objects, it’s easy to leverage their powers using gems or native helpers.

Form objects are quite powerful because it’s easy to extract them from legacy code without changing their interface, yet they greatly improve readability and extendability. It’s also easier to test their behaviour without touching the database, reducing the time and cost of running the test suite. If you’re ever tempted to create several records inside an action, need to perform specific validation in a particular context, or respond to a complex filtering form, evaluate if a Form Object would be appropriate!

Bonus points: adding macros to YAAF

In a project using Form Objects, I found it practical to manage nested models using Rails-like macros. I’ve implemented them on top of YAAF by injecting a module into the class, just like Rails does. Here’s the code I used:

class ApplicationForm < YAAF::Form
  class << self
    # This is useful for I18n, dom_id, etc
    def to_model
      name.sub("Form", "").safe_constantize
    end

    # This allows adding Rails-like macros (has_one, accepts_nested_attributes_for…)
    def association_macros
      @association_macros ||= begin
        mod = const_set(:GeneratedAssociationMacros, Module.new)
        include mod
        mod
      end
    end

    #  This adds [model]= , [model]_id, and [model]_id= methods, just like Rails does
    def has_one(model, **options)
      klass = options.fetch(:class, model.to_s.classify)
      association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
        attr_writer :#{model}
        delegate :id, to: :#{model}, prefix: true, allow_nil: true

        def #{model}
          @#{model} ||= #{klass}.new
        end

        def #{model}_id=(id)
          @#{model} = #{klass}.find(id) if id.present?
        end
      CODE
    end

    # This allows setting attributes for a nested has_one model
    def accepts_nested_attributes_for(model, **options)
      klass = options.delete(:class) || model.to_s.classify
      association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
        attr_reader :#{model}_attributes

        def #{model}_attributes=(params = {})
          @#{model} = #{klass}.new(params) if params.present?
        end
      CODE
    end
  end
end

And here is a spec file describing these macros and making sure that they work as expected.

# spec/forms/application_form_spec.rb
RSpec.describe ApplicationForm do
  describe ".association_macros" do
    it "includes the GeneratedAssociationMacros module" do
      described_class.association_macros
      expect(described_class.ancestors.second).to eq ApplicationForm::GeneratedAssociationMacros
    end
  end

  describe ".has_one" do
    let(:dummy) {
      class DummyAssociation; end
      class Dummy < ApplicationForm
        has_one :dummy_association
      end
      Dummy
    }
    [:dummy_association, :dummy_association=, :dummy_association_id, :dummy_association_id=].each do |method|
      it "adds the #{method} method" do
        expect(dummy.instance_methods).to include(method)
      end
    end
  end

  describe ".accepts_nested_attributes_for" do
    let(:dummy) {
      class DummyAssociation; end
      class Dummy < ApplicationForm
        accepts_nested_attributes_for :dummy_association
      end
      Dummy
    }
    [:dummy_association_attributes, :dummy_association_attributes=].each do |method|
      it "adds the #{method} method" do
        expect(dummy.instance_methods).to include(method)
      end
    end
  end
end
Previous page Previous page Next page Next page