Back to home

Exploring Dry-RB With Dry Matcher And Dry Monads main image

Exploring Dry-RB With Dry Matcher And Dry Monads

This post will give a short introduction to the dry-monads and dry-matcher gems and walk through how we can use these gems to clean up our Ruby code to create a nicer developer experience.

Our project today will be to create a contrived Calculator class that will make use of the dry-monads gem and a CrunchNumbers class that operates as an imperative shell calling the calculator instance and handling the responses.

Source code can be found here

Prerequisites

  1. Basic familiarity with Bundler.
  2. Familiarity with reading tests written in RSpec.
  3. Familiarity with the Pry Gem.

Getting started

We will create the project directory demo-dry-monads-matcher and use Bundler to initialize the project:

$ mkdir demo-dry-monads-matcher $ cd demo-dry-monads-matcher # initialise Bundler project $ bundler init # RSpec added for testing $ bundler add rspec --group "development,test" # Pry added for debugging $ bundler add pry --group "development,test" # Add the other required dry-rb gems $ bundler add dry-monads $ bundler add dry-matcher # Create some folders and files that we will use $ mkdir lib spec $ touch lib/calculator.rb lib/crunch_numbers.rb spec/crunch_numbers_spec.rb

We will be creating both a Calculator class that features a call method to call some contrived private methods and a CrunchNumbers class that will rely on dependency injection of a calculator instance, use that instance to method call and then handle the responses.

Creating the Calculator class.

Inside of lib/calculator.rb, add the following:

require 'dry/monads' class Calculator include Dry::Monads[:result, :do] def call(input:) value = Integer(input) value_add_3 = yield add_3_if_gt_1(value) value_mult_2 = yield mult_2_if_even(value_add_3) Success(value_mult_2) end private def add_3_if_gt_1(value) if value > 1 Success(value + 3) else Failure(:less_than_one) end end def mult_2_if_even(value) if value.even? Success(value * 2) else Failure(:not_even) end end end

In our contrived class, we are writing two private methods that will return either a Success or Failure monad based on the input.

The call method that invokes both of our private methods makes use of the yield keyword to assign the result of invoking a private method to a variable, otherwise it will return the Failure monad to the caller.

For example, if we call calculator.call(3), the return value will be Success(12), however if we pass invalid values we could possibly have a return value of Failure(:less_than_one) or Failure(:not_even).

This will be demonstrated in our RSpec tests that we will write later.

Creating the CrunchNumbers class

Our CrunchNumbers class will be a class that requires a calculator instance to be injected into it, as well as an input value (that we are inferring to be an integer). The instance will then have a crunch method that will call the call method on the calculator instance.

We will use dry-matcher to match on the result in order to demonstrate the value of the gem:

require 'calculator' require 'dry/matcher/result_matcher' class CrunchNumbers include Dry::Monads[:result] def crunch(calculator:, input:) Dry::Matcher::ResultMatcher.call(calculator.call(input: input)) do |m| m.success do |response| "Yay! Value is #{response}" end m.failure :less_than_one do 'Nay less than one' end m.failure :not_even do 'Nay not even' end end end end

With m, we can check if the response was successful or whether it was a particular failure so that we can handle the response accordingly.

In order to test this, let's write our RSpec tests.

Writing our specs

Inside of spec/crunch_numbers_spec.rb, add the following:

require 'crunch_numbers' require 'calculator' RSpec.describe CrunchNumbers do let(:calculator) { Calculator.new } describe '#crunch' do context 'valid input' do it 'returns "Yay! Value is <expect_value>" when the return value is greater than 1 and even' do expect(subject.crunch(calculator: calculator, input: 3)).to eq('Yay! Value is 12') end end context 'invalid input' do it 'returns "Nay less than one" when the add_3 fails > 1 validation' do expect(subject.crunch(calculator: calculator, input: 1)).to eq('Nay less than one') end it 'returns "Nay not even" when the return value is odd' do expect(subject.crunch(calculator: calculator, input: 2)).to eq('Nay not even') end end end end

In our tests, we are checking that the crunch method returns the expected value when the input is valid and when the input is invalid.

We are also creating a calculator variable to use between each test.

Our context blocks help us to visually see the tests in a more readable format based on valid or invalid input values.

If we run bundle exec rspec, we can see that our tests pass as expect and dry-matcher is working as expected.

$ bundle exec rspec ... Finished in 0.00593 seconds (files took 0.16121 seconds to load) 3 examples, 0 failures

Awesome! We have successfully written our CrunchNumbers class to handle all expected outcomes from our Calculator class.

Pattern matching with Dry-Monads

Although the dry-matcher gem has proven useful, an alternative way to implement our code would be to match use of pattern matching in Ruby (for Ruby 2.7+).

In our lib/crunch_numbers.rb file, update the code to the following:

require 'calculator' require 'dry/matcher/result_matcher' class CrunchNumbers include Dry::Monads[:result] def crunch(calculator:, input:) case calculator.call(input: input) in Success(Integer => response) then "Yay! Value is #{response}" in Failure(:less_than_one) then 'Nay less than one' in Failure(:not_even) then 'Nay not even' end end end

If we run RSpec again, we can see that we are still successful.

bundle exec rspec /Users/dennisokeeffe/code/blog-projects/demo-dry-monads-matcher/lib/crunch_numbers.rb:22: warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby! ... Finished in 0.00407 seconds (files took 0.19054 seconds to load) 3 examples, 0 failures

It is up to you which method you prefer, but I must admit that I find the pattern matching much more succinct and readable (as well as one less gem).

Handling errors

When pattern matching, we can handle specific errors by rescuing them and returning them as a failure.

Note: if you rescue StandardError, it may cause unexpected behavior for your other failure monads.

For example, our current Calculator class can still failure if we do not pass a valid integer. To handle this argument error, we could update our Calculator class as follows:

require 'dry/monads' class Calculator include Dry::Monads[:result, :do] def call(input:) value = Integer(input) value_add_3 = yield add_3_if_gt_1(value) value_mult_2 = yield mult_2_if_even(value_add_3) Success(value_mult_2) rescue ArgumentError => e Failure(e) end # ... rest omitted end

Our CrunchNumbers class could now look like so:

require 'calculator' require 'dry/matcher/result_matcher' class CrunchNumbers include Dry::Monads[:result] def crunch(calculator:, input:) case calculator.call(input: input) in Success(Integer => response) then "Yay! Value is #{response}" in Failure(:less_than_one) then 'Nay less than one' in Failure(:not_even) then 'Nay not even' # Handling the ArgumentError and assigning the error to a value in Failure(ArgumentError => e) then "Nay #{e.message}" end end end

Finally, we could add one last test to our spec to check this works as expected:

require 'crunch_numbers' require 'calculator' RSpec.describe CrunchNumbers do let(:calculator) { Calculator.new } describe '#crunch' do # ... valid context tests omitted context 'invalid input' do # ... previous tests omitted it 'returns `Nay invalid value for Integer(): "Hello"` when the return value is not a number' do expect(subject.crunch(calculator: calculator, input: 'Hello')).to eq('Nay invalid value for Integer(): "Hello"') end end end end

Finally, we can check everything works as expected by running our tests one more time:

bundle exec rspec /Users/dennisokeeffe/code/blog-projects/demo-dry-monads-matcher/lib/crunch_numbers.rb:22: warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby! .... Finished in 0.00423 seconds (files took 0.16233 seconds to load) 4 examples, 0 failures

Returning our errors as a failure monad enables us to continue following the concept of "function core, imperative shell" and our CrunchNumbers "shell" could then handle that failure to capture the error to our error logging system.

Summary

Today's post demonstrated how to use both the dry-matcher and dry-monads gems.

While the dry-matcher gem has its place, I am personally more attached to the elegance of using dry-monads along with pattern matching.

I will be visiting other Dry RB gems in upcoming posts to try out their features and see if their pros can be used in future Ruby code.

Resources and further reading

Photo credit: rgaleria

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on workingoutloud.dev, Den Dribbles and LandPad .

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.