DEV Community

loading...

Growing Rails - Utilizing Form Models for complex validations or side effects

zealot128 profile image Stefan Wienert Originally published at stefanwienert.de on ・4 min read

Rails by default has a couple of “buckets” to put your code into. Some people claim that this is not sufficient when building larger apps, other people hold against, that you could upgrade your “model” directory and put all kinds of Ruby POROs (Plain Ruby Objects) into it. I am not totally convinced to mix database-backed models and all kinds of different objects, but rather like to identify common patterns and create subdirectories directly under app/, like: app/queries, app/api, app/api_clients or the bespoken Form Models under app/forms.

Advantages of Form Models

Validations

The longer I work with Rails, and the longer or larger the project horizon is planned, the less I like to use complex validations on the model. Why? Because in my experience, the validations only make sense, when the model is created by a user through a UI. But not all objects are created by a UI. Think of:

  • Ad-hoc creation of small objects in development, test
  • Cronjobs that create or update a model and fail because one item has gone invalid because a validation / column has been introduced after the creation of the item itself - this happens quite often IME
  • different validations depending on the state of the user (Form wizards, registration vs. updating profile, etc.)

Side Effects

The second big win of form models are side effects , like sending a email/notification, enqueuing jobs, creating log/audit records, update elasticsearch, etc.pp. Doing those in the controller is maybe feasible but it can go out of hand very fast. Doing those in callbacks is IMO a very bad practice: Thinking about backfilling some attributes, but accidentally sending a notification to all. You always have to know, which side effects are present, even when updating in the background. So, I think the save method of a Form Object is a perfect place to kick off various actions.

Database-less actions

Also, you sometimes have actions that not necessarily have a database table attached, think of: CSV export (with config), Providing a test action for an integration (Webhook test, IMAP integration, SAML integration, …). Those are perfect candidates for Form Models!

Controller does not need to know the model’s attributes

Another advantage, which I later found out about, is that I can get rid of the permitted_params / params.require stuff from the controller (which is there rightly so to prevent Mass Assignment Injections). Because our form model can only reveal the attributes which the user can update anyways, we can build a very simple wrapper, that automatically permits all attributes of the form model. I really like that, because now the controller does not have to know about the model’s fields – How often did you forgot to add a missing attribute to the permit(..) method?

Our Form base class

Over the years, our base class changed. One thing I want of a Form Model, is Parameter Coercion (e.g. casting “1” to true for a boolean). In the past, we used the virtus Gem to handle the definition of the attributes and coercion. But recently, after Rails released the Attributes API, we can just use ActiveModel::Attributes.

# app/forms/application_form
class ApplicationForm
  # ActiveModel: We get validations, model_name stuff, etc.
  # now our object quaks almost like an ActiveRecord::Base model
  include ActiveModel::Model

  # Gives us the `attribute `` method to define attributes with data types:
  # attribute :email, :string,
  # attribute :active, :boolean, default: true
  include ActiveModel::Attributes

  # Helper Method to call from the controller:
  #
  # MyForm.new_with_permitted_params(params)
  #
  # It fetches the correct key, e.g. params.require(:my_form).permit(:a, :b, c: [], d: {})
  def self.new_with_permitted_params(params)
    permitted_params = params.
      require(model_name.param_key).
      permit(*permitted_params_arguments)
    new(permitted_params)
  end

  # Maps the defined `attributes` to a argument list for params.permit()
  # Array and Hash attribues must be written in hash form.
  def self.permitted_params_arguments
    structures, primitives = attribute_types.
      map { |name, type|
        if type == :array
          { name => [] }
        elsif type == :hash
          { name => {} }
        else
          name
        end
      }.partition { |i| i.is_a?(Hash) } # rubocop:disable Style/MultilineBlockChain
    params = [*primitives, structures.reduce(&:merge)].reject(&:blank?)
    if params.length == 1
      params.first
    else
      params
    end
  end

  # placeholder to implement by the inherited form instances
  def save
    return false unless valid?

    raise NotImplementedError
  end
end

Enter fullscreen mode Exit fullscreen mode

Example Usage

Imagine you are for once not using Devise and implementing Password Reset yourself.

# app/forms/password_reset_form.rb
class PasswordResetForm < ApplicationForm
  attribute :email, :string
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }

  def save
    return unless valid?

    account = User.where(email: email).first
    unless account
      sleep 1
      return true
    end

    account.regenerate_reset_password_token_if_not_active
    Mailer.password_reset(account).deliver_now
    true
  end
end

Enter fullscreen mode Exit fullscreen mode

As the model is 100% compatible with a ActiveRecord Model, we can use it the same in the controller:

class PasswordResetController < ApplicationController
  def new
    @form = PasswordResetForm.new
  end

  def create
    @form = PasswordResetForm.new_with_permitted_params(params)
    if @form.save
      redirect_to root_path, notice: "E-Mail instructions have been sent"
    else
      render :new
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Now, if we get the requirement to log account activities (audit trail), the save method is a perfect place to continue. For this purpose, I usually define “normal” attribute accessors that the controllers fill. Those fields will not be available through the permitted params sieve and are safe for this purpose.

# ...
  def create
    @form = PasswordResetForm.new_with_permitted_params(params)
    @form.request = request
    ...
  end

###

class PasswordResetForm < ApplicationForm
  # ...
  attr_accessor :request

  def save
    #...
    account.activities.create(ip: request.ip, user_agent: request.user_agent)
    #.
  end

Enter fullscreen mode Exit fullscreen mode

Hope that helps you in your organisation of form models! For us, those are a frequently used pattern.

Discussion (0)

pic
Editor guide