iTranslated by AI
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:
-
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. -
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, theaccepts_nested_attributes_formethod is a candidate. However, this method has a very poor reputation, and its use is sometimes even prohibited.
- 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:
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
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.
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.
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.
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.
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
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
<%= 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 %>
<%= 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
class Rule < ApplicationRecord
has_many :authorities
validates :rule_name, presence: true, length: { in: 1..30 }
end
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
class Service < ApplicationRecord
has_many :authorities, through: :authority_service_relation
end
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.
Discussion