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.