/ Rails

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.