iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔖

Notes on Form Objects in Ruby on Rails

に公開

I needed to create and update up to grandchild models in a single process for work, and I somehow managed to write it with messy code.

In the future, I want to write as concisely as possible, so I decided to summarize Form Objects.

What is a Form Object?

It is used to implement use cases that are fulfilled by using form_with.

Why use it?

In Rails, resource-based routing using the resources method is the standard. It maps resources represented by URLs one-to-one with database tables and assumes interaction with users through these CRUD operations.

However, there are cases where this approach alone does not work well, such as the following:

  1. When CRUD operations are performed in multiple use cases
    For example, user registration may be done via a form or from a CSV file. In the former case, it is necessary to check the value of a checkbox for agreeing to the terms of use, but in the latter case, there is no such need. In such scenarios, trying to handle both can lead to problems like bloated models or overly complex callbacks.

  2. When registration or update operations are required across multiple models
    Consider a case where operations on a child model are required when an operation is performed on a parent model. In this case, the accepts_nested_attributes_for method is a candidate. However, this method has a very poor reputation, and its use is sometimes even prohibited.

https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef

https://moneyforward.com/engineers_blog/2018/12/15/formobject/

  1. When no corresponding table exists
    For example, in use cases such as creating or deleting cookie-based sessions, no corresponding model exists. While the logic could be implemented in other models or controllers, the code would become bloated. Other expected cases include requests to Elasticsearch.

To express it more abstractly, it is described as in the following article:

https://applis.io/posts/rails-design-pattern-form-objects#formオブジェクトの必要性

If you perform the formatting and persistence of user input only within the controller, the controller will become bloated. The reason for this is that the controller gains too much knowledge about the model layer. At this point, the view also gains knowledge for displaying the form, leading to similar problems as the controller. This violates the Single Responsibility Principle, and changes in the model layer will affect the controller and view.
Conversely, if you give these responsibilities to an ActiveRecord model, then the ActiveRecord model will have too much knowledge of the form. If there is an independent responsibility like a form, the role of a Form Object is to encapsulate this into a single class.

Usage Example

Consider an example of saving everything up to grandchild elements.
Suppose there are multiple services, and a user possesses multiple rules for setting service permissions (read/write).

The tables and ER diagram are as follows:

Table Column Type
rules id integer
rule_name varchar
authorities id integer
rule_id integer
authority integer
authority_service_relations id integer
authority_id integer
service_id integer
services id integer
service_name varchar

This example has been verified to work with Ruby 2.7.5 and Ruby on Rails 6.1.6.

Form Object

app/forms/rule_form.rb
class RuleForm
 include ActiveModel::Model
 include ActiveModel::Attributes
 
 attribute :rule_name, :string
 attribute :read
 attribute :write
 
 validates :rule_name, presence: true, length: { in: 1..30 }
 
 delegate :persisted?, to: :rule
 
 def initialize(attributes = nil, rule: Rule.new)
  @rule = rule
  attributes ||= default_attributes
  super(attributes)
 end
 
 def save
  return if invalid?

  ActiveRecord::Base.transaction do  

    rule.authorities.destroy_all
    if read[:services]&.any?
      read_authority = rule.authorities.build(authority: Authority.authorities[:read] )
      read[:services].compact_blank!
      read[:services].each do |read_service|
        read_authority.authority_service_relations.build(service_id: read_service)
      end
    end
    
    if write[:services]&.any?
      write_authority = rule.authorities.build(authority: Authority.authorities[:write])
      write[:services].compact_blank!
      write[:services].each do |write_service|
        write_authority.authority_service_relations.build(service_id: write_service)
      end
    end
    rule.update!(rule_name: rule_name)
  end

  rescue ActiveRecord::RecordInvalid
    false
 end
 
 def to_model
  rule
 end
 
 private

    attr_reader :rule
    
    def default_attributes
      {
        rule_name: rule.rule_name,
        read: { services: rule.authorities.read.joins(:services) },
        write: { services: rule.authorities.write.joins(:services) },
      }
    end
end  
  

Here is an explanation of the code.
include ActiveModel::Model is a module that bundles part of the module group provided by Active Model. By combining multiple modules, it provides the interface necessary for interaction with controller and view methods.

https://guides.rubyonrails.org/active_model_basics.html#model-modules

include ActiveModel::Attributes is a module that allows the use of the attribute method to define attributes. In the attribute method, the first argument specifies the attribute name and the second argument specifies the type name.

https://guides.rubyonrails.org/active_model_basics.html#attribute-methods-module

Note that while this code was verified with Rails 6 as mentioned before, in Rails 7, ActiveModel::Attributes is now automatically included when you include ActiveModel::Model.

https://techracho.bpsinc.jp/hachi8833/2022_01_28/114954

delegate is a method related to delegation. By delegating persisted? from the rule model, it automatically switches the form action between POST and PATCH when submitting with form_with in the view.

initialize initializes the values of the Form Object. I found the following article, which I also cited earlier, to be very informative on this topic.

https://applis.io/posts/rails-design-pattern-form-objects#form-object

to_model is a method required for the view display (form_with). It switches the action URL to the appropriate path (in this case, rules_path or rule_path(id)).

Controller

app/controllers/rules_controller.rb
class RulesController < ApplicationController
  def index
    @rules = Rule.all
  end

  def new
    @form = RuleForm.new
  end

  def create
    @form = RuleForm.new(rule_params)

    if @form.save
      redirect_to rules_path
    else
      render :new
    end
  end

  def edit
    load_rule

    @form = RuleForm.new(rule: @rule)
  end

  def update
    load_rule
    @form = RuleForm.new(rule_params, rule: @rule)

    if @form.save
      redirect_to rules_path
    else
      render :edit
    end
  end

  private

  def rule_params
    params.require(:rule).permit(:rule_name, 
      read: {
        services: []
        },
      write: {
        services: []
        }
      )
  end

  def load_rule
    @rule = Rule.find(params[:id])
  end
end

View

app/views/rules/new.html.erb
<%= form_with model: @form, local: true do |form| %>
  <%= form.text_field :rule_name %>
  <%= form.fields_for :read do |read| %>
    <%= read.collection_check_boxes :services, Service.all, :id, :service_name %>
  <% end %>
  <%= form.fields_for :write do |write| %>
    <%= write.collection_check_boxes :services, Service.all, :id, :service_name %>
  <% end %>
  <%= form.submit %>
<% end %>
app/views/rules/edit.html.erb
<%= form_with model: @form, local: true do |form| %>
  <%= form.text_field :rule_name %>
  <%= form.fields_for :read do |read| %>
    <%= read.collection_check_boxes :services, Service.all, :id, :service_name,  checked: @form.read[:services].pluck(:service_id) %>
  <% end %>
  <%= form.fields_for :write do |write| %>
    <%= write.collection_check_boxes :services, Service.all, :id, :service_name,  checked: @form.write[:services].pluck(:service_id) %>
  <% end %>
  <%= form.submit %>
<% end %>

Model

app/models/rule.erb
class Rule < ApplicationRecord
  has_many :authorities

  validates :rule_name, presence: true, length: { in: 1..30 }
end
app/models/authority.erb
class Authority < ApplicationRecord
  belongs_to :rule
  has_many :authority_service_relations
  has_many :services, through: :authority_service_relations
  enum authority: { read: 1, write: 2 }
end
app/models/service.erb
class Service < ApplicationRecord
  has_many :authorities, through: :authority_service_relation
end
app/models/authority_service_relation.erb
class AuthorityServiceRelation < ApplicationRecord
  belongs_to :authority
  belongs_to :service
end

Thoughts

Creating a Form Object makes the code very readable because all form-related logic is centralized in one place. When I implemented this myself before, I ended up writing even the grandchild model's save logic within the top-level parent model, which made it hard to read, so I'm very grateful for this pattern.

Also, I hadn't really been conscious of things like the automatic switching between POST and PATCH, but I was able to deepen my understanding through the experience of implementing it manually.

On the other hand, because I rely so much on Rails, I struggled with how to handle pure Ruby classes. I would like to deepen my understanding of that as well in the future.

References and Articles

Perfect Ruby on Rails (Revised Edition)

Rails Design Patterns: Form Objects

GitHubで編集を提案

Discussion