Stratus3D

Software Engineering, Web Development and 3D Graphics

Extensible Rails 4 Form Object Design

Keeping models and controllers simple as Rails applications grow can often be a challenge. Controllers begin handling business logic in addition to view logic. Models grow large with methods responsible for the presentation of the data and the business logic that surrounds the data. With the addition of things like accepts_nested_attributes_for the problem gets even worse. accepts_nested_attributes_for tethers your models together, making one model aware of the attributes of the other. In the view this problem is compounded by the knowledge the form markup contains about the nested structure of the models. Form objects are one of the ways to simplify models and controllers. In this post I am going to show what I have found to be an effective form object design. It will eliminate the need for accepts_nested_attributes_for.

There are many solutions to this problem. Form objects are one solution. There are many form object implementations. There are gems like reform, simple_form_object and virtus. And while these work well in many cases I found some of the things they did undesirable. Most gems require some duplication of logic. They usually require the validation logic to be present in the form object, even if they are already present in the model. With my solution the validation logic remains in the original models where it should be. Of course additional validations can be added to the form object itself to verify the validity of the objects as a group.

The Basic Form Object Class

Most of this solution came from Thoughtbot’s Upcase forum where @derekprior provided a simple form object example. His example had elegant solutions to validation and persistence of multi-model forms. It fell short in some other areas. His solution didn’t provide a way to initialize the form with existing objects. Using existing objects is necessary when creating edit forms. My form class can be initialized with the objects it updates when it is created. This makes any controller action that uses the form object very simple.

An Example

To illustrate how my form object works let’s suppose we have an online store that has a Customer model and an Address model. Customers might not have an address when they sign up. Keeping the data in two separate models makes sense. Store administrators might want to edit all the information associated with a customer in one form. And we must remember that the Address record for a customer may not exist yet. This is where form objects come in. Our form object will manage creating and updating of customer records and their associated address records. First here are the two models:

customer.rb
1
2
3
class Customer < ActiveRecord::Base
  has_one :address
end
address.rb
1
2
3
class Address < ActiveRecord::Base
  belongs_to :customer
end

The Form Object

Next we create a form class. I like to keep mine in app/forms/ but you can put them wherever you like. The form class is by far the most complex of the three classes. Don’t worry though, I will go through it and explain what each section of it does.

customer_form.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class CustomerForm
  # include ActiveModel so we have all the nice ActiveModel methods available
  include ActiveModel::Model
  # All the models that are apart of our form should be part attr_accessor.
  # This allows the form to be initialized with existing instances.
  attr_accessor :customer, :address

  def self.customer_attributes
    Customer.column_names.push(Customer.reflections.keys)
  end

  def self.address_attributes
    Address.column_names.push(Address.reflections.keys)
  end

  customer_attributes.each do |attr|
    delegate attr.to_sym, "#{attr}=".to_sym, to: :customer
  end

  address_attributes.each do |attr|
    delegate attr.to_sym, "#{attr}=".to_sym, to: :address
  end

  delegate :id, :persisted?, to: :customer

  validate :validate_children

  def self.model_name
    customer.model_name
  end

  def assign_attributes(params)
    customer_attributes = params.slice(*self.class.customer_attributes)
    customer.assign_attributes(customer_attributes)
    address_attributes = params.slice(*self.class.address_attributes)
    address.assign_attributes(address_attributes)
    setup_associations
  end

  def save
    if valid?
      ActiveRecord::Base.transaction do
        customer.save!
        address.save!
      end
    end
  end

  def customer
    @customer ||= Customer.new
  end

  def address
    @address ||= Address.new
  end

  private

  def setup_associations
    address.customer = customer
  end

  def validate_children
    setup_associations

    if customer.invalid?
      promote_errors(customer.errors)
    end

    if address.invalid?
        promote_errors(address.errors)
    end
  end

  def promote_errors(child_errors)
    child_errors.each do |attribute, message|
      errors.add(attribute, message)
    end
  end
end

At the very top of the class we include ActiveModel::Model. This provides all the nice ActiveModel methods that you have come to expect on your model instances. This also allows us to treat our class more like a model instance in the controller. Next we have the attr_accessor call. This is where we specify the names of the models that make up our form. These are the names, not the classes, of those models.

1
2
3
4
5
  # include ActiveModel so we have all the nice ActiveModel methods available
  include ActiveModel::Model
  # All the models that are apart of our form should be part attr_accessor.
  # This allows the form to be initialized with existing instances.
  attr_accessor :customer, :address

Next come the attributes and the delegation logic. First we define class methods that return the attributes of each model listed in attr_accessor call. In our case we need two such methods - one for the Customer model and one for the Address model. Next we take the array of attributes from each model and delegate getter and setter methods for each of the attributes back to the original model. Again, in our case this is :customer, and :address (Note that if the two models contain attributes with the same name only one will be delegated correctly due to the name conflict. If you run into this issue try using delegate’s :prefix option). This means that when we assign :first_name to our form object (@form_object.first_name = 'Fred') we are actually setting the :first_name attribute of the Customer instance. This delegation is what allows us to easily aggregate all the attributes from multiple models into one class without having to duplicate attribute names. Also note the last line. We delegate :id and :persisted? to Customer, as these are methods that must be present on our form object (Note that this may not be needed in all cases).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  def self.address_attributes
    Address.column_names.push(Address.reflections.keys)
  end

  def self.customer_attributes
    Customer.column_names.push(Customer.reflections.keys)
  end

  address_attributes.each do |attr|
    delegate attr.to_sym, "#{attr}=".to_sym, to: :address
  end

  customer_attributes.each do |attr|
    delegate attr.to_sym, "#{attr}=".to_sym, to: :customer
  end

  delegate :id, :persisted?, to: :customer

Next comes the model_name class method and a few other methods. The model_name class method is used by simple_form to determine the appropriate create and update urls the form should use. Next we have the assign_attributes method. This method is responsible for setting the attributes on all the model instances (in our case just Customer and Address). We call assign_attributes on each instance and pass in the appropriate portion of the attributes hash. I chose to slice the attributes hash based on model attributes and pass the resulting hashes to each model. There might be a better way of doing this but I found this method readable and relatively simple. After setting the attributes assign_attributes calls the setup_associations method which sets up the model associations properly (more on that in a minute). Next is the save method. It invokes valid? before attempting to save the records. If valid? returns true each model’s save! method is invoked inside a transaction. We also define functions for each of the models contained in the form (again, in our case this is Customer and Address). These methods are important as they will initialize new Customer and Address instances if we didn’t provide our own when initializing the form. Finally, we have a call to validate, which invokes our custom :validate_children method (which is described in the next paragraph).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  validate :validate_children

  def self.model_name
    customer.model_name
  end

  def assign_attributes(params)
    customer_attributes = params.slice(*self.class.customer_attributes)
    customer.assign_attributes(customer_attributes)
    address_attributes = params.slice(*self.class.address_attributes)
    address.assign_attributes(address_attributes)
    setup_associations
  end

  def save
    if valid?
      ActiveRecord::Base.transaction do
        customer.save!
        address.save!
      end
    end
  end

  def customer
    @customer ||= Customer.new
  end

  def address
    @address ||= Address.new
  end

Now all we have left are the private methods. setup_associatons is a method that is responsible for setting up any necessary associations prior to saving or validating the form data. In our case all it does is assign the customer to the customer attribute of the address. Next is validate_children. This is our validation callback. This method is responsible for all validations. First it invokes setup_associations to ensure the associations are setup, then it checks if each model in the form is valid, and if it is not, it passes that instance’s errors to the promote_errors method. The promote_errors method takes all the errors that it is passed and adds each one to the errors array on the CustomerForm instance. This in turn renders the form itself invalid. Furthermore, the errors in the errors array correspond with delegated attributes on the form object. These are the same attributes that will be rendered as form fields in view. Since the attributes match, simple_form will be able to handle the display of errors in the form the way it normally would - with messages above or below the corresponding form field.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  private

  def setup_associations
    address.customer = customer
  end

  def validate_children
    setup_associations

    if customer.invalid?
      promote_errors(customer.errors)
    end

    if address.invalid?
        promote_errors(address.errors)
    end
  end

  def promote_errors(child_errors)
    child_errors.each do |attribute, message|
      errors.add(attribute, message)
    end
  end

All of these methods allow us to treat our form object very much like an ActiveRcord instance. And other than class names and associations, we didn’t have to duplicate anything that already existed in our models. Existing model validations are used automatically. This makes for a very DRY form object. All of this will make our controller and view code simpler than what would be required with accepts_nested_attributes_for.

The Controller and Views

Now our form object isn’t much use unless we actually use it in our controllers. The CustomerController is where we are most likely going to need our CustomerForm. Here all we need to do to build working new and edit forms:

CustomerController.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class CustomerController < ApplicationController
 def new
    @customer_form = CustomerForm.new()
  end

  def create
    @customer_form = CustomerForm.new(customer_form_params)

    if @customer_form.save
      redirect_to customer_path(@customer_form.customer)
    else
      render :new
    end
  end

    def edit
      @customer = Customer.find(params[:id])
      @customer_form = CustomerForm.new(customer: @customer, address: @customer.address)
  end

  def update
    @customer = Customer.find(params[:id])
    @customer_form = CustomerForm.new(customer: @customer, address: @customer.address)
    @customer_form.assign_attributes(customer_form_params)

    if @customer_form.save
      redirect_to customer_path(@customer_form.customer)
    else
      render :edit
    end
  end

 def customer_form_params
    params.require(:customer).permit(%w{email password password_confirmation
      last_name first_name address address_2 city city_type state postal_code
       country})
  end
end

Since our form object handles everything we can treat our form object like we would treat any other model in our controller. The only difference is that when editing we must first retrieve the customer and the address and pass them into the CustomerForm.new call. We then call assign_attributes with the customer form parameters like normal.

The view is also very simple. All we need is a form partial like the one below, which we can include in our new.html.erb and edit.html.erb view templates:

_form.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<%= simple_form_for @customer_form do |f| %>
  <%= f.input :first_name %>
  <%= f.input :last_name %>
  <%= f.input :email %>
  <%= f.input :password %>
  <%= f.input :password_confirmation %>

  <%= f.input :address %>
  <%= f.input :address_2 %>
  <%= f.input :city %>
  <%= f.input :city_type %>
  <%= f.input :state %>
  <%= f.input :postal_code %>
  <%= f.input :country %>

  <%= f.button :submit %>
<% end %>
new.html.erb and edit.html.erb
1
<%= render 'form' %>

Summary

Hopefully you can see how simple and extensible this solution is compared to some of the alternatives. We didn’t have to duplicate a single attribute name or validation method. We could easily add a Profile object to the form with needing to add more than 4 small blocks of code.

I have thought about putting this in a gem. But for now I think that isn’t necessary. If you have any thoughts about my form class I would love to here them. Feel free to contact me.

9/4/2015 Update

When I first posted this I accidentally left out the assign_attributes method of the CustomerForm class. Obviously that’s a pretty critical portion of the form class. I updated the relevant portions of this post with the missing information

Resources: