DEV Community

Ivan Ilyukhin
Ivan Ilyukhin

Posted on • Updated on

Overcoming complexity of service objects with dry libraries

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
Enter fullscreen mode Exit fullscreen mode

➡️ 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

Enter fullscreen mode Exit fullscreen mode
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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

✨ 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-monads, 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'
# ...
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

➡️ 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
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)