How to launch an app with dry-system

Illustration of a person reading a book labeled "dry-system" at a desk with a laptop showing "Rails" on the screen, next to a shelf with books named after programming libraries. Illustration of a person reading a book labeled "dry-system" at a desk with a laptop showing "Rails" on the screen, next to a shelf with books named after programming libraries.

In the first article of the series we’ve in basic terms explained how we use dry-rb for our project. Now we can talk more thoroughly about each of its features, so let’s start with dry-system.

If one day you decide to give up on Rails (as we did) and use Sinatra Sinatra or Roda instead, even the first server launch might become a disaster. Rails was carefully autoloading all the gems, running initializers, managing dependencies using 'require' for you as well as carrying out many other things you couldn’t even think about (https://guides.rubyonrails.org/autoloading_and_reloading_constants.html) But now you have to manually add 'require' keywords to dozens of files just to launch your app. In fact, you have to do the same procedure for all the files with dependencies. It makes your app way faster, but doesn’t bring you any pleasure.

Dry-system is aimed at solving such problems. It allows you to divide your app into independent encapsulated components, facilitates the initializing process, provides an Inversion of Control (IoC)- a container to register and instantiate dependencies. Below you can find an example of how to use it for a real app.

In this series we’re going to create a simple app using iterations. It will check stock prices and notify you if they exceed a specified threshold. The first version of an app will be just a simple rake task, which is run this way:

bundle exec rake check_price[ticker,price]

Project preparation

Let’s create three folders system, lib, log in the app root folder and add Gemfile there:

source 'http://rubygems.org'
gem 'dry-system', '~> 0.10.1'
gem 'dry-monitor', '~> 0.1.2'


gem 'http'
gem 'rake'

Container setup

Now we can proceed with dry-system. The first thing we need to set up is a container. We use it to register dependencies we need to call while the app is running. This will save us the trouble of writing many unnecessary require files and make inversion of control possible.

Create system/container.rb file with:

require 'dry/system/container'
require 'dry/system/components'
class Notifier < Dry::System::Container
  use :logging
  use :env, inferrer: -> { ENV.fetch('RACK_ENV', :development).to_sym }
  configure do |config|
    # config.root = /root/app/dir
    config.auto_register = 'lib'
  end
  load_paths!('lib', 'system')
end

In this file we prescribe how the container should build the app.

Now let’s step by step explain what happens here:

The three lines below are to include plug-ins for logging, determining the runtime environment and monitoring the process.

use :logging
use :env, inferrer: -> { ENV.fetch('RACK_ENV', :development).to_sym }
use :monitoring

Determine the path to look for dependencies. In this case we’ve intentionally commented it out, since we don’t need to change a default setting. But it might be helpful to know which path is allowed.

# config.root = /root/app/dir

Configure the folder in the root folder where the dependencies will be automatically registered. It means that 'require' method will be called for all the files in this folder, and required classes will be registered with the keys according to their paths. Thus, lib/folder/class.rb class will be registered inside the container as folder.class. We’re going explain how this class works later.

config.auto_register = 'lib'

This statement adds the folders passed in arguments to $LOAD_PATH, which makes it easier to use ‘require’.

load_paths!('lib', 'system')

Now create system/import.rb file. This module will allow you to access the objects registered in the container.

require 'container'
Import = Notifier.injector

That’s it, we’ve described the container and how it should work. Now you can run it by calling a .finalize! method. After that, the application classes will be automatically registered and the external dependencies from the boot folder will be initialized. More about that later, now you need to run the container using irb:

irb(main):001:0> require_relative 'system/container'
=> true
irb(main):002:0> Notifier.finalize!
=> Notifier

Always running the container this way is not the greatest solution. So let’s save this logic as a separate system/boot.rb file. Let me also draw your attention to the fact that we add dependencies not only to the container itself, but also to the system/import.rb file:

require 'bundler/setup'
require_relative 'container'
require_relative 'import'

Notifier.finalize!

Initializing external dependencies

Before the launch any app usually needs to initialize external libraries. For this purpose create one more system/boot folder. It’s the same as a config/initializers folder in Rails: after .finalize! is called, the 'require' for all the files in this folder will be executed. This is what the logger adjustment in system/boot/logger.rb file looks like for our app:

Notifier.boot :logger do
  start do |container|
    container[:logger].level = ENV.fetch('LOG_LEVEL', 1)
  end
end

App logic

Well, the container now initializes not only app classes, but also external libraries. Now you can start describing the logic of the app. In this article the implementation description is simplified, but in the next one we’re going to talk about how to make it better. This whole procedure is not connected with dry-system, so we’re not going to comment it anyhow.

lib/notifier/fetch_price.rb

require 'http'

class FetchPrice
  def call(ticker)
    HTTP.get(url(ticker)).yield_self do |response|
      BigDecimal(response.to_s)
    end
  end
  private
  def url(ticker)
    "https://api.iextrading.com/1.0/stock/#{ticker.upcase}/price"
  end
end

lib/notifier/check_price.rb

class CheckPrice
  def call(price, boundary)
    `say "Price is #{price}, time to buy"` if price > boundary
  end
end

Now it’s possible to check the stock price and compare it with the threshold. You can go ahead with creating the controller class. This is what it might have looked like:

lib/notifier/main.rb

require 'notifier/check_price'
require 'notifier/fetch_price'
class Main
  def call(ticker, boundary)
    price = FetchPrice.new.call(ticker)
    CheckPrice.new.call(price, boundary)
  end
end

It might have looked like that only if you hadn’t had the container where the classes FetchPrice and CheckPrice (the dependencies of the Main class) had already been registered. If you rewrite the code using the container, it might look like that:

require 'import'
class Main
  include Import['notifier.check_price', 'notifier.fetch_price']

  def call(ticker, price)
    check_price.call(fetch_price.call(ticker), price)
  end
end

Let’s now talk about how it works: you include the Import module and pass the dependency you need as an argument. After that, this dependency will be automatically available inside of the class.

This principle is called Dependency Inversion. Thanks to it inside your code you rely on abstractions registered in the container and not on the particular implementations as it was in the former case. It reduces cohesion, facilitates quality control process and technical support in general. This is what D stands for in a widely-known abbreviation SOLID.

The last thing you need to do is add a rake task which will launch your app. Create Rakefile:

Rakefile

desc 'Check stock price'
task :check_price, %i[ticker price] do |_task_name, args|
  require_relative './system/boot'


  ticker = args[:ticker]
  price = BigDecimal(args[:price])


  Notifier['notifier.main'].call(ticker, price)
end

Given that you’ve added a 'require' for system/boot, you don’t need to explicitly run the container and resolve system/import.rb dependency for all the classes which use Import module anymore. Check what you have now:

bundle exec rake check_price[googl,1000]
bundle exec rake check_price[googl,1200]

It’s high time you added the monitoring feature to your app to make sure that the price is checked regularly. Dry-system has a built-in monitoring system based on dry-monitor. It allows monitoring of objects inside the container according to the key. Modify your rake task so that it will check the price in an infinite loop and add monitoring of those checks:

Rakefile

desc 'Check stock price'
task :check_price, %i[ticker price] do |_task_name, args|
  require_relative './system/boot'


  ticker = args[:ticker]
  price = BigDecimal(args[:price])


  Notifier.monitor('notifier.main') do |event|

    payload = event.payload
    Notifier.logger.info "Price checked with args: #{payload[:args]} in #{payload[:time]}ms"
  end


  loop do
    Notifier['notifier.main'].call(ticker, price)
    sleep 10
  end
end

That’s it! Run the rake task once more and check log/development.log:

Price checked with args: ["googl", 0.15e4] in 903.98ms
Price checked with args: ["googl", 0.15e4] in 793.36ms
...

Well, we’ve described in basic terms how dry-system works. It’s a powerful tool with a wide array of features which we cannot cover in one article only. So we highly recommend you to have a look at the docs of this gem. That’s it for today, in the next article we’re going to talk about how we organize the app code using Dry-transaction.