Shorten validation code in Rails Application

Shorten validation code in Rails Application

In any application, there are chances that we keep repeating the same validation logic over the time. Specifically when those kind of validations are common type like email validation, required validation... or when it belong to business domain

Rails can help you tackle this problem easily. It is bundled with a lot of "tools" that help you to DRY (Don't Repeat Yourself) your code. Today, I will share with you one of its good practices using Validator classess

Validation target

In a typical pattern, Rails application validation logic usually locates in 2 places: model classes and parameter classes (which represents data sent from client side).
This post shows you a practice in parameter class. However, the same strategy can apply into model classes as well.

Typical parameter class

For example we have a simplified parameter class named StaffForm, to contains data from client side, sent via APIs. There are 3 properties inside, including: email, join_date, quit_date. Each of them need some kind of validations. In the real world, there may be more, but not necessary to this post :)

In general, below is how it usually looks like:

  • Include necessary module: ActiveModel::Model or ActiveModel::Validations
  • Validation logic
  • Input data initialization
class StaffForm
  include ActiveModel::Model

  validate :join_date_before_quit_date
  validate :email_must_belong_company

  def initialize(params = {})
    @params = params
  end

  private

  def join_date_before_quit_date
    errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
  end

  def email_must_belong_company
    errors.add(:email, 'This email domain is not supported') unless email.match?(/[a-z0-9]+@[a-z]+.company.com/)
  end

  def join_date
    @params[:join_date]
  end

  def quit_date
    @params[:quit_date]
  end

  def email
    @params[:email]
  end
end

In code above, we include all of validation logic into the class so for the partially similar paramters, we need to write the same code again.

class PartTimeStaffForm
  include ActiveModel::Model

  validate :join_date_before_quit_date
  validate :email_must_belong_company

  def initialize(params = {})
    @params = params
  end

  private

  def join_date_before_quit_date
    errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
  end

  # here is the different --------------
  def email_must_belong_company
    return if email.match?(/[a-z0-9]+@parttime.company.com/) || email.match?(/[a-z0-9]+@seasonal.company.com/)

    errors.add(:email, 'This email domain is not supported')
  end

  def join_date
    @params[:join_date]
  end

  def quit_date
    @params[:quit_date]
  end

  def email
    @params[:email]
  end
end

EachValidator class

The idea is to separate validation logic out, divide into single responsible validators, make it easier to reuse. For individual property, we use EachValidator class.
Any class inherited from ActiveModel::EachValidator and has "Validator" postfix in name can be used with "validates" method.

class CompanyEmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    record.errors.add(:email, 'This email domain is not supported') unless email.match?(/[a-z0-9]+@[a-z]+.company.com/)
  end
end

Then in parameter class, we remove the corresponding validation logic and use our new validator with validates method. The "company_email" value in company_email: true is a part of our new validator name

class StaffForm
  include ActiveModel::Model

  validate :join_date_before_quit_date
  validates :email, company_email: true # <- change here

  def initialize(params = {})
    @params = params
  end

  private

  def join_date_before_quit_date
    errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
  end

  def join_date
    @params[:join_date]
  end

  def quit_date
    @params[:quit_date]
  end

  def email
    @params[:email]
  end
end

But it's not done yet. In case of part time employee, the subdomain of email address is limited to "parttime.company.com" or "seasonal.company.com". So instead create a new validator, we can use option to make it more flexible

class CompanyEmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    if options[:subdomain].blank?
      record.errors.add(:email, 'This email domain is not supported') unless value.match?(/[a-z0-9]+@[a-z]+.company.com/)
    else
      return if options[:subdomain].any? { |s| value.match?(/[a-z0-9]+@#{s}.company.com/) }

      record.errors.add(:email, 'This email domain is not supported')
    end
  end
end
class StaffForm
  ...
  validates :email, company_email: true
  ...
end
class PartTimeStaffForm
  ...
  validates :email, company_email: { subdomain: ['parttime', 'seasonal'] }
  ...
end

validates_with

Our code now is much more concise with above approach but there is still a problem. When a validation involves many properties, it doesn't seem to be make sense to use validates. Obviously, you can get any value of the parameter class's instance with record variable in validate_each method. However it's hard to understand and maintain as well because of the validation belongs to no property. For this case, validates_with is the perfect choice.

A validator which is used with validates_with must inherit ActiveModel::Validator and declare validate method. This method accepts a parameter class instance and pass it via "record" variable.

class ContractValidator < ActiveModel::Validator
  def validate(record)
    record.errors.add(:quit_date, 'Quit date must be after join date.') if record.quit_date <= record.join_date
  end
end

Next we remove the old validation logic in parameter class and call validates_with method instead. However, as we want to access join_date and quit_date in ContractValidator, so we need to expose those 2 publicly. So the final version looks like this

class StaffForm
  include ActiveModel::Model

  validates_with ContractValidator
  validates :email, company_email: true

  def initialize(params = {})
    @params = params
  end

  def join_date
    @params[:join_date]
  end

  def quit_date
    @params[:quit_date]
  end

  private

  def email
    @params[:email]
  end
end
class PartTimeStaffForm
  include ActiveModel::Model

  validates_with ContractValidator
  validates :email, company_email: { subdomain: ['parttime', 'seasonal'] }

  def initialize(params = {})
    @params = params
  end

  def join_date
    @params[:join_date]
  end

  def quit_date
    @params[:quit_date]
  end

  private

  def email
    @params[:email]
  end
end

Thank you! :)