Contact Us

The dry-rb Ecosystem That Simplifies Application Architecture

This article looks at the dry-rb set of Ruby libraries and how they help simplify application architecture. In particular, it shows how gems from the dry ecosystem are used in the author's projects:

  • dry-auto_inject
  • dry-validation
  • dry-struct

Don't repeat yourself

One of the principles of Ruby on Rails is to use mechanisms that minimize code duplication in applications — the Don't repeat yourself principle, DRY. DRY makes it possible to build a modular application architecture and to split responsibility for business logic cleanly between classes. Following the principle within a large project, however, is not as easy as it may first appear.

In the author's experience, dry-rb handles the decomposition of complex operations into simpler ones well and makes the code much easier to understand.

The dry-rb ecosystem includes more than 20 gems that help implement various tasks. You can read more about each of them in the official documentation: https://dry-rb.org/

This article highlights the most practical tools — the ones the author's team adopted in its projects and uses on a regular basis.

dry-auto_inject

DI (dependency injection) is a useful idea that makes it possible to handle dependencies outside the class that depends on them. Dependency injection is what allows small components to be combined into more complex behavior.

With the dry-auto_inject gem the process becomes simpler. Instead of creating constructors or accessor methods, all you have to do is add the extra dependencies.

To use dry-auto_inject, after installing the gem create a container class and configure the DI module in config/initializers/auto_inject.rb:

require_relative 'containers/forms'
require_relative 'containers/queries'
require_relative 'containers/serializers'
require_relative 'containers/services'
require_relative 'containers/helpers'
require_relative 'containers/presenters'

class DryContainer
  extend Dry::Container::Mixin

  import FormsContainer
  import QueriesContainer
  import SerializersContainer
  import ServicesContainer
  import HelpersContainer
  import PresentersContainer
end

DI = Dry::AutoInject(DryContainer)

This class brings together the containers from the application's various modules. For convenience, they were split into separate files.

Register query objects under a specific namespace:

QueriesContainer = Dry::Container::Namespace.new('queries') do
  namespace(:admin) do
    namespace(:users) do
    register(:index) { Admin::Users::Index.new }
    register(:show) { Admin::Users::Show.new }
    end

    namespace(:feeds) do
    register(:index) { Admin::Feeds::Index.new }
    register(:show) { Admin::Feeds::Show.new }
    end

    namespace(:articles) do
    register(:index) { Admin::Articles::Index.new }
    register(:show) { Admin::Articles::Show.new }
    end
  end
end

Once all the containers are described, include the configured DI module in the class that uses the dependencies. For example:

module Api
  module Admin
    class UsersController < AdminController
    include DI[
                users_list_serializer: 'serializers.admin.users.list',
                serializer: 'serializers.admin.users.single',
                index_query: 'queries.admin.users.index',
                show_query: 'queries.admin.users.show',
                index_form: 'forms.admin.users.index',
                update_form: 'forms.admin.users.update',
                destroy_form: 'forms.admin.users.destroy',
                destroy_service: 'services.admin.users.destroy',
                update_service: 'services.admin.users.update',
                show_presenter: 'presenters.users.show',
                ]

The module included via include DI[*dependencies] resolves the dependency from the container and lets you use it under the given name.

With the container set up in advance, objects can be registered once and then reused in many parts of the application.

The dependency list is also easy to update.

dry-validation

The Ruby on Rails framework provides standard methods for validating input data in models. But if extra checks are required or the list of parameters varies, the model class becomes unreadable because of the huge amount of validation code.

The dry ecosystem's dry-validation gem can serve as an alternative.

With this gem, validation logic can be moved into separate forms or contracts that consist of parameters describing the check for each of them plus custom rules.

module Users
  class CreateForm < ApplicationForm
    params do
    required(:gender).value(:string, included_in?: User.genders)
    required(:name).value(:string)
    required(:birthday).value(:date)
    required(:country).value(:string)
    required(:city).value(:string)
    optional(:city_description).value(:string)
    optional(:image_ids).value(:array).each(:integer, gteq?: 1)
    required(:main_image_id).value(:integer, gteq?: 1)
    required(:avatar_image_id).value(:integer, gteq?: 1)
    optional(:referal_code).maybe(:string, min_size?: 6, max_size?: 6)
    end

    rule(:birthday).validate(:valid_birthday)
  end
end

Rules can be described in the form itself, but in practice it turned out to be more convenient to move them into separate macros. That way a rule is defined once and used in several forms without duplicating code.

module Macros
  module BirthdayMacros
    extend ActiveSupport::Concern

    included do
    register_macro(:valid_birthday) do |_macro:|
        key.failure(I18n.t('api.errors.messages.wrong_birthday')) if value.present? && value > Date.today
        key.failure(I18n.t('api.errors.messages.min_birthday')) if value.present? && ((Date.today.to_time - value.to_time) / 1.year).floor < 18
    end
    end
  end
end

The parent ApplicationForm class describes the configuration for error localization and includes all the macros in use:

class ApplicationForm < Dry::Validation::Contract
  config.messages.backend = :i18n

  include Macros::CodeMacros
  include Macros::DateMacros
  include Macros::BirthdayMacros
  include Macros::PhoneNumberMacros
  include Macros::SubcategoryMacros
  include Macros::DateRangeMacros
  include Macros::PaginateMessagesMacros
  include Macros::ImageFileMacros
  include Macros::CategoryTitleMacros
  include Macros::ImageRelationMacros
  include Macros::MessageLocalIdMacros
  include Macros::HobbyNameMacros
  include Macros::UniqueMacros
  include Macros::LastNameMacros
end

To make parameter validation more convenient, a concern was added for controllers that work with forms:

module DryValidatable
  extend ActiveSupport::Concern

  def validate_with(form, params, key = nil)
    params = params.to_unsafe_h unless params.is_a?(Hash)

    validation_result = form.new.(params[key] || params)

    raise Api::UnprocessableEntityException.new(validation_result.errors.to_h) unless validation_result.success?

    validation_result.to_h
  end
end

The validate_with method checks whether the form returned errors. On successful validation it returns a hash with the checked parameters. Calling this method from a controller looks like this:

valid_params = validate_with(create_form, params)

Here create_form is the form dependency, the inclusion of which was covered earlier with dry-auto_inject.

Using the dry-validation gem provides an efficient setup for validating input parameters without extra load on model classes.

dry-struct

There are situations where a method needs to accept a large number of parameters. Ruby allows the use of a **kwargs parameter, which passes a variable-length list of keyword arguments.

On a large project, however, **kwargs makes it hard to track which parameters a given method needs.

This is where the dry-struct gem helps.

module Profiles
  class UpdateStruct < Dry::Struct
    attribute :name, Types::String.optional.default(nil)
    attribute :last_name, Types::String.optional.default(nil)
    attribute :phone_number, Types::Integer.optional.default(nil)
    attribute :password, Types::String.optional.default(nil)
    attribute :email, Types::String.optional.default(nil)
    attribute :points, Types::Integer.optional.default(nil)
    attribute :vip_period, Types::Integer.optional.default(nil)
    attribute :age, Types::Integer.optional.default(nil)
    attribute :city, Types::String.optional.default(nil)
    attribute :city_description, Types::String.optional.default(nil)
    attribute :country, Types::String.optional.default(nil)
    attribute :birthday, Types::Params::Date.optional.default(nil)
    attribute :status_phrase, Types::String.optional.default(nil)
    attribute :scope_of_activity, Types::String.optional.default(nil)
    attribute :profession, Types::String.optional.default(nil)
    attribute :is_smoking, Types::String.optional.default(nil)
    attribute :is_drinking, Types::Bool.optional.default(nil)
    attribute :chronotype, Types::String.optional.default(nil)
    attribute :personality_type, Types::String.optional.default(nil)
    attribute :gender, Types::String.optional.default(nil)
    attribute :hobbies_ids, Types::Array.optional.default([].freeze)
    attribute :image_ids, Types::Array.optional.default([].freeze)
    attribute :main_image_id, Types::Integer.optional.default(nil)
    attribute :avatar_image_id, Types::Integer.optional.default(nil)
    attribute :role, Types::String.optional.default('user')
  end
end

The struct describes all the required attributes, their types and default values. Note that dry-struct cannot validate parameters the way dry-validation does, so this gem should only be used for structuring data.

Initializing the struct looks like this:

def update
    valid_params = validate_with(update_form, params)
    params_object = ::Profiles::UpdateStruct.new(valid_params)
    update_service.(current_user, params_object)
end

The struct is created with the parameters that the form has already validated, and then this struct is passed to the target method.

So by using a struct object as a method parameter, long parameter lists are avoided and the code becomes more readable.

This article describes far from all the advantages of the dry ecosystem. The goal was to show that gems can be used to implement an architecture that is convenient to work with on large projects.

Validation, structuring, dependency injection — these are capabilities that are quick and simple to set up thanks to the tools dry-rb provides.