Monads in Ruby
The concept of Railway Oriented Programming
Introduction
In programs, many times we have to deal with data flows. In such processes, we often have to validate input arguments that are received from other parts of the overall flow. However, complex structures can require complex ways of validation, which in turn can lead to code that is difficult to manage.
In this article, we will look at monads and the idea of Railway Oriented Programming. These two elements help build consistent and scalable processes that comprise multiple steps.
Welcome to If/Else hell
To illustrate the issue, let’s assume the existence of a certain service. This service deals with the shipping of packages. Its implementation could look like this:
class SendPackage
def call(package_id, send_from_id, send_to_id)
package = Package.find_by(id: package_id)
return false unless package
awb = package.awb
return false unless awb
send_to_location = Location.find_by(id: send_to_id)
return false unless send_to_location
send_from_location = Location.find_by(id: send_from_id)
return false unless send_from_location
send_to_address = send_to_location.address
send_from_address = send_from_location.address
if !(send_to_address.nil? || send_from_address.nil?)
# returns True or raises an error PackageCantBeSent
package.send!(
send_to_address:,
send_from_address:,
awb:
)
else
false
end
rescue PackageCantBeSent => e
return false
end
end
result = SendPackage.new.call(1, 2, 3)
if result
puts 'Package sent'
else
puts 'Packages not sent'
end
As we can see from the example above, there is a lot of code that focuses on protecting against the use of nil values. Without this, there is a possibility that some part of the service will raise an error. This approach is rather illegible. In addition, expanding this service with more elements will increase the number of if/else expressions.
The Ruby language and the Ruby on Rails framework have introduced some functions or expressions of the language itself to simplify working with system elements that can return a nil value. We have the “.try(…)” and “presence” functions from the Active Support Core Extension, or the “&” operator introduced in Ruby 2.3. Using any of these could greatly simplify the code above. We, however, will take a different approach. We will use monads from the dry-monads library.
Monads in Ruby
What are monads? This concept comes from category theory. It can be defined as “a monoid in a category of endofunctors.” To understand the definition itself would require knowledge of some basic concepts of category theory. However, the possibility of using monads in programming does not require knowledge of the theory behind them. The concept of a monad can be considered from the point of view of functional programming. They exist, for example, in Haskell or Scala.
Ignoring the strict definition of monads, we can say that it is a certain scheme of function structure, which assumes a consistent way of returning values. The result of the function carries information about the success or failure of the operation, along with an optional payload. This consistency allows functions to be combined into chains of calls, without having to constantly verify the result of individual components. If one component of the process returns a failure, subsequent steps will not attempt to execute with the empty/error result of the previous function.
There are several structures in the dry-monads library that we can call monads. They serve different purposes, but can also be used interchangeably in certain cases. We will discuss a few of them, moving from the most basic case to structures that allow advanced error handling.
The Maybe monad
One of the examples of monads available in the dry-monads library that we will discuss first is the Maybe monad. The values it returns are Some and None. Some can be interpreted as the positive result of a given operation. None, on the other hand, will appear if you try to handle a nil value. Let’s try to refactor the example from the beginning of the article using the Maybe monad.
class SendPackage
# [1]
include Dry::Monads[:maybe]
def call(package_id, send_from_id, send_to_id)
# [2]
find_package(package_id).bind do |package|
# [3]
Maybe(package.awb).bind do |awb|
# [4]
find_address(send_to_id).bind do |send_to_address|
# [5]
find_address(send_from_id).fmap do |send_from_address|
# [6]
package.send!(
send_to_address:,
send_from_address:,
awb:
)
end
end
end
end
rescue PackageCantBeSent => e
return None()
end
private
def find_address(location_id)
find_location(location_id).maybe(&:address)
end
def find_package(package_id)
Maybe(Package.find_by(id: package_id))
end
def find_location(location_id)
Maybe(Location.find_by(id: location_id))
end
end
SendPackage.new.call(1, 2, 3).value_or('Package not sent')
As you can see from the above, using Maybe allows you to remove the if/unless/else expressions entirely. The Maybe constructor returns a value of Some or None depending on whether a value or nil is passed to it, accordingly. In addition, the values returned by Maybe can be combined into a chain using the maybe function (see the find_address function). This is very similar to the use of the & operator.
Let’s now discuss the specific steps:
[1] Attaching this module to a class allows the use of Maybe(…), Some(…) and None().
[2] If find_package returns Some(package), the block passed to the bind command is called. Otherwise, None is returned.
[3] If package.awb is nil, Maybe returns None. Otherwise, it returns Some(package.awb) and the block passed to the bind function is called.
[4] The behavior of the function and block is the same as in [2].
[5] Using the fmap function instead of bind allows the block to surround the return value with the Maybe constructor. This means that the value returned by fmap will have a value of Some(…) or None and will be ready to be further merged into the call chain.
[6] There is no need to check if the values passed to send! are empty. Using bind and Maybe ensures that if this block is called, the passed parameters are valid.
The code using the Maybe monad is shorter. It also provides a consistent way to return information from our service. This would allow us to create call chains without having to constantly check whether the operation of our service was successful.
class HandlePackage
def call(...)
SendPackage.new.call(...).bind do |package|
SendNotification.new.call(package:).fmap do |notification|
SaveToLog.new.call(notification:)
end
end
end
end
HandlePackage.new.call(...).value_or('Problem with package')
Using Maybe monads, however, will not always be a more readable way to write code than if/else. Nested operations are prone to errors. In addition, we do not get information about the cause of the error. This can be solved by using another monad called Result and an approach called do-notation.
The Result monad
The Result monad, unlike Maybe, returns Success and Failure values. Each of these values allows a payload to be returned, which can then be read by the service caller.
class SendPackage
include Dry::Monads[:result]
def call(package_id, send_from_id, send_to_id)
find_package(package_id).bind do |package|
find_awb(package).bind do |awb|
find_address(send_to_id).bind do |send_to_address|
find_address(send_from_id).fmap do |send_from_address|
package.send!(
send_to_address:,
send_from_address:,
awb:
)
end
end
end
end
rescue PackageCantBeSent => e
return Failure(e.message)
end
private
def find_awb(package)
if package.awb
Success(package.awb)
else
Failure("AWB for #{package} not found")
end
end
def find_address(location_id)
find_location(location_id).bind do |location|
if location.address
Success(location.address)
else
Failure("Address for #{location_id} not found")
end
end
end
def find_package(package_id)
package = Package.find_by(id: package_id)
if package
Success(package)
else
Failure("Package with #{package_id} not found")
end
end
def find_location(location_id)
location = Location.find_by(id: location_id)
if location
Success(location)
else
Failure("Location with #{location} not found")
end
end
end
result = SendPackage.new.call(1, 2, 3)
# [1]
if result.success?
puts "Package has been sent"
# [2]
puts "send! returned #{result.success}"
else
# [3]
puts result.failure
end
The call method looks basically the same. We will focus on the supporting methods. Instead of the Maybe value, Success or Failure is returned. They, too, carry a payload. This is the value that is desired or an error message. As in the Maybe monad, Success and Failure also here have interpretations of success and failure properly, which is rather obvious looking at the names of these constructors. For a block in the bind and fmap functions to be executed, the functions must return Success.
The handling of the service call is significantly different:
[1] If the calling function returns Success(value), success? returns true. The opposite of this function is failure?
[2] To access the payload carried by the Success constructor, use the success method.
[3] If an error occurs, success? will return false. The code to handle this error is then executed. In this case, the content of the payload carried by Failure(message) is displayed.
The do-notation
Earlier we mentioned do-notation, which was introduced in version 1.0 of the dry-monads library. Its use gets rid of nesting, which greatly simplifies work with monads. The approach is based on the “yield” keyword, which behaves similarly to the bind method. A demonstration of the use of do-notation can be found below.
class SendPackage
include Dry::Monads[:do, :result]
def call(package_id, send_from_id, send_to_id)
package = yield find_package(package_id)
awb = yield find_awb(package)
send_to_address = yield find_address(send_to_id)
send_from_address = yield find_address(send_from_id)
Success(
package.send!(
send_to_address:,
send_from_address:,
awb:
)
)
rescue PackageCantBeSent => e
return Failure(e.message)
end
private
[...]
end
result = SendPackage.new.call(1, 2, 3)
[...]
The first change can be found in the definition of module inclusion. It has been updated with “:do”. Now let’s look at the yield expression. If the method passed to yield returns Success(value), the entire expression returns the load carried by this constructor (that is, value). Otherwise, the call is aborted and Failure is returned. It’s easy to see that the code looks like it was written sequentially. You just have to get used to the way yield works and the fact that any line can abort a call method.
The Try monad
Monads also allow us to handle exceptions that are raised by external functions. For this purpose, we can use the Try monad.
class SendPackage
include Dry::Monads[:do, :result, :try]
def call(package_id, send_from_id, send_to_id)
package = yield find_package(package_id)
awb = yield find_awb(package)
send_to_address = yield find_address(send_to_id)
send_from_address = yield find_address(send_from_id)
Try[PackageCantBeSent] do
package.send!(
send_to_address:,
send_from_address:,
awb:
)
end.to_result
end
private
[...]
end
result = SendPackage.new.call(1, 2, 3)
[...]
By default, Try handles all exceptions. However, this is considered an anti-pattern. Therefore, we should always define a list of exceptions to be handled. Try returns Value when no exception occurred or Error when such an exception occurred. However, we can easily convert the result from the Try domain to the to_result function.
Railway Oriented Programming
The use of monads to handle errors and empty values is not just a visual simplification of code. The features of monads allow the use of a certain style of programming, which is called Railway Oriented Programming. This concept was introduced by Scott Wlaschin.
Success and failure as rails
Railway Oriented Programming assumes the existence of paths that programs can follow. In the simplest case, there would be two paths — Success and Failure. The occurrence of Success or Failure at some point in the process directs it to the appropriate path. Handling of the final result occurs on the last high-level call. Just like in a railway— the paths mentioned are tracks, and the functions that return Success or Failure are switches.
The above analogy is well illustrated by the following figure:
This approach allows easy composition of parts of the process. Adding new elements, composition or their removal is analogous to managing switches on tracks. We will always connect to the tracks of success and failure. In turn, both tracks always lead to the same goal — a high-level call result.
Handling result
We are not limited to handling only Success/Failure type results. As we mentioned earlier Success and Failure can carry payload. This can be a message, error codes or created/updated objects. For the example, let’s take a look at the HandlePackage call again:
result = HandlePackage.new.call(...)
if result.success? && result.success == true
puts 'Everything is fine'
elsif result.success? && result.success.is_a?(String)
puts "Everything is fine but with message #{result.success}"
elsif result.success?
puts "Everything is fine but with object #{result.success}"
elsif result.failure? && result.failure.is_a?(Symbol)
puts "Error code: #{result.failure}"
elsif result.failure? && result.failure.is_a?(ActiveRecord::RecordInvalid)
puts "Errors from object: #{result.failure.record.errors}"
else
puts "Unknown error: #{result.failure}"
end
As we can see from the above, handling the result of the entire process is done in one place. It is worth mentioning at this point that starting from version 1.3 dry-monads supports a tool called pattern matching introduced in Ruby 2.7. This allows you to simplify the code handling the result of the process.
result = HandlePackage.new.call(...)
case result
in Success(Boolean)
puts 'Everything is fine'
in Success(String => m)
puts "Everything is fine but with message #{m}"
in Success(_)
puts "Everything is fine but with object #{_}"
in Failure(Symbol => s)
puts "Error code: #{s}"
in Failure(ActiveRecord::RecordInvalid => e)
puts "Errors from object: #{e.record.errors}"
in Failure(_)
puts "Unknown error: #{_}"
end
This approach gives us the freedom to interpret the result as we wish. This allows us to handle all cases of a business process in one place. At the same time, the internal implementations of each step are simple — they always return an obvious value — Success or Failure.
Summary
Monads and Railway Oriented Programming are interesting parts of the programming world. They are great solutions for code that repeatedly deals with error handling or empty values. However, like any approach, they should be used with caution and only when they are really needed. In order to use them correctly, it is worth learning more about them. A great source is certainly the dry-monads documentation. To learn more about Railway Oriented Programming, it is best to start with an article written by Scott Wlaschin — “F# for Fun and Profit”. Thanks for reading!
Words by Piotr Jezusek, Senior Engineer
Editing by Kinga Kuśnierz, Content Writer
Materials: