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)