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.
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.
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.
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.