What is a form object?

Brad Pauly — October 8, 2015

A form object is an informal "pattern" that many rails developers use to simplify controllers and ActiveRecord models. They can also make your code easier to understand. As an informal pattern there are many implementations. Below are a few blog posts that show different approaches.

The general idea is the same. Take input from a form, validate it, and then go on with your business. One of the motivations for this pattern is to remove business logic from ActiveRecord models and, in general, I like this idea. The catch for me is that some validations require querying a database, which is what ActiveRecord is made for. Could you leverage ActiveRecord to do its thing while keeping other responsibilities (i.e. updating other models or sending emails) in the form object?

ActiveForm (an experiment)

I like to write pseudo-code as a way of sketching out an API. Tests are great for this, but for brevity I'll just show you what I wanted to do in the controller.

class RegistrationsController < ApplicationController
  def create
    @registration_form = RegistrationForm.new(params[:registration_form])
    if @registration_form.save
      redirect_to '/'
    else
      render action: :new
    end
  end
end

This is straight forward, easy to understand and doesn't do anything other than direct the flow of the applicaiton. Next is the code for RegistrationForm which will start to give you an idea of what AcitveForm provides.

class RegistrationForm < ActiveForm::Form
  accepts_attributes_for :user, :name, :email
  accepts_attributes_for :organization, :name

  within_save :associate_organization
  after_save :send_welcome_email

  def associate_organization
    organization.update_attribute(:user_id, user.id)
  end

  def send_welcome_email
    UserMailer.welcome(user, organization).deliver
  end
end

The calls to .accepts_attributes_for on line 2 & 3 are what hooks us up to the ActiveRecord models User and Organization. Those give us access to instances of those models via #user and #organization which are used later in #associate_organization and #send_welcome_email. On line 5 we use .within_save to define a callback to be called when the models are being saved. It is separate from .after_save because it is wrapped in a transaction and .after_save is not.

With form builders

Along with the #user and #organization methods (built with attr_accessor) there are also attr_accessors for each model and its attributes. In the example we're using we get #user_name, #user_email, and #organization_name. This allows us to keep form builder code nice and simple. This is a bare bones form (Sorry, I had to remove erb to get the highlighting to work properly).

form_for @registration_form, url: registrations_path do |form|
  form.text_field :user_name
  form.text_field :user_email
  form.text_field :organization_name
  form.submit 'Register'
end

It's just like working with an ActiveRecord model but with a little extra typing. Now that we've covered how we use it let's look at how its built. At the beginning the goal was to have ActiveRecord perform validations so let's start there. The full implementation is below for line number references.

We saw that .accepts_attributes_for is where this happens. Line 84 below shows the method definition. This is where the attr_accessor calls happen. At the same time #map_model_attribute creates a Hash, attribute_map, that keeps track of which attribues belong to which model. We'll need that later when building the ActiveRecord models. That gets everything set up so now we can look at the instance methods.

In our controller we are only calling #save which in turn calls #process. Those are where the bulk of the work is done. Lines 19-23 are where our ActiveRecord models are instantiated and attributes are set. Next we call #validate_models which loops over the models, calls #valid? on each one and then copies any errors back to the form object so it can be used in the output. Now if we have any errors we bail out calling it a failure and returning false. If we don't have any errors we persist the models on lines 7-10. The transaction is used so any problems will roll everything back. This is also where we have a chance to perform other database operations via the .within_save callback. After the transaction we check for an .after_save callback and finally return true on line 12.

module ActiveForm
  class Form
    include ActiveModel::Model

    def save
      if process
        ActiveRecord::Base.transaction do
          models.map(&:save!)
          send(self.class.within_save_method) if self.class.within_save_method
        end
        send(self.class.after_save_method) if self.class.after_save_method
        return true
      else
        return false
      end
    end

    def process
      self.class.model_names.map do |model_name|
        model = "#{model_name}".camelize.constantize.new(attributes_for_model(model_name))
        models << model
        send("#{model_name}=", model)
      end
      validate_models
      errors.messages.empty?
    end

    def attributes_for_model(model_name)
      attributes = {}
      self.class.attribute_map[model_name].each do |attribute_name|
        attributes[attribute_name] = send("#{model_name}_#{attribute_name}".to_sym)
      end
      attributes
    end

  private

    def models
      @models ||= []
    end

    def validate_models
      models.each do |thing|
        name = thing.class.name.downcase
        unless thing.valid?
          thing.errors.messages.each do |k, v|
            v.each do |m|
              errors.add("#{name}_#{k}".to_sym, m)
            end
          end
        end
      end
    end

    def self.model_names
      @model_names ||= []
    end

    def self.attribute_map
      @attribute_map ||= {}
    end

    def self.map_model_attribute(model_name, attribute_name)
      attribute_map[model_name] ||= []
      attribute_map[model_name] << attribute_name
    end

    def self.within_save_method
      @within_save_method
    end

    def self.within_save(method_name)
      @within_save_method = method_name
    end

    def self.after_save_method
      @after_save_method
    end

    def self.after_save(method_name)
      @after_save_method = method_name
    end

    def self.accepts_attributes_for(model_name, *attributes)
      model_names << model_name
      attr_accessor model_name
      attributes.each do |attribute_name|
        map_model_attribute(model_name, attribute_name)
        attr_accessor "#{model_name}_#{attribute_name}".to_sym
      end
    end
  end
end

A similar but different pattern

Most of the form object examples I've seen, including this one, focus on input that is saved to a database. The majority of the time that's probably what's happening, but not always. What about a authentication? You collect credentials, but they aren't saved to a database. Because ActiveForm includes ActiveModel::Model we can validate input without an ActiveRecord model.

Assuming our User class has an authenticate method from has_secure_password we could make a LoginForm like this:

class LoginForm < ActiveForm::Form
  attr_accessor :email, :password, :remember_me

  def authenticate
    User.find_by(email: email).try(:authenticate, password)
  end
end

And our controller code would use #authenticate instead of #save. Although we aren't using remember_me in LoginForm it is handy to put it there so the form builder has access to it, keeping the value if the form is rendered again. We just pass it to #start_session to be dealt with there.

class SessionsController < ApplicationController
  def new
    @login_form = LoginForm.new
  end

  def create
    @login_form = LoginForm.new(params[:login_form])

    if user = @login_form.authenticate
      start_session(user, @login_form.remember_me)
      redirect_to '/'
    else
      flash.now[:notice] = "ACCESS DENIED" # Just kidding, be nice.
      render :new
    end
  end

private
  def start_session(user, remember_me)
    # Start a session and use remember_me to decide how long it will last.
  end
end

A lot of what ActiveForm provides isn't used here so it might not make sense to use it in this case.

What do you think?

I'd love to hear what you think about this approach. Have you implemented your own version "form objects"? How did you do it?

Want new posts sent to your email?

I'm always looking for new topics to write about. Stuck on a problem or working on something interesting? You can reach me on Twitter @bradpauly or send me an email.