We're working on something new! Hook Relay gives you Stripe-quality webhooks in minutes. Sign up for free today! Check out Hook Relay

Railway Oriented Programming In Rails Using Dry-Monads

It's not every day that you learn a new approach to error handling for Ruby. In this article, Abiodun walks us through a novel error-handling process called Railway Oriented Programming and shows us how to implement it with dry-rb's monads.

Error handling is a vital part of every program. It's important to be proactive about what errors might arise in the course of the implementation of a piece of code. These errors should be handled in a manner that ensures output is produced that properly describes each error and the stage of the application it occurred. Nevertheless, it's also important to achieve this in a manner that ensures your code remains functional and readable. Let's start by answering a question that you might already have: What is Railway Oriented Programming?

Railway Oriented Programming

A function that achieves a particular purpose could be a composition of smaller functions. These functions carry out different steps that eventually lead to achieving the final goal. For example, a function that updates a user's address in the database and subsequently informs the user of this change may consist of the following steps:

validate user -> update address -> send mail upon successful update

Each of these steps can either fail or be successful, and the failure of any step leads to the failure of the entire process, as the purpose of the function is not achieved.

Railway Oriented Programming (ROP) is a term invented by Scott Wlaschin that applies the railway switch analogy to error handling in functions like these. Railway switches (called "points" in the UK) guide trains from one track to another. Scott employs this analogy in the sense that the success/failure output of each step acts just like a railway switch, as it can move you to a success track or a failure track.

Success and Failure Track Illustration Success/failure outputs acting as a railway switch

When there is an error in any of the steps, we are moved to the failure track by the failure output, thereby by-passing the rest of the steps. However, when there is a success output, it is connected to the input of the next step to help lead us to our final destination, as shown in the image below.

Connected switches illustration Success/failure outputs of several steps chained together

This two-track analogy is the idea behind Railway Oriented Programming. It strives to return these success/failure outputs at every step of the way (i.e., every method that is part of a process) to ensure that a failure in one step is a failure of the entire process. Only the successful completion of each step leads to overall success.

An Everyday Life Example

Imagine that you have a goal to purchase a carton of milk in person from a store named The Milk Shakers. The likely steps involved would be as follows:

Leave your house -> Arrive at The Milk Shakers -> Pick up a carton of milk -> Pay for the carton of milk

If you can't get out of your house, the entire process is a failure because the first step is a failure. What if you did get out of the house and went to Walmart? The process is still a failure because you didn't go to the designated store. The fact that you can get milk from Walmart doesn't mean the process will continue. ROP stops the process at Walmart and returns a failure output letting you know that the process failed because the store was not The Milk Shakers. However, if you had gone to the correct store, the process would have continued, checking the outputs and either ending the process or proceeding to the next step. This ensures more readable and elegant error handling and achieves this efficiently without if/else and return statements linking the individual steps.

In Rails, we can achieve this two-track railway output using a gem called Dry Monads .

Introduction to Dry Monads and How They Work

Monads were originally a mathematical concept. Basically, they are a composition or abstraction of several special functions that, when used in a code, can eliminate the explicit handling of stateful values. They can also be an abstraction of computational boilerplate code needed by the program logic. Stateful values are not local to a particular function, a few examples include: inputs, global variables, and outputs. Monads contain a bind function that makes it possible for these values to be passed from one monad to another; therefore, they are never handled explicitly. They can be built to handle exceptions, rollback commits, retry logic, etc. You can find more information about monads here .

As stated in its documentation, which I advise you review, dry monads is a set of common monads for Ruby. Monads provide an elegant way to handle errors, exceptions, and chaining functions so that the code is much more understandable and has all the desired error handling without all the ifs and elses. We would be focusing on the Result Monad as it is exactly what we need to achieve our success/failure outputs we talked about earlier.

Let's start a new Rails app titled Railway-app using the following command:

rails new railway-app -T

The -T in the command means that we will skip the test folder since we intend to use RSpec for testing.

Next, we add the needed gems to our Gemfile: gem dry-monads for the success/failure results and gem rspec-rails in the test and development group as our testing framework. Now, we can run bundle install in our app to install the added gems. To generate our test files and helpers, though, we need to run the following command:

rails generate rspec:install

Divide a Function Into Several Steps

It is always advisable to divide your function into smaller methods that work together to achieve your final goal. The errors from these methods, if any, help us identify exactly where our process failed and keep our code clean and readable. To make this as simple as possible, we will construct a class of a Toyota car dealership that delivers a car to a user if the demanded model and color are available, if the year of manufacture is not before the year 2000, and if the city to be delivered to is in a list of nearby cities. This should be interesting.  :)

Let's start by dividing the delivery process into several steps:

  • Verify that the year of manufacture is not before year 2000.
  • Verify that the model is available.
  • Verify that the color is available.
  • Verify that the city to be delivered to is a nearby city.
  • Send a message stating that the car will be delivered.

Now that we have the different steps settled, let's dive into the code.

Inputting Success/Failure Output Results

In our app/model folder, let's create a file called car_dealership.rb and initialize this class with the important details. At the top of the file, we have to require dry/monads, and right after the class name, we have to include DryMonads[:result, :do] . This makes the result monad and the do notation (which makes possible the combination of several monadic operations using the yield word) available to us.

require 'dry/monads'

class CarDealership

include Dry::Monads[:result, :do]

  def initialize
    @available_models = %w[Avalon Camry Corolla Venza]
    @available_colors = %w[red black blue white]
    @nearby_cities = %w[Austin Chicago Seattle]
  end
end

Next, we add our deliver_car method, which will consist of all the other steps involved and return a success message if all the steps are successful. We add the yield word to combine or bind these steps to one another. This means that a failure message in any of these steps becomes the failure message of the deliver_car method, and a success output in any of them yields to the call of the next step on the list.

def deliver_car(year,model,color,city)
  yield check_year(year)
  yield check_model(model)
  yield check_city(city)
  yield check_color(color)

  Success("A #{color} #{year} Toyota #{model} will be delivered to #{city}")
end

Now, let's add all the other methods and attach success/failure results to them based on the results of their checks.

def check_year(year)
  year < 2000 ? Failure("We have no cars manufactured in year #{year}") : Success('Cars of this year are available')
end

def check_model(model)
  @available_models.include?(model) ? Success('Model available') : Failure('The model requested is unavailable')
end
def check_color(color)
  @available_colors.include?(color) ? Success('This color is available') : Failure("Color #{color} is unavailable")
end

def check_city(city)
  @nearby_cities.include?(city) ? Success("Car deliverable to #{city}") : Failure('Apologies, we cannot deliver to this city')
end

We currently have our class and all the methods we need. How would this play out? Let's find out by creating a new instance of this class and calling the deliver_car method with different arguments.

good_dealer = CarDealership.new

good_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
#Failure("We have no cars manufactured in year 1990")

good_dealer.deliver_car(2005, 'Rav4', 'red', 'Austin')
#Failure("The model requested is unavailable")

good_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
#Failure("Color yellow is unavailable")

good_dealer.deliver_car(2000, 'Venza', 'red', 'Surrey')
#Failure("Apologies, we cannot deliver to this city")

good_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
#Success("A blue 2000 Toyota Avalon will be delivered to Austin")

As shown above, the failure result of the deliver_car method varies depending on the method at which it fails. The failure of that method becomes its failure, and upon the success of all methods, it returns its own success result. Also, let's not forget that these steps are individual methods that can also be called independently of the deliver_car method. An example is shown below:

good_dealer.check_color('wine')
#Failure("Color wine is unavailable")

good_dealer.check_model('Camry')
#Success('Model available')

Testing with RSpec

To test the above code, we go to our spec folder and create a file car_dealership_spec.rb in the path spec/models. On the first line, we require our 'rails_helper'. We will write tests for the context of failure first and then success.

require 'rails_helper'

describe CarDealership do
  describe "#deliver_car" don
    let(:toyota_dealer) { CarDealership.new }
    context "failure" do
      it "does not deliver a car with the year less than 2000" do
        delivery = toyota_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'We have no cars manufactured in  year 1990'
      end

       it "does not deliver a car with the year less than 2000" do
        delivery = toyota_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'Color yellow is unavailable'
      end
   end
 end
end

As shown above, we can access the failure or success results using result.failure or result.success . For a context of success, the tests would look something like this:

context "success" do
  it "delivers a car when all conditions are met" do
    delivery = toyota_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
    expect(delivery.success).to eq 'A blue 2000 Toyota Avalon will be delivered to Austin'
    expect(delivery.failure).to eq nil
  end
end

Now, you can add other tests in the failure context by tweaking the supplied arguments to the deliver_car method. You can also add other checks in your code for situations where an invalid argument is provided (e.g., a string is provided as a value for the year variable and others like it). Running bundle exec rspec in your terminal runs the tests and shows that all tests pass. You basically don't have to add checks in your test for failure and success results at the same time, as we cannot have both as the output of a method. I have only added it to aid in the understanding of what the success result looks like when we have a failure result and vice versa.

Conclusion

This is just an introduction to dry-monads and how it can be used in your app to achieve Railway Oriented Programming. A basic understanding of this can be further applied to more complex operations and transactions. As we have seen, a cleaner and more readable code is not only achievable using ROP, but error handling is detailed and less stressful. Always remember to attach concise failure/success messages to the different methods that make up your process, as this approach aids in identifying where and why an error occurred. If you would like to get more information about ROP, we recommend watching this presentation by Scott Wlaschin.

Honeybadger has your back when it counts. We're the only error tracker that combines exception monitoring, uptime monitoring, and cron monitoring into a single, simple to use platform.

Our mission: to tame production and make you a better, more productive developer. Learn more

author photo

Abiodun Olowode

Abiodun is a Full Stack Engineer at LaunchPad Recruits working with Ruby/Rails and React. She loves sharing her knowledge via writing and spends her free time singing or watching football games.


“We’ve looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.”
Michael Smith
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you’re paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Error Monitoring Free for 15 Days
"Wow — Customers are blown away that I email them so quickly after an error."
Chris Patton
Try Error Monitoring Free for 15 Days