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/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.
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.