Railway oriented programming is a design pattern which helps us handle errors in our applications. Instead of relying on exceptions, we design our data and functions in a specific way. Since applications are essentially just a combination of steps, we’ll make some design decisions about those steps and their structure:

There is a Result type, which can be either a Success or a Failure

type, which can be either a or a Success and Failure are practically containers with different data

and are practically containers with different data Steps accept Result and return Result

and return Once a step returns Failure , we stop further execution

I want to emphasize that Result is just an alternative name for the Either monad. Railway Oriented Programming comes from functional programming, so it is tightly related to the usual FP concepts like monads, composition, and many others. However, you don’t need to have an extensive knowledge of monads to use ROP in your code. In this article, I’ll show you how to write railway-oriented code in Ruby.

ROP with dry-transaction

There is a dry-transaction gem which provides a DSL to build railway-oriented business transactions. The core part of the gem is dry-monads which provides the Result type and tools to work with it.

To create a railway-oriented operation, we’ll need to do a few things:

Create a class and include Dry::Transaction

Define a few methods that return either Success or Failure

or Use step adapters to chain those methods together

Then, we can instantiate the class and pass any input to the #call method.

Here’s how it looks like:

class MyOperation include Dry :: Transaction include Dry :: Monads step :validate step :log step :persist def validate ( data ) if data . valid? Success ( name: data . name , age: data . user_age ) else Failure ( "something went wrong" ) end end def log ( name :, ** rest ) print ( "User name is #{ name } " ) Success ( name: name , ** rest ) end def persist ( name :, age :) ... # some business logic here ... Success ( name: name , age: age ) end end MyOperation . new . call ( ... ) # ^ can return either # Success(name: ..., age: ...) # or Failure("something went wrong")

As you can see, we use class-level step method to compose the validate , log and persist methods. Failure returned from validate halts the further execution.

Pros of the approach:

It’s plain Ruby

It allows us to reuse steps

It works!

Chained methods don’t need to unwrap the input

Cons of the approach:

The DSL has a weaker control over the program’s flow — we can’t have conditions unless we add a special step

The Result object that we pass around keeps accumulating data and becomes enormous, so we have to use **rest in our function signatures

object that we pass around keeps accumulating data and becomes enormous, so we have to use in our function signatures Database transactions were hard to implement until around step came around. Still awkward, though

dry-monads to the rescue

Since dry-transaction is based on dry-monads, we could probably build something ourselves, right?

Result has a few methods to help us chain those monads:

bind applies unwrapped Success value to the block, which should return a Result object. No-op on Failure

applies unwrapped value to the block, which should return a object. No-op on fmap is similar to bind , but wraps the returned value into Success

is similar to , but wraps the returned value into or is similar to bind , but only applies Failure values

is similar to , but only applies values or_fmap is similar to or , but wraps the returned value into Success

is similar to , but wraps the returned value into tee does the same thing as bind , but returns input if the result is a Success

does the same thing as , but returns input if the result is a success? and failure? tell us which kind of Result it is

and tell us which kind of it is value_or extracts the value from Success or returns fallback value

This is how the same example would look like using raw monads:

class MyOperation include Dry :: Monads def call ( data ) validate ( data ). bind ( method ( :log )). bind ( method ( :persist )) end def validate ( data ) if data . valid? Success ( name: data . name , age: data . user_age ) else Failure ( "something went wrong" ) end end def log ( name :, ** rest ) print ( "User name is #{ name } " ) Success ( name: name , ** rest ) end def persist ( name :, age :) ... # some business logic here ... Success ( name: name , age: age ) end end

The differences:

Plain methods instead of a DSL

Better control over flow of our application: more ways to add branching

However, there are some disadvantages to the approach

Having to use #method is hideous. Solution? Callable objects

is hideous. Solution? Callable objects We still have to pass all parameters to each function

parameters to each function Complex logic gets awkward as we add more steps to the chain

It doesn’t halt execution if a function returned a Failure , so we’ll have to work around that

Since 1.0.0.beta1 of dry-monads, there’s a solution to the problems: do notation.

Do notation in Ruby

When we work with monads and Result in particular, we have to constantly bind them together. When we introduce complex logic with conditions, we end up with nested binds, and those are hard to work with. Do notation provides an alternative to bind , which also flattens the code.

So we can replace this:

def call ( data ) validate ( data ). bind ( method ( :log )). bind ( method ( :log )) end

With that:

def call ( data ) validated_data = yield validate ( data ) yield log ( validated_data [ :name ]) persist ( validated_data ) # <= You don't need to `yield` the last expression end

or even that:

def call ( data ) validated_data = yield validate ( data ) log ( validated_data [ :name ]) persist ( validated_data ) end

Sounds cool, right? We had to use bind to chain those operations. Now we can just yield the steps that can fail and keep the code flat.

This is how our operation looks with do notation:

class MyOperation include Dry :: Monads include Dry :: Monads :: Do . for ( :call ) def call ( data ) validated_data = yield validate ( data ) log ( validated_data [ :name ]) persist ( validated_data ) end def validate ( data ) if data . valid? Success ( name: data . name , age: data . user_age ) else Failure ( "something went wrong" ) end end def log ( name ) print ( "User name is #{ name } " ) end def persist ( name :, age :) ... # some business logic here ... end end

The core points:

A new mixin: Dry::Monads::Do.for(:call)

yield halts the execution if the function returns Failure

halts the execution if the function returns No need to unwrap the monad: yield does it for us

does it for us log and persist no longer need to return Result as they don’t affect the flow

and no longer need to return as they don’t affect the flow We don’t have to stick to declarative style anymore

No need to yield the last expression — Ruby handles it for you

the last expression — Ruby handles it for you Last expression must return Result

Performance

The reason I wrote the article is that I wanted to benchmark do notation and compare its performance against dry-transaction.

The questions I wanted to answer:

Is do-notation faster than dry-transaction?

What are the performance differences between happy and not-so-happy paths?

What kind of performance drop do we have as we add more steps?

So I wrote a simple benchmark to test those things. Design decisions:

No IO or loops

Simple arithmetics is good enough

Objects behave like pure functions

The algorithm I tested looks as follows:

Multiply input by 2

If the result is greater than 100, return an error

Add 2

Total: 3 steps.

Benchmark output:

Warming up -------------------------------------- do-notation: happy 33.809k i/100ms do-notation: failure 14.274k i/100ms transaction: happy 5.878k i/100ms transaction: failure 5.867k i/100ms Calculating ------------------------------------- do-notation: happy 387.914k (± 1.4%) i/s - 1.961M in 5.056134s do-notation: failure 152.445k (± 1.7%) i/s - 770.796k in 5.057752s transaction: happy 59.981k (± 3.0%) i/s - 299.778k in 5.002999s transaction: failure 60.327k (± 1.5%) i/s - 305.084k in 5.058375s Comparison: do-notation: happy: 387913.7 i/s do-notation: failure: 152445.2 i/s - 2.54x slower transaction: failure: 60327.4 i/s - 6.43x slower transaction: happy: 59981.0 i/s - 6.47x slower

So what do we see:

dry-transaction performance isn’t really affected by failures

do notation becomes approximately 2.5 times slower if we get a Failure

Do notation is over six times faster than dry-transaction

Heavier benchmark

Alright, so we had a benchmark that worked with three steps that could theoretically return Failure . But real-world apps are way more complex than that. So I decided to add more steps and see what happens.

Algorithm:

Multiply input by 2

Add 2 three times

If the result is greater than 100, return an error

Add 2 four times

Total: 9 steps.

Benchmark output:

Warming up -------------------------------------- do-notation: happy 10.384k i/100ms do-notation: failure 9.282k i/100ms transaction: happy 2.084k i/100ms transaction: failure 2.083k i/100ms Calculating ------------------------------------- do-notation: happy 108.311k (± 1.3%) i/s - 550.352k in 5.082157s do-notation: failure 89.917k (± 6.9%) i/s - 454.818k in 5.086821s transaction: happy 21.047k (± 2.1%) i/s - 106.284k in 5.052038s transaction: failure 21.047k (± 1.5%) i/s - 106.233k in 5.048585s Comparison: do-notation: happy: 108310.5 i/s do-notation: failure: 89917.5 i/s - 1.20x slower transaction: happy: 21047.4 i/s - 5.15x slower transaction: failure: 21047.1 i/s - 5.15x slower

So what do we see here:

Happy path is not that much faster than not-so-happy path That’s because happy path still has to evaluate the remaining steps. It takes time.

dry-transaction still shows similar performance for both outcomes

dry-transaction is five times slower than do notation

Facts & conclusion

Railway Oriented Programming is a way to gracefully handle errors in your application

You can use dry-monads and dry-transactions to build railway-oriented services

Functions can return either Success or Failure , which form the Result monad

or , which form the monad Dry-transaction provides a DSL for railway oriented programming

Use bind , fmap , or and or_fmap to build railway-oriented code in Ruby

, , and to build railway-oriented code in Ruby Use do notation to have a better control over your program’s flow

Do notation is way faster and more flexible than dry-transaction

faster and more flexible than dry-transaction This approach is framework-agnostic: works with Rails, Hanami, Sinatra, dry-web-roda

Also, there’s a visible lack of documentation for dry-monads, so if you decide to give it a try, you are welcome to contribute!