Dry Schema: Decouple Validations from Rails Models
Standard Ruby on Rails best practices suggest that we should define our validations on the model object. RoR gives you the tool, aka DSL (domain specific language), to implement these validations. For simple situations, say a signup form, this works really well, but what about more complicated scenarios? What if your model serves several different controllers? Or what if, for example, different types of users could submit different values to the same model? Do we want to use messy if
blocks to check the user
type and apply the correct set of validations? Probably not. So what’s the solution? Enter Dry-schema.
First, let’s look at what comes out of the box with rails:
class User
validates :email, presence: true
validates :password, confirmation: true
end
It’s simple enough and works well too. Now lets consider another scenario:
Let’s say you have a somewhat complex model which requires you to do multiple “steps” or page submits to gather the data you need. A simple Google search yields Developing a wizard or multi-steps forms in Rails by Nicolas Blanco. Here I summarize his article:
First, create a model, in this case User
, and define all the persistence validations:
class User < ApplicationRecord
validates :email, presence: true, format: { with: /@/ }
validates :name, presence: true
validates :city, presence: true
validates :country, presence: true
validates :phone_number, presence: true
end
Then create a separate class for each of the wizard steps each of which encapsulates the validations for that step:
module Wizard
module User
STEPS = %w(step1 step2 step3 step4).freeze
class Base
include ActiveModel::Model
attr_accessor :user
delegate *::User.attribute_names.map { |attr| [attr, "#{attr}="] }.flatten, to: :user
def initialize(user_attributes)
@user = ::User.new(user_attributes)
end
end
class Step1 < Base
validates :email, presence: true, format: { with: /@/ }
end
class Step2 < Step1
validates :name, presence: true
end
class Step3 < Step2
validates :city, presence: true
validates :country, presence: true
end
class Step4 < Step3
validates :phone_number, presence: true
end
end
end
And the controller, which uses a session variable to pass arguments from one step to another (a no-no in my book):
class WizardsController < ApplicationController
before_action :load_user_wizard, except: %i(validate_step)
def validate_step
current_step = params[:current_step]
@user_wizard = wizard_user_for_step(current_step)
@user_wizard.user.attributes = user_wizard_params
session[:user_attributes] = @user_wizard.user.attributes
if @user_wizard.valid?
next_step = wizard_user_next_step(current_step)
create and return unless next_step
redirect_to action: next_step
else
render current_step
end
end
def create
if @user_wizard.user.save
session[:user_attributes] = nil
redirect_to root_path, notice: 'User succesfully created!'
else
redirect_to({ action: Wizard::User::STEPS.first }, alert: 'There were a problem when creating the user.')
end
end
private
def load_user_wizard
@user_wizard = wizard_user_for_step(action_name)
end
def wizard_user_next_step(step)
Wizard::User::STEPS[Wizard::User::STEPS.index(step) + 1]
end
def wizard_user_for_step(step)
raise InvalidStep unless step.in?(Wizard::User::STEPS)
"Wizard::User::#{step.camelize}".constantize.new(session[:user_attributes])
end
def user_wizard_params
params.require(:user_wizard).permit(:email, :name :city, :country, :phone_number)
end
class InvalidStep < StandardError; end
end
Now let’s write this another way, using dry-schema:
module Wizard
StepOneSchema = Dry::Schema.Params do
required(:user).hash do
required(:email).filter(format?: /@/).filled(:string)
end
end
StepTwoSchema = Dry::Schema.Params do
required(:user).hash do
required(:email).filter(format?: /@/).filled(:string)
required(:name).filled(:string)
end
end
StepThreeSchema = Dry::Schema.Params do
required(:user).hash do
required(:email).filter(format?: /@/).filled(:string)
required(:name).filled(:string)
required(:city).filled(:string)
required(:country).filled(:string)
end
end
StepFourSchema = Dry::Schema.Params do
required(:user).hash do
required(:email).filter(format?: /@/).filled(:string)
required(:name).filled(:string)
required(:city).filled(:string)
required(:country).filled(:string)
required(:phone_number).filled(:integer, min_size?: 7)
end
end
class WizardsController < ApplicationController
before_action :convert_params_to_hash
def new
@user = init_user
end
def step_one
validation = Wizard::StepOneSchema.call(@hash_params)
@user = init_user(@hash_params)
if validation.success?
render :step_two
return
end
add_errors_to_object(@user, validation)
render :step_one
end
def step_two
validation = Wizard::StepTwoSchema.call(@hash_params)
@user = init_user(@hash_params)
if validation.success?
render :step_three
return
end
add_errors_to_object(@user, validation)
render :step_two
end
def step_three
validation = Wizard::StepThreeSchema.call(@hash_params)
@user = init_user(@hash_params)
if validation.success?
render :step_four
return
end
add_errors_to_object(@user, validation)
render :step_three
end
def step_four
validation = Wizard::StepFourSchema.call(@hash_params)
@user = init_user(@hash_params)
if validation.success?
flash[:success] = 'User created successfully'
redirect_to root_path
return
end
add_errors_to_object(@user, validation)
render :step_four
end
private
def init_user(params = {})
User.new(params)
end
def add_errors_to_object(object, validation)
validation.errors.each {|key, value| object.errors.add(key, value)}
end
def convert_params_to_hash
@hash_params = params.to_unsafe_h
end
end
Here, I used the dry-schema library to capture what needs to be validated in each step. The only thing that I wish Dry-schema implemented, is the ability to include the previous step schema rules into the next one instead of repeating the code. Alas, that is not the case. We could, however, check the success?
of the previous schema(s) in the controller action instead of repeating the rules.
Other than simplifying the code and being framework agnostic, the decoupling of validations from the model is a great thing. You could use the schemas anywhere in your code. We use a similar structure to validate certain structures in the view, and we use it to validate the params
in a controller, and if we really wanted to, we could also use it in a before_save
action on the model.