How to use Command pattern with the help of Dry-transaction

Illustration of a person sitting at a desk reading a book with 'dry-transaction' on the cover, a laptop displaying a 'Rails' error message, and a shelf filled with programming books in the background. Illustration of a person sitting at a desk reading a book with 'dry-transaction' on the cover, a laptop displaying a 'Rails' error message, and a shelf filled with programming books in the background.

In the previous article of the series we were talking about how to use dry-system to launch an app and use a container. Now we’re going to talk about how to implement business logic using transactions and have a close look at the possibilities the container use might give.

What is a transaction?

First of all let’s talk about what a transaction actually is. Any action in our app, such as user creation, stock prices check or report generation, can be seen as a sequence of steps. For example, to create a new user you:

  • validate input data types, check password strength and regular expressions match;
  • check the new data for uniqueness against the database;
  • save the user in the database;
  • send an email to the user to confirm registration.

Each of these steps can be represented as a separate class. Such class is an implementation of the command pattern. Let’s call it an operation. Each operation must be elementary. It facilitates the unit testing, since every operation can be tested separately and only with the necessary input data combination. A transaction is a pipeline of such operations. You might have seen that before in the interactor gem. We have an article about it on mkdev.

Let’s look at the operations in terms of the dry-transaction gem. Each operation should have a public #call method and return either Success(value) or Failure(error), where you define value and error by yourself. Success and Failure classes are parts of the dry-monads gem. We’re not going to talk about this one in this article.

If Success(value) is returned, the transaction continues and ‘value’ is passed as a parameter to the next step. If all operations finished successfully, the result of the transaction will be Success(value) returned in the last operation. If during the transaction processing some operation returns Failure(error), the whole transaction is aborted and you get Failure(error) as a result of the unsuccessful operation. This approach is called Railway Oriented Programming.

App preparation

Now we can start with the code. In the previous article we’ve started making a simple app that checks stock prices. You can find it here. First of all you need to add a new dry-transaction gem to Gemfile:

gem 'dry-transaction', '~> 0.13.0'

Let’s update lib/notifier folder. Delete all the classes that were there and create two folders: transactions and operations. Leave them empty for now.

Now let’s proceed with the system folder. Create an application.rb file:

# system/application.rb
require 'yaml'
require 'dry/transaction'
require 'dry/transaction/operation'

In this app we require dependencies necessary for our app. Now you need to delete this logic from system/boot.rb:

# system/boot.rb
require 'bundler/setup'
require_relative 'application'
Notifier.finalize!

Now, another small yet valuable change in system/container.rb:

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

If you compare what we’ve done here with the previous changes, you can see that we added config.default_namespace = 'notifier'. This allows us to omit writing notifier in the container key name. Thus, for example notifier.operations.operation will become operations.operation.

Now we can finally describe the transaction. Price check process can be divided into 4 steps:

  1. configuration parsing;
  2. current stock prices fetching;
  3. price check;
  4. notification telling what to sell and what to buy.

These steps are the operations of our transaction. Initialize the classes, but not add any logic to them yet. Operations will always succeed and pass what they’ve received to the next step:

# lib/notifier/operations/fetch_prices.rb
module Operations
  class FetchPrices
    include Dry::Transaction::Operation
    include Import[:logger]
    def call(input)
      Success(input)
    end
  end
end
# lib/notifier/operations/filter_prices.rb
module Operations
  class FilterPrices
    include Dry::Transaction::Operation
    include Import[:logger]
    def call(input)
      Success(input)
    end
  end
end
# lib/notifier/operations/filter_prices.rb
module Operations
  class ParseYaml
    include Dry::Transaction::Operation
    include Import[:logger]
    def call(input)
      Success(input)
    end
  end
end
# lib/notifier/operations/filter_prices.rb
module Operations
  class Notify
    include Dry::Transaction::Operation
    include Import[:logger]
    def call(input)
      Success(input)
    end
  end
end

We’ve created 4 classes which return Success(input). That means that if they are combined into a transaction, they will be executed in a specified order and as a result there will be the very same Success(input). Here’s the transaction description:

# lib/notifier/transactoins/check_yaml_prices.yml
module Transactions
  class CheckYamlPrices
    include Dry::Transaction(container: Notifier)
    step :parse_yaml, with: 'operations.parse_yaml'
    step :fetch_prices, with: 'operations.fetch_prices'
    step :filter_prices, with: 'operations.filter_prices'
    tee :notify, with: 'operations.notify'
  end
end

Here we give the transaction the class description keys from the ‘Notifier’ container. They are needed to process each transaction step. You can alternatively describe these steps with methods. For example:

module Transactions
  class ExampleTransaction
    include Dry::Transaction
    step :step_example
    def step_example(input)
      Success(input)
    end
  end
end

Moreover, you can use a method and a container key both. The method will be prioritized and you’ll be able to prepare input data for an operation from the container and call super after that:

module Transactions
  class ExampleTransaction
    include Dry::Transaction
    step :step_example
    def step_example(input)
      adjusted_input = input.merge(useless_arg: 1)
      super(adjusted_input)
    end
  end
end

You might have noticed that the notify step is passed to the tee function instead of step as the others. Doing so we demonstrate that this step doesn’t have to return anything. This is highly convenient in cases when the step just logs something. If we had another step after notify, it would have received the same stuff as notify at the input. There are many other adapters, more about them here. Now we can start with the classes realization. Here’s what it’s going to look like:

# lib/notifier/operations/parse_yaml.rb
module Operations
  class ParseYaml
    include Dry::Transaction::Operation
    YAML_PATH = File.join('./', 'config')
    def call(input)
      filename = input.fetch :filename
      path = File.join(YAML_PATH, "#{filename}.yml")
      data = YAML.load_file(path).transform_values do |ticker_data|
        ticker_data.transform_values { |price| BigDecimal(price.to_s) }
      end
      Success(tickers_data: data)
    end
    private
    def raw_yaml(filename)
      File.read(File.join(YAML_PATH, "#{filename}.yml"))
    end
  end
end
# lib/notifier/operations/notify.rb
module Operations
  class Notify
    include Dry::Transaction::Operation
    def call(input)
      buy = input.fetch :buy
      sell = input.fetch :sell
      prices = input.fetch :prices
      buy.each do |ticker|
        ticker_price = prices[ticker]
        `say "#{ticker} price is #{ticker_price}, time to buy"`
      end
      sell.each do |ticker|
        ticker_price = prices[ticker]
        `say "#{ticker} price is #{ticker_price}, time to sell"`
      end
    end
  end
end

Here it is, our transaction is complete and ready for execution! As we’ve updated the app behavior, we need to update the rake task as well:

# lib/tasks/check_prices.rake
desc 'Check yaml stock prices'
task :check_prices, %i[filename] do |_task_name, args|
  require_relative './../../system/boot'
  filename = args[:filename]
  transaction_key = 'transactions.check_yaml_prices'
  Notifier.monitor(transaction_key) do |event|
    payload = event.payload
    Notifier.logger.info "Prices checked with args: #{payload[:args]} in #{payload[:time]}ms"
  end
  Notifier[transaction_key].(filename: filename)
end

Now you can pass the filename with the prices of needed stocks and shares as an argument. It should look like something like this:

googl:
  buy: 800
  sell: 1000
aapl:
  buy: 150
  sell: 210
fb:
  buy: 100
  sell: 200

Check if everything works properly by running bundle exec rake check_prices[prices] in the console. You might notice that the logs are being written into log/development.log:

# log/development.log
...
I, [2019-02-18T23:19:32.472590 #78281] INFO -- : AAPL price: 0.17042e3; boundaries: {"buy"=>0.15e3, "sell"=>0.21e3}
I, [2019-02-18T23:19:32.472692 #78281] INFO -- : FB price: 0.1625e3; boundaries: {"buy"=>0.1e3, "sell"=>0.2e3}
I, [2019-02-18T23:19:47.569804 #78281] INFO -- : Prices checked with args: [{:filename=>"prices"}] in 18253.77ms

Since we use the file to configure the script, before processing we need to check that the configuration file exists and notify the user if it doesn’t. To do this we need to change the #call method of our parser:

# lib/notifier/operations/parse_yaml.rb
def call(input)
  filename = input.fetch :filename
  path = File.join(YAML_PATH, "#{filename}.yml")
  return Failure('config file missing') unless File.exist?(path)
  data = YAML.load_file(path).transform_values do |ticker_data|
    ticker_data.transform_values { |price| BigDecimal(price.to_s) }
  end
  Success(tickers_data: data)
end

We’ve added return Failure('config file missing') unless File.exist?(path) which stops the transaction and forces it to returnFailure('config file missing'). So the transaction will not crash in case the configuration doesn’t exist. However, the user won’t be notified about the error and it needs to be done. Matcher is the best tool to do this. It’s based on the dry-matcher gem and allows creating different behavior for Success and Failure obtained during different steps. This is what it looks like in our case:

# lib/tasks/check_prices.rake
Notifier[transaction_key].(filename: filename) do |result|
  result.success do
    puts "Prices checked at #{Time.now}"
  end
  result.failure :parse_yaml do |error|
    message = "Failed to parse prices config due to error: #{error}"
    logger.info message
    puts message
  end
end

Here we pass the block, which takes the transaction result (‘result’ variable) as a parameter, to the transaction. We determine behavior for different returned values inside of this block. If everything’s fine and the result variable equals Success, the first code branch will be executed. If ‘result’ equals Failure, the branch corresponding to the step which has returned ‘Failure’ will be executed. In our case it’s the step with parse_yaml. You need to keep in mind that ‘Failure’ might be returned during any step, but Success will only be returned after the last step, when all the transaction operations have been executed successfully.

I believe that matcher is one the best things about dry-transaction. It allows you to monitor not only what’s been returned by different operations, but also how the transaction reacts to that.

That’s it about dry-transaction. You can learn more from the docs. In the next article we’re going to have a look at dry-monads to find out what they do and how to use them for an app.

Series "Developing an app with Dry-rb"
  1. What is dry-rb and how it might help solve problems with a Rails app
  2. How to launch an app with dry-system
  3. How to use Command pattern with the help of Dry-transaction