Reusable validations in Rails Form Objects

gravatar

If you’re using “Form Objects” to not keep validations in your ActiveRecord model, you might find yourself challenged when trying to reuse custom validators, that are common across few classes.

Imagine code that validates and processes purchases by different payment providers. In the end, they are doing the same thing, but in a different way, thus we have separate classes for handling each of them.

class StripePurchase
  include ActiveModel::Model

  attr_accessor :token

  validates :token, presence: true

  def call
    return false if invalid?
    # Process Stripe payment
  end
end
class PayPalPurchase
  include ActiveModel::Model

  attr_accessor :success_url, :failure_url

  validates :success_url, :failure_url, presence: true

  def call
    return false if invalid?
    # Process PayPal payment
  end
end

User is purchasing the product, so we need to check if it exists.

This validation is exactly the same for both providers.

class ValidateProduct
  include ActiveModel::Model

  attr_accessor :product_id

  validate :product_presence
  validate :product_availability

  private

  def product_presence
    errors.add(:product_id, :invalid) unless Product.where(id: product_id).exists?
  end

  def product_availability
    errors.add(:product_id, :not_available) unless Stock.where(product_id: product_id).exists?
  end
end

To use it, you simply write:

class StripePurchase
  include ActiveModel::Model

  attr_accessor :token, :product_id

  validates :token, presence: true

  def call
    validate_product = ValidateProduct.new(product_id: product_id)
    unless validate_product.valid?
      define_singleton_method(:errors) { validate_product.errors }
      return false
    end

    return false if invalid?
    # Process Stripe payment
  end
end

While purchasing, user can input a voucher that will discount a price, but we need to check if it actually exists and is available for the given product.

class ValidateVoucher
  include ActiveModel::Model

  attr_accessor :voucher, :product_id

  validate :voucher_presence

  private

  def voucher_presence
    errors.add(:voucher, :invalid) unless Voucher.where(code: voucher, product_id: product_id).exists?
  end
end

Now you can use them both with the benefit that when the first validation fails, you don’t process another one, because it cannot succeed. When there is no product, then for sure there is no discount for it.

class StripePurchase
  include ActiveModel::Model

  attr_accessor :token, :product_id, :voucher

  validates :token, presence: true

  def call
    validate_product = ValidateProduct.new(product_id: product_id)
    unless validate_product.valid?
      define_singleton_method(:errors) { validate_product.errors }
      return false
    end

    validate_voucher = ValidateVoucher.new(voucher: voucher, product_id: product_id)
    unless validate_voucher.valid?
      define_singleton_method(:errors) { validate_voucher.errors }
      return false
    end

    return false if invalid?
    # Process Stripe payment
  end
end

This way you can compose your form objects from small pieces and reuse them in different places.