Introduction
Almost everyone in the rails community(and not only) talks about the slim controllers: "Let's move everything to the service objects and functions, keep controllers as simple as possible". It's a great concept, and I fully support it. Service objects and functions for controllers' methods(not only but mainly for them) usually have two additional parts besides business logic:
- input validation;
- errors passing.
They aren't a big problem if your function does not call other functions and does not have nested objects as the input arguments. The problem that they are love to grow and mysteriously interact with each other.
In this post, I want to share the way how you can overcome these problems. Grab yourself a cup of your favourite beverage ☕ and join this journey.
Time for experiments
Imagine that you are designing internal software for a laboratory. It should run experiments from the passed arguments. Arguments should be validated to get a sensible response instead of meaningless 500 errors.
Without dry libraries
➡️ Let's start from the controller for the experiment. The controller should only run the function and render results or errors:
class ExperimentsController
def create
result = ConductExperiment.call(experiment_params)
if result[:success]
render json: result[:result]
else
render json: result[:errors], status: :bad_request
end
end
private
def experiment_params
params.require(:experiment).permit(
:name,
:duration,
:power_consumption
)
end
end
➡️ Next, add the service object. It is a good practice to move the repeated part to the base object:
class ApplicationService
def initialize(*params, &block)
@params = params
@block = block
end
def call
validation_result = validate_params
if validation_result[:success]
execute
else
validation_result
end
end
def validate_params
{ success: true }
end
def self.call(*params, &block)
new(*params, &block).call
end
end
class ConductExperiment < ApplicationService
def initialize(params)
super
@params = params
end
def validate_params
errors = []
if @params[:duration].blank?
errors << 'Duration should be present'
return { success: false, errors: errors }
end
if @params[:duration] < 1
errors << 'Duration should be positive number'
return { success: false, errors: errors }
end
{ success: true }
end
def execute
if Random.rand > 0.3
{ success: true, result: 'Success!' }
else
{ success: false, errors: ['Definitely not success.'] }
end
end
end
It already looks cumbersome, don't ya?😕 But all of a sudden, users of our API requested to add the type validation to all fields and a new entity - research, that will accept an array of experiments and checks that summary power consumption is less than a defined limit. Sounds scary 😲. Doing this will be really hard with the current configuration, so let's add some dry libraries and look at how easy it will be.
The full version is here.
Update validations
➡️ First, we need to upgrade the validation process. For this will be used the gem dry-validation. It adds validations that are expressed through contract objects.
A contract specifies a schema with basic type checks and any additional rules that should be applied. Contract rules are applied only once the values they rely on have been successfully verified by the schema.
➡️ Begin by updating the ApplicationService
. We replace validate_params
method with validator
, where can be defined either class name of the contract or contract itself. In the call
, add the call of the validator
and change the validation_result
check, respectively.
class ApplicationService
def initialize(*params, &block)
@params = params
@block = block
end
def call
validation_result = validator.new.call(@params) unless validator.nil?
if validator.nil? || validation_result.success?
execute
else
{ success: false, errors: validation_result.errors.to_h }
end
end
def validator
nil
end
def self.call(*params, &block)
new(*params, &block).call
end
end
➡️ Next, add the class where will be defined validations. Contracts have two parts:
- schema(or params) where are defined basic type checks
- rules for complex checks which are applied after schema validations.
⚠️ Be careful with optional values. You should check that value is passed before the checking.
class ConductExperimentContract < Dry::Validation::Contract
schema do
required(:duration).value(:integer) # in seconds
optional(:title).value(:string)
optional(:power_consumption).value(:integer) # in MW
end
rule(:duration) do
key.failure('should be positive') unless value.positive?
end
rule(:power_consumption) do
key.failure('should be positive') if value && !value.positive?
end
end
➡️ In the ConductExperiment
class, just replace the validation with the previously defined validator class.
class ConductExperiment < ApplicationService
def initialize(params)
super
@params = params
end
def validator
ConductExperimentContract
end
def execute
if Random.rand > 0.3
{ success: true, result: 'Success!' }
else
{ success: false, errors: ['Definitely not success.'] }
end
end
end
Looks great! Time to add functionality for the research process.
This class will be finished successfully if all experiments are finished successfully or will return an error message from the first failed experiment.
class ConductResearch < ApplicationService
def initialize(params)
super
@params = params
end
def validator
ConductResearchContract
end
def execute
result = []
@params[:experiments].each do |experiment_params|
experiment_result = ConductExperiment.call(experiment_params)
return experiment_result unless experiment_result[:success]
result << experiment_result[:result]
end
{ success: true, result: result }
end
end
➡️ The most interesting part is the ConductResearchContract
. It validates each element of the experiments array for compliance with the schema defined in the ConductExperimentContract
. Sadly, now, to run rules for each experiment, you must run them manually like in the rule(:experiments)
.
class ConductResearchContract < Dry::Validation::Contract
MAX_POWER_CONSUMPTION = 30
schema do
required(:title).value(:string)
required(:experiments).array(ConductExperimentContract.schema).value(min_size?: 1)
end
rule(:experiments).each do
result = ConductExperimentContract.new.call(value)
unless result.success?
meta_hash = { text: 'contain bad example' }.merge(result.errors.to_h)
key.failure(meta_hash)
end
end
rule do
total_power_consumption = values[:experiments].reduce(0) do |sum, experiment|
experiment[:power_consumption].nil? ? sum : sum + experiment[:power_consumption]
end
if total_power_consumption > MAX_POWER_CONSUMPTION
key(:experiments).failure("total energy consumption #{total_power_consumption} MW exceeded limit #{MAX_POWER_CONSUMPTION} MW")
end
end
end
✨ We did it! It validates everything and works great. But we can make it even better.
The result version of this example is here.
Make it simpler with monads
In this part I won't explain what are monads and how this library works, thats very good explained in the documentation and in thesecool posts: Functional Ruby with dry-monad
s, Improve Your Services Using Dry-rb Stack and Five common issues with services and dry-monads. Here I will just demonstrate how it can improve the existing code.
➡️ Firstly add dry-monads
gem to the Gemset.
# ...
gem 'dry-monads'
# ...
➡️ After this add the add the behaviour of monads to contracts. For this, you should run the command Dry::Validation.load_extensions(:monads)
. For rails applications I usually create file config/initializers/dry.rb
where store all global configs for dry gems.
➡️ Next update service objects. Replace these ifs with the do notation syntax.
class ApplicationService
include Dry::Monads[:result, :do]
def initialize(*params, &block)
@params = params
@block = block
end
def call
yield validator.new.call(@params) unless validator.nil?
execute
end
def validator
nil
end
def self.call(*params, &block)
new(*params, &block).call
end
end
➡️ In the ConductExperiment
, wrap results to the Dry::Monads::Result
monads
class ConductExperiment < ApplicationService
def initialize(params)
super
@params = params
end
def validator
ConductExperimentContract
end
def execute
if Random.rand > 0.3
Success('Success!')
else
Failure('Definitely not success.')
end
end
end
and replace that ugly construction with temporary variables with this neat form in the ConductResearch
service.
class ConductResearch < ApplicationService
def initialize(params)
super
@params = params
end
def validator
ConductResearchContract
end
def execute
result =
@params[:experiments].map do |experiment_params|
yield ConductExperiment.call(experiment_params)
end
Success(result)
end
end
➡️ Because failure objects are different, result messages should also be handled differently.
class ExperimentsController < ApplicationController
def create
result = ConductExperiment.call(experiment_params.to_h)
if result.success?
render json: result.value!
else
error_object =
if result.failure.is_a?(Dry::Validation::Result)
result.failure.errors(full: true).to_h
else
result.failure
end
render(json: error_object, status: :bad_request)
end
end
private
def experiment_params
params.require(:experiment).permit(
:name,
:duration,
:power_consumption
)
end
end
That's all!🎆 Compare it with the previous version. I think you will agree - it looks more readable.
Result version is available here.
I hope this post was helpful for you. Have a nice day!
Top comments (0)