How to launch an app with dry-system
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.
Series "Developing an app with Dry-rb"
- What is dry-rb and how it might help solve problems with a Rails app
- How to launch an app with dry-system
- How to use Command pattern with the help of Dry-transaction