Codementor Events

Complex form objects with Rails

Published Jan 04, 2019Last updated Jun 20, 2019

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.

Discover and read more posts from Victor H
get started
post commentsBe the first to share your opinion
Victor H
5 years ago

This are facts:

  • REST is not always the answer to all problems
  • Form objects have limitations
  • If your form object is growing out of control in complexity then try to delegate the complexity to a different class, maybe you need to make your request lighter so it does not update a lot of resources at the same time.
  • GraphQL solves REST and Form objects issues but it introduces a lot of complexity and most important: Not all apps are that complex to implement this technique.
Agustinus Verdy Widyawiradi
5 years ago

Hey, love the insight. A couple of points though:

  • Is it a good idea to have an endpoint like update_user_with_social_networks, I mean shouldn’t we follow REST?
  • Same question to Pei Yee Teh, if you put all validations in the form, does that mean we can’t use the model on its own? I guess we can still leave the validation related to individual model to the model itself and manually bubble up the errors to the Form object
Victor H
5 years ago

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).

Pei Yee Teh
5 years ago

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?

Victor H
5 years ago

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.

Show more replies