Complex form objects with Rails
The problem I wanted to solve
I have been using REST for a long time, but some times it does not fit my applications needs. Sometimes while creating a form you have to pass nested attributes and it ends up as a total disaster.
What are form objects?
Form objects let you manage an operation easily, for instance: Update a user
with its social_networks
.
But why form objects?
- Clean controllers
- Business logic stays in the right place
- Validations should belong to form objects not AR models
- Nicely display errors on forms
Note: If you have built large Rails applications you will notice that adding validations and rules to your models will limit the ability to modify your models in the future, that's why forms objects are a great solution for this issue. Now you can have different rules (validations) for different actions on your app, and your models will not be limited by validations anymore.
Building Complex form objects in Rails
We are going to describe a common scenario...
Update an active record model with a has many association
Say you have a form where you want to update the user name, and its social networks.
Keeping in mind that our main concern it's our validations, we don't want to write any validation in our models because we already know all the inconveniences.
The solution we will to add the validations that are extrictly necessary for this requirement, which is "update the user name with its social networks".
app/forms/update_user_with_social_networks_form.rb
class UpdateUserWithSocialNetworksForm
include ActiveModel::Model
attr_accessor(
:success, # Sucess is just a boolean
:user, # A user instance
:user_name, # The user name
:social_networks, # The user social networks
)
validates :user_name, presence: true
validate :all_social_networks_valid
def initialize(attributes={})
super
# Setup the default user name
@user_name ||= user.name
end
def save
ActiveRecord::Base.transaction do
begin
# Valid will setup the Form object errors
if valid?
persist!
@success = true
else
@success = false
end
rescue => e
self.errors.add(:base, e.message)
@success = false
end
end
end
# This method will be used in the form
# remember that `fields_for :social_networks` will ask for this method
def social_networks_attributes=(social_networks_params)
@social_networks ||= []
social_networks_params.each do |_i, social_network_params|
@social_networks.push(SocialNetworkForm.new(social_network_params))
end
end
private
# Updates the user and its social networks
def persist!
user.update!({
name: user_name,
social_networks_attributes: build_social_networks_attributes
})
end
# Builds an array of hashes (social networks attributes)
def build_social_networks_attributes
social_networks.map do |social_network|
social_network.serializable_hash
end
end
# Validates all the social networks
# using the social network form object,
# which has its own validations
# we are going to pass those validations errors
# to this object errors.
def all_social_networks_valid
social_networks.each do |social_network|
next if social_network.valid?
social_network.errors.full_messages.each do |full_message|
self.errors.add(:base, "Social Network error: #{full_message}")
end
end
throw(:abort) if errors.any?
end
end
app/forms/social_network_form.rb
class SocialNetworkForm
include ActiveModel::Model
include ActiveModel::Serialization
attr_accessor(
:id,
:url
)
validates :url, presence: true
def attributes
{
'id' => nil,
'url' => nil
}
end
end
app/controllers/update_users_with_social_networks_controller.rb
class UpdateUserWithSocialNetworksController < ApplicationController
before_action :build_form, only: [:update]
def edit
@form = UpdateUserWithSocialNetworksForm.new(user: current_user)
@social_networks = current_user.social_networks
end
def update
@form.save
if @form.success
redirect_to root_path, notice: 'User was successfully updated.'
else
render :edit
end
end
private
def permitted_params
params
.require(:update_user_with_social_networks_form)
.permit(:user_name, social_networks_attributes: [:id, :url])
end
def build_form
@form = UpdateUserWithSocialNetworksForm.new({
user: current_user,
user_name: permitted_params[:user_name],
social_networks_attributes: permitted_params[:social_networks_attributes]
})
end
end
app/views/update_user_with_social_networks/edit.html.erb
<%= form_for(@form, url: update_user_with_social_networks_path, method: :put) do |f| %>
<% if @form.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@form.errors.count, "error") %> prohibited this User from being saved:</h2>
<ul>
<% @form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :user_name %>
<%= f.text_field :user_name %>
</div>
<%= f.fields_for :social_networks, @social_networks do |sn_form| %>
<div class="field">
<%= sn_form.label :url %>
<%= sn_form.text_field :url %>
</div>
<% end %>
<div class="actions">
<%= f.submit "Update" %>
</div>
<% end %>
config/routes.rb
Rails.application.routes.draw do
root 'update_user_with_social_networks#edit'
resource :update_user_with_social_networks, only: [:edit, :update]
end
Notes
- This form object will not handle creating associations
- This form object will not handle the association deletion (mark of destruction
_destroy
)
Key learnings
As you can see the code isn't very nice, in order to customize complex form objects you will need to hack Rails a bit, just like I showed in this blog.
Tips and advice
There is a gem which takes forms objects to a new level (it handles nested associations and more) Reform
Final thoughts and next steps
While form objects are a great design pattern they could turn really complex depending on your business logic.
The example ilustrated above is something I will never do in real life since it is a bad idea to save one resource and multiple resources in one go, ideally you make one request to update one resource first and then make another request to update other resource(s). So, this is just showing the power of form objects, which can be coded in many ways to achieve different goals.
GraphQL has a huge advantage over REST and form objects since you can use mutations and add custom validations to your actions, the downside is that you will need a client to consume the GraphQL API, and Rails does not comes with it (Yet).
Here you can find the Github repository of this blog post.
This are facts:
Hey, love the insight. A couple of points though:
update_user_with_social_networks
, I mean shouldn’t we follow REST?REST is not always the answer. Similar to slice a cake with an axe. validations on the model are always hard to scale (they will kick you in the face soon or later). No, don’t try rescuing the model errors in the form objects (is a massive mess, specially when handling associations errors).
Hi, would like to understand why you are not recommending to put validations in model? For example, when i need to create a new object in a rake task, without the validations and form object, it is not possible to verify the object is valid or not right?
See https://www.codementor.io/victor_hazbun/complex-form-objects-in-rails-qval6b8kt?utm_swu=2227#comment-w45o4zhwv
And for the rake tasks, use a form object instead of the model directly, put the validations in the form object instead.