Reusable validations in Rails Form Objects
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.