Exploring Dry-RB With Dry Matcher And Dry Monads
Published: Feb 21, 2022
Last updated: Feb 21, 2022
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
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
Exploring Dry-RB With Dry Matcher And Dry Monads
Introduction