Dealing With Form Objects in Rails
Let's say - we have the following nice clean model for our new application:
class Label < ActiveRecord::Base
belogns_to :user, required: true
validates :name, presence: true, uniquness: true
end
Several months later, we have the requirement to add quote
and description
to Label
and those attributes should be mandatory. So we update Label
:
class Label < ActiveRecord::Base
belogns_to :user, required: true
validates :name, presence: true, uniquness: true
validates :quote, :description, presence: true
end
Now we have a big problem. All of our previous Label
records are invalid. Because those new attributes didn't exist an hour ago. Also quote
and description
are not the kind of fields we add good default values.
So we revert this change and start thinking about a better way to handle the situation.
When a little more thought is put into this - we find that we need this validation to be applied in two places - my/labels/create
and my/labels/update
pages.
One pattern I have seen used for dealing with this is the following:
class Label < ActiveRecord::Base
belogns_to :user, required: true
validates :name, presence: true, uniquness: true
validates :quote, presence: true, if: :validating_as_designer?
validates :description, presence: true, if: :validating_as_designer?
def update_as_designer(attributes = {})
@validating_as_designer = true
update_attributes(attributes).tap do
@validating_as_designer = false
end
end
private
def validating_as_designer?
@validating_as_designer
end
end
In a newish model, this approach doesn't look so bad.
But this becomes unmaintainable easy. The main issue here is that we are adding form specific logic into a domain model. Now this model knows about certain pages. In my experience, such things tend to happen for the core modals of the application, which are bigger by nature.
Also, I have seen situations where a new developer enters the project, see that update_as_designer
is used and decided that "this is the way they do stuff here" and adds update_as_admin
.
So what is a better way to deal with the issue? I tend to favor the extraction of form objects:
class DesignerForm
include ActiveModel::Model
attr_accessor :label, :user, :name, :quote, :description
validates :user, presence: true
validates :name, presence: true
validates :quote, presence: true
validates :description, presence: true
def initialize(label = Label.new)
@label = label
end
def persisted?
@label.persisted?
end
def update(attributes)
attributes.each do |name, value|
public_send "#{name}=", value
end
if valid?
@label.update attributes
else
false
end
end
end
That sure looks too complicated! I can understand why people decide to use the "nice short method" instead of this big bulky object. But this is just our starting point.
Let's apply some minor modules; I use in almost every project I'm working on.
class DesignerForm
include ActiveModel::Model
include ActiveModel::Attributes
attr_reader :label
delegate :persisted?, to: label
# ActiveModel::Attributes method:
# share attributes between form object and label
attributes :user, :name, :quote, :description, delegate: :label
# ValidValidator:
# runs label validations and copies the errors to the form
validates :label, valid: true
# this is form specific validations we need
validates :quote, presence: true
validates :description, presence: true
def initialize(label = Label.new)
@label = label
end
def update(attributes)
# ActiveModel::Attributes method
self.attributes = attributes
if valid?
@label.save!
true
else
false
end
end
end
That is a lot better. Still not perfect. In my experience after having 2-3 form objects in the application - more and more common functionally is extracted. In the end, most of the form objects become something like following:
class DesignerForm
include BaseForm
model :label, attributes: %i(user name quote description)
validates :quote, presence: true
validates :description, presence: true
end
I don't start with BaseForm
because every project has slightly different requirements for handling forms.
p.s. As expected - there a several gems which provide nice apis for form objects. I don't use any of them.