Dry-rb and Trailblazer Reform

Why do we use dry-rb and Trailblazer Reform on the backend?


Framework Ruby On Rails implements the MVC pattern of application building. It’s very easy to understand — controller (C) receives data from the user and transfers it to the model (M), where it is processed and then displayed in the view (V). For a small application, that is quite enough — the model describes the validation rules, adds methods and various callbacks, and some actions are performed in the controller with it.

But if your project becomes bigger, and more sophisticated business logic appears in it, then the amount of code in your models starts to grow, more complicated conditions appear in the validation rules, the number of callbacks increases and more and more actions are performed in the controllers.

If you can’t foresee this in advance, then later the project will turn into a bunch of unsupported code, where it will be difficult to find something, add or change, and it will negatively affect either the project itself or your development, its rate and quality.

It’s very bad.

Since we, JetRockets, write large and complex apps, then our approach to it is also serious and well thought out. It’s not enough for us to have a standard MVC approach in application building — we need to think over large volumes of business logic, make it componential, replaceable and easily testable.

The objects of services and forms together with a set of gems dry-rb and Reform from Trailblazer help us.

Centralized store

Now certain separate services are responsible for some operations in our apps, and form objects are responsible for the validation of input data. And for the convenient storage and use of these services and forms with all the required interactions, we took the gem the dry-container from the dry-rb set. It provides a set for implementing its container modules, where you can register and store anything you like. The centralized store makes it easier to edit and change the interactions of services when it’s necessary and call them from a certain place.

dry-container is very simple in use — we make a module for our container, set the necessary namespaces for the path inside and register all essential initialized classes with required interactions, if it’s necessary. For example, we have a user creation service that uses the following form of user creation for validation characteristics:

# project/app/containers/global_container.rb

module GlobalContainer
  extend Dry::Container::Mixin

  namespace('user') do
    namespace('forms') do
      register('create_user_form_class') { User::CreateUserForm }
    end

    namespace('services') do
      register('user_creator') do
        User::CreateUser.new(self['user.forms.create_user_form_class'])
      end
    end
  end
end

Now, when we need to call the user creation service, we will do it from the container module, and all required interactions will be initialized there. In this case we mean the class of the user creation form.

# …
GlobalContainer['user.services.user_creator'].call(resource_user, user_params)
# …

Data processing

Let’s turn to the services themselves and our approach to writing and using them. If it is possible, each service should be responsible for one particular action, but if there’s complex logic, you should run other services which it contains as interactions. And for the convenient processing of service results we started using dry-matcher and dry-monads from dry-rb set. The use of both these gems at one time makes it user-friendly to operate the service objects of the application, and it is also convenient to use certain services within others.

We have noticed that in most cases the result of any action has only two outcomes: Either success or failure. It turns out that the best implementation of this idea for us is monad Either.

So, the result of any service object is the Success object in case of success or the Failure object in case of failure. For further processing of the result, we use the corresponding matcher, and we implement separately further actions for every possible case. This approach helps us visually represent business logic in the code and to make changes rapidly if needed.

Let’s take a look at the example of the user creation service, which we put into the container in the previous example:

# project/app/services/user/create_user.rb

require "dry-monads"
require "dry/matcher/result_matcher"

class User::CreateUser
  include Dry::Monads::Result::Mixin
  include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

  attr_reader :create_user_form_class

  def initialize(create_user_form_class)
    @create_user_form_class = create_user_form_class
  end

  def call(resource_user, user_params)
    form = create_user_form_class.new(resource_user)

    if form.validate(user_params)
      user = create_user!(form)

      Success.new(user)
    else
      Failure.new(form)
    end
  rescue => e
    Failure.new(e)
  end

  private

  def create_user!(form) 
    user = form.sync
    user.save!
  end
end

If the user creation service works well (creating of that very user), we initiate the successful result of the service work by Success monad with the object of the created new user inside. If the validation of the user creation form is unsuccessful according to the result of the service work, we return Failure monad with the object of the validated form inside (and here we can subsequently get the form validation errors).

In the presence of an unexpected error in the service work, we perform excluding, and also return it with Failure monad, since it is also an unsuccessful service work, but this time with an error object inside.

Now in the application controller, or in other services where the data is used, we can get the result of its work inside the block through its parameter, and process it in different ways, depending on the successful or unsuccessful work of the service:

# …
GlobalContainer['user.services.user_creator'].call(resource_user, user_params) do |m|
  m.success { |user| ... }
  m.failure { |form_or_error| ... }
end
# …

In cases where we need to process more than two service behavior scenarios, we can write our matcher. For example, in some cases of failure, we need to return errors in form validation, and in some cases — an exception, and to process these results in different ways.

By writing the following matcher, in its failure we check for the presence of the necessary key (exception) and the presence of an object of the StandardError class in the monad of failure:

# project/config/initializers/dry_matcher.rb

module Dry
  class Matcher
    FormOrErrorMatcher = Dry::Matcher.new(
      success: Case.new(
        match: -> value, *pattern {
          result = value.to_result
          result.success?
        },
        resolve: -> value {
          result = value.to_result
          result.value!
        }
      ),
      failure: Case.new(
        match: -> value, *pattern {
          result = value.to_result
          result.failure? && (pattern.any? ? pattern.include?(:exception) && result.value.is_a? StandardError : true)
        },
        resolve: -> value {
          result = value.to_result
          result.failure
        }
      )
    )
  end
end

Wrap the method of service launch by our matcher:

#...
include Dry::Matcher.for(:call, with: Dry::Matcher::FormOrErrorMatcher)
#...

And then process the service work results in this way:

#...
GlobalContainer['user.services.user_creator'].call(resource_user, user_params) do |m|
  m.success { |user| ... }
  m.failure(:exception) { |error| ... }
  m.failure { |form| ... }
end
#...

Result object

Now we go on. In most cases, the result of the service work is not some specific value, but a set of essential data, with which it would be much more convenient to work as an object. And to create a service result object, we took dry-struct and dry-types, both from that very set dry-rb. They are great for creating structural objects and typing their features. It is very convenient.

For using of feature typing, we need to include the module Dry::Types into our module Types. It will let us add our own classes for the feature typing in the future.

require 'dry-types'
require 'dry-struct'

#...
module Types
  include Dry::Types.module
end

class Result < Dry::Struct
  attribute :count, Types::Coercible::Int.default(0)
  attribute :type, Types::Strict::String
  attribute :errors, Types::Strict::Array.optional
end
#...

The final service result class we can use in this way:

[1] pry(main)> Result.new(count: 5, type: 'test', errors: nil)
=> #<Result count=5 type="test" errors=nil>

[2] pry(main)> Result.new(count: 7, type: 'test', errors: [{name => 'invalid'}])
=> #<Result count=7 type="test" errors=[{:name=>"invalid"}]>

[3] pry(main)> Result.new(count: 7, type: nil, errors: nil)
Dry::Struct::Error: [Result.new] nil (NilClass) has invalid type for :type

You can also write your own classes of object feature typing. For example, for turning currency line into decimal value:

require 'dry-types'

module Types
  include Dry::Types.module
  #...
  class Currency
    def self.call(v)
      if v.present?
        (v.is_a?(String) ? Types::Coercible::Decimal.(v.strip.gsub(/[^0-9.]/, '')) : Types::Coercible::Decimal.(v.to_s))
      else
        nil
      end
    end
  end
  #...
  Dry::Types.register_class(Currency)
  #...
end
#...
property :amount, type: Types::Currency #'$35,622.50' => 35622.50
#...

The forms of data validation

Now let’s move to form objects and data validation. As I mentioned at the beginning, business logic was also brought to form objects; it let our models to stay clear and create different scenarios for their use. And for a more flexible and diverse work with the form objects, we started using Reform from Trailblazer.

Reform is a powerful form validation tool that allows us to work with one or more models simultaneously, with attached forms and collections. It lets you preconfigure the form object, and also process the data after validation, use conditional validation blocks, add virtual attributes and type features using dry-types.

When using Reform together with dry-validation from the dry-rb collection, you can write your own validation methods, or use the ones from the list, and do the parallel or sequential validation.

An example of the form of creating a Contact entity with the User entity belonging to it, using the previously created module Types:

# project/app/models/contact.rb

class Contact < ActiveRecord::Base
end
# project/app/models/user.rb

class User < ActiveRecord::Base
  belongs_to :contact
end
# project/app/forms/contact/create_contact_form.rb

require 'reform/form/coercion'

class Contact::CreateContactForm < Reform::Form
  feature Coercion # for using dry-types

  property :name, type: Types::String
  property :email, type: Types::String

  validation do
    configure do
      config.namespace = :contact

      def email?(value)
        ! /magical-regex-that-matches-emails/.match(value).nil?
      end
    end

    required(:name).filled
    required(:email).filled(:email?)
  end
end
# project/app/forms/user/create_user_form.rb

require 'reform/form/coercion'

class User::CreateUserForm < Reform::Form
  feature Coercion # for using dry-types

  property :login, type: Types::String
  property :role, type: Types::String
  property :active, type: Types::Bool
  property :contact, form: Contact::CreateContactForm, prepopulator: ->(_) { self.contact = Contact.new }, populator: -> (model:, **) { model || self.contact = Contact.new }

  validation do
    configure do
      config.namespace = :user

      def unique?(value)
        User.find_by(login: value).nil?
      end
    end

    required(:login).filled(:unique?)
    required(:role).filled(included_in?: User::ROLES)
    required(:active).filled(:bool?)
    required(:contact).filled
  end
end

For your own validation methods you need to configure translations of the error text by registering a file with them in the initializer:

# project/config/initializers/dry_validation.rb

Dry::Validation::Schema.config.messages_file = 'config/locales/dry-validation/errors.yml'
# project/config/locales/dry-validation/errors.yml

en:
  errors:
    rules:
      user:
        rules:
          login:
            unique?: login must be unique
      contact:
        rules:
          email:
            email?: email is invalid

In conclusion

The use of service objects and form objects helped us to keep up our controllers and models skinny, made it possible to add new business logic more easily and change the old one without any difficulties, and allowed us to reuse common components in different scenarios.

The aforementioned gems from the set dry-rb and Reform from Trailblazer helped greatly. We also suggest using these wonderful products in your apps regardless of their complexity, in order to simplify their development.


Dmitry
Voronov

Full-stack Developer at JetRockets

See what people are saying

Rectangle 100 x 48b5de14 c9a0 4d69 8972 a705ac62cc6a
Piotr Solnica ( @_solnic_ )
Once again folks confuse convenience with simplicity, ie AR in Rails *is not simple*. It’s ridiculously complex, but on the surface it *seems* simple. `User.create` looks slick, I know, but this doesn’t mean you keep things simple. You keep things *convenient* and it may bite you.
Rectangle 100 x 8ee1b82d 99b2 4cf1 95d1 d43933309182
Reddit r/ruby
There is a lively debate about this topic going on on Reddit with more than 40 comments.
Rectangle 100 x 176204e3 c567 4dfa 874c f4f08c8ab965
Ruby Tuesday newsletter
Dmitry's article was mentioned in the Ruby Tuesday newsletter.
Rectangle 100 x 18d1dd41 44db 4476 8990 fcbd22796e4f
Piotr Solnica ( @_solnic_ )
I gotta be honest. I’m so tired of explaining all over again, that dry-rb is not reinventing Java. Please try to be a bit more open-minded. DI is a great concept in OO, isolating global state is a good idea, and reduction of coupling is undoubtedly always beneficial.

Explore more of JetRockets