At today’s dev discussion in Boston, we discussed form objects. My notes follow.
What is a form object?
A form object exists specifically to be the object passed to form_for
in your views. Often it aggregates data that is used to create multiple objets, though this doesn’t have to be the case – you may be collecting data for a single object but in a way that doesn’t cleanly map to the fields on your model.
** What’s Wrong with accepts_nested_attribtutes_for
?**
The ‘Rails Way’ to aggregate data for multiple objects in a single form is through accepts_nested_attributes_for
. This works reasonable well (though there are some gotchas), so what’s the problem?
- It makes your model concerned with how the view is collecting data
- It ties your view explicitly to the structure of all of the models and their relationships.
- It increases the already large API of your ActiveRecord objects.
- It can be confusing to reason about, particularly as you add more than 1 relationship or more than one level of relationships.
** An example form object **
Here’s an (annotated) example of a form object I wrote on my recent project. This object is used as the form on sign up and collects fields for user (username and email) and profile (zip code). This is a pretty simple example of a form object because it has very few fields and only two components its aggregating data for.
class Registration
# Make this model play nice with `form_for`. Gives us validations, initialize, etc
include ActiveModel::Model
# Accessors for the fields we are exposing in the form
attr_accessor :email, :password, :zip
# This is an implementation detail of our authentication (Clearance). It's ultimately going to
# sign this object in and that process expects a user object that has these fields available
delegate :remember_token, :id, to: user
# For this object to be valid, we want the child objects (user and profile) to be valid. This is
# the validation method that does this.
validate :validate_children
# We tell registration it's a user. This will keep it pointing to Users controller and allow it to use
# user translations
def self.model_name
User.model_name
end
# This is what our controller calls to save the user.
def save
if valid?
# Create a transaction. If any of the database stuff in here fails, they will raise an exception
# (because they are bang methods) and rollback the transaction.
ActiveRecord::Base.transaction do
user.save!
profile.save!
end
end
end
private
# Initialize the user object with the arguments that apply to user
def user
@user ||= User.new(email: email, password: password)
end
# initialize the profile object with the arguments that apply to profile
def profile
@profile ||= user.build_profile(zip: zip)
end
# the implementation of our validation method. We just delegate to our
# two child objects which define their validations (presence on all fields in this case)
def validate_children
if user.invalid?
promote_errors(user.errors)
end
if profile.invalid?
promote_errors(profile.errors)
end
end
# Errors on `user` or `profile` aren't helpful to us because `user.email` isn't on our form. `email` is however.
# Take all errors from our child objects and promote them to the same field on the base object. This will make
# them render properly on the form.
def promote_errors(child_errors)
child_errors.each do |attribute, message|
errors.add(attribute, message)
end
end
end
That’s a pretty simple form object because it’s only concerned with the save action and exposes very few fields. It does however serve to illustrate some of the tricky bits of form objects that only get trickier as the form object increases in complexity.
** Validations **
How should you handle these? Should you delegate to the child objects as we have done in the registration or should the validations exist exclusively on the Model? Our feeling was that any validations that were exclusive to the form object (something the models might not care at all about but you care about in the context of this form) should live on the form object. We liked the idea of validations core to the model continuing to live on the model. That leaves two possible solutions for handling those in your form objects:
- delegate valid and promote like we did in the registration.
- Extract a validator object with the common validations and use it in both locations.
If you promote errors, what happens if an attribute not on your form (say, profile.first_name) becomes required in the base model? Now you will get an error trying to promote that error up to the parent form object because that attribute does not exist on the form object.promote_errors
would have to be smart enough to move these to :base
in some logical manner.
** Delegation overload **
Our example form object doesn’t have this problem, but in cases where you are dealing with existing, initialized objects passed in, you end up having to delegate a lot of methods (both accessors and potentially writers). We looked at one of the complicated examples of this that I won’t share here, but the solution there was to use method_missing
to make the form object both a decorator and a form object. ActiveModel requiring initialize to take a hash made this more complicated than I wanted the process to be.
** Existing Libraries **
Form objects have been a fairly popular topic in the rails community lately so there’s been a few shots taken at libraries to make them simpler. We looked at these and felt they had some nice bits but had parts we also didn’t like. They either required writing the save logic in the controller or writing all the validations on the form object. We felt it might be worth taking a stab at doing a library that extracted the simple type of form object as shown above to see if that’s a pattern that is effective, reusable and expandable. It could handle the error promotion and perhaps have some niceties around defining the composite/child objects.