There’s one question that comes up soon after starting to use form objects:
Where should I place my validations? In the model or in the form object?
By default validations live in the model.
But if the form object has no validations then it does not validate user input! We decide to move the validations from the model to the form object.
But now it’s possible to create an invalid model in some other part of the code! We decide to copy the validations and add them to both the model and the form object.
But now we are duplicating code! It’s likely that these validations will diverge, that some developer will change some validation in one place and not in the other. We decide to extract the validations into a module (or a concern) and then include it in both the model and the form object.
But this spreads the code over another file and adds another level of indirection — when we open up the model file it’s not immediately obvious what its validations are.
There should be a better solution.
We can place most of the validations (more on this later) on the models. On the form object we can delegate down validations to each “child model” and promote up any errors found in the models.
Let’s check an example of a form object:
class Registration
include ActiveModel::Model attr_accessor :email, :password, :country, :city def save
ActiveRecord::Base.transaction do
user.save!
location.save!
end
end private def user
@user ||= User.new(email: email, password: password)
end def location
@location ||= user.build_location(country: country, city: city)
end
endWe can reuse the validations of user and location like this:
class Registration
# ... validate :validate_children def save
return false if invalid? # ...
end private def validate_children
if user.invalid?
promote_errors(user.errors)
end if location.invalid?
promote_errors(location.errors)
end
end def promote_errors(child_errors)
child_errors.each do |attribute, message|
errors.add(attribute, message)
end
end
endCalling invalid? in the save method runs all the validations, including the validate_children method. Since there’s no easy way to move all the errors from one object to another, we iterate over all the errors and add them one by one to the form object.
We can extract some of this logic into a base FormObject (or a concern) if we start using this pattern a lot.
Still, there are some validations that should live in form objects.
I like to divide validations into two groups: data integrity validations and business logic validations.
Data integrity validations are concerned with the fidelity and quality of the data saved to the database. All locations must have non-empty country and city attributes so this should be validated in the model:
class Location < ApplicationRecord
validates :country, presence: true
validates :city, presence: true
endThese are database rules. Ideally these rules are mirrored on the database via its schema and its constraints:
ALTER TABLE locations
ALTER COLUMN country SET NOT NULL,
ALTER COLUMN city SET NOT NULL;On the other hand, business logic validations are concerned with the appropriateness and completeness of the data going through a certain workflow. The email registration workflow requires users to enter an email and accept the terms of service so this should be validated in the form object:
class EmailRegistration
include ActiveModel::Model validates :email, presence: true
validates :terms_of_service, acceptance: true
endThese are contextual rules. These rules only apply to this particular use case. Think of how much harder it would be to create a phone registration workflow if the email validation lived in the User model.
Contextual rules enforced on the model level are global rules.
Whenever you find yourself needing to skip a validation in certain situations or needing to configure when a validation should run with the :on option, try to see if there’s a way to extract that validation and that logic into a form object.
We now want to ensure that emails entered in the email registration workflow are unique. Since the email presence validation lives in the EmailRegistration form object we’d be inclined to add the new validation there:
class EmailRegistration
include ActiveModel::Model validates :email, presence: true, uniqueness: true
validates :terms_of_service, acceptance: true
endYet, if the user is able to change his email later on, he can pick an email that is already taken. We don’t want this to happen. This means that if a user has an email then it must be unique. This is a data integrity validation and it should live in the model:
class User < ApplicationRecord
validates :email, uniqueness: true, allow_blank: true
endLearning to distinguish between these two types of validations is essential to the design and structure of our applications.
Conclusions
- Don’t mix data integrity validations with business logic validations.
- Place data integrity validations inside models.
- Place business logic validations inside form objects.
- Promote model errors to form object errors.
Now go on and create your own form objects. Add contextual validations. Simplify your code!
Have you had trouble validating form objects? How did you solve your use case? We’d love to hear from you! Comment below with your experiences!
At Runtime Revolution we take our craft seriously and always go the extra mile to deliver a reliable, maintainable and testable product. Do you have a project to move forward or a product you’d like to launch? We would love to help you!