Applicative programming in Ruby: railway reimagined
Part 1 | Part 2
In this post we will see how applicative programming can be used for implementing code in the Railway style using a gem applicative-rb.
Railway programming is a common patten for scenarios when you have a sequence of steps, which are executed in a given order. When everything is good—you get a successful result, if one step fails—you don’t perform the remaining ones and return the failure. In other words—it’s a fancy way of error–handling.
Imagine a service that process an order:
- we deduct money from user account;
- we check that the required item is in the stock;
- we update order status.
This is how we can do it with a DSL similar to well–known dry-monads:
class ProcessOrder
def initialize(order) = @order = order
def perform
result = ApplicationRecord.transaction do # start the transaction
deduct_from_user_account.bind {
prepare_shipment.bind { # we get here only when previous step returned success
update_order_status
}
}.tap do |result|
# rollback in case of failure
raise ActiveRecord::Rollback.new(result.failure) if result.failure?
end
end
end
private
def deduct_from_user_account
if @order.user.balance > @order.amount
@order.user.deduct_amount(@order.amount)
# we return success without explanation
Right()
else
# we return failure with the error message inside
Left("cannot deduct #{@order.amount}, user has #{@order.user.balance}")
end
end
def prepare_shipment
@order.item_id == 42 ? Right() : Left("not enough items in warehouse")
end
def update_order_status
@order.processed!
Right()
end
end
To make things work each step should return either success (Right
) or failure (Left
). The behavior itself depends on the container: the decision what to do is made inside the #bind
method.
Container Either
Let’s step away from the service object for now and focus on the container. The container is called Either, and it can be implemented in the following way:
class Either
class Left < Either
attr_reader :error
def initialize(error) = @error = error
def deconstruct = [@error]
end
class Right < Either
attr_reader :value
def initialize(value) = @value = value
def deconstruct = [@value]
end
end
Right
means a successful result and might hold some value, Left
means some failure with the error message inside. As you see, it’s very similar to well–known Maybe
, but can hold a value inside.
Let’s see it in action, fetch_email
is a method to get the email by user ID:
def fetch_email(user_id)
if user_id == 42
Either::Right.new("john.doe@example.com")
else
Either::Left.new("User #{user_id} not found")
end
end
def format_email(either_email)
case either_email
in Either::Right(email) # here we unpacked email string from the container
Either::Right.new("Email: #{email}") # here we pack new string back to the container
in left
left # return container as is
end
end
format_email(fetch_email(42)) # => #<Either::Right:… @value="Email: john.doe@example.com">
format_email(fetch_email(1)) # => #<Either::Left:… @error="User 1 not found">
How we work with the value inside the container? You have to unpack it if possible (there’s no need to change the error value), work with value and pack back.
Looks like there will be a lot of code where we pack and unpack things! Can we avoid it?
Functors
There is a nice abstraction for it called functor:
module Functor
# (a -> b) -> f a -> f b
def fmap(&_fn) = raise NotImplementedError
end
Functor interface has one function, we will call it fmap
, like it’s called in Haskell. Ruby does not have types, so we cannot add a signature to the code. Let’s use a Haskell notation, since it’s pretty readable (a -> b) -> f a -> f b
.
In other words, it should pass the function that transforms a value of type a
to a value of type b
. #fmap
will call this function on the data in the container, which has a type f a
(f
is “functor”). As a result, a value of type f b
will be returned.
Read more about Functors in my Haskell post
Here is the example implementation for Either:
class Either
# ...
include Functor
def fmap(&fn)
case self
in Functor::Right(value) then Functor::Right(fn.curry.(value))
in left then left
end
end
end
With this function format_email
can be simplified:
def fetch_email(user_id)
if user_id == 42
Either::Right.new("john.doe@example.com")
else
Either::Left.new("User #{user_id} not found")
end
end
def format_email(either_email) = either_email.fmap { |email| "Email: #{email}" }
Proper functor should follow some rules:
- If
identity
(def identity(value) = value
) is passed, thanfmap
should return value without changes; fmap (f . g) == fmap f . fmap g
, where.
is a composition of functions.
Applicative functors
This approach works great for functions with only one argument. But what if we two arguments or more? Thanks to curry, functions can take less arguments and return another function:
def sum(x, y) = x + y
Either::Right.new(42).fmap(&method(:sum))
# => #<Either::Right:... @value=#<Proc:... (lambda)>>
We can call this function with another argument and get the result:
Either::Right.new(42).fmap(&method(:sum)).value.(1) # => 43
How to make it more readable?
Applicative functor interface is the extension of Functor
and contains two more methods:
pure
that returns most simple container with value;^
takes the function from the first container and applies it to the value stored in the second container.
Applicative functors also have some laws, but they are a bit more complex so we will not discuss them here. Let’s assume that all the implementations in the post are valid.
This is how interface looks like:
module Applicative
include Functor # applicative functor should have same methods as functor
def self.included(klass)
# a -> f a
klass.extend(Module.new do
def pure(_value) = raise NotImplementedError
end)
end
# a -> f a
def pure(value) = self.class.pure(value)
# f (a -> b) -> f a -> f b
def ^(_other) = raise NotImplementedError
end
Let’s try to use it for Either
:
class Either
# ...
include Applicative
def self.pure(value) = Right.new(value)
def ^(other)
case self
in Right(fn) then other.fmap(&fn)
in left then left
end
end
end
Note that things will happen only if both containers are Right
, otherwise it will just keep the error.
Let’s rewrite format_email
one more time:
def format_email(either_email)
add_label = lambda { |label, email| "#{label}: #{email}" }
Either.pure(add_label) ^ Either::Right.new("Email") ^ either_email
end
Why is it useful? It makes curry
“safe”, because we can describe a “golden path”. Error will be propagated because of the container semantics: we agreed that Left
should be kept as is without changes. If you have some steps connected with ^
and one of them returns Left
, all steps to the right won’t even happen and the Left
will be returned.
If it’s stilly blurry—check out this post with pictures
However, there is a small downside: each argument for the function we want to curry safely (Either.pure(add_label)
) should be calculated independently, because these calculations cannot see each other. This is what monads were invented for.
Monads
We discussed monads briefly in the beginning, and I could not stop myself from implementing monads from scratch. We are not going to dig dip into the theory, and jump right to the practice.
Unlike applicative functors, monads can access the data form the previous steps. In order to make something monad you need to implement only two methods return
and bind
:
module Monad
include Applicative # monad should have same methods as applicative functor
def self.included(klass)
klass.extend(Module.new do
# a -> m a
def returnM(value) = pure(value)
end)
end
# m a -> (a -> m b) -> m b
def bind(&fn) = raise NotImplementedError
end
Check out the source here. As you see, returnM
does the same thing as we did in Applicative#pure
, while bind
is more interesting: it accepts the block, calls it with the current value in the container (if it makes sense, as usual), and returns the result.
We will go with
returnM
causereturn
is a reserved word in Ruby
Compare signatures of Applicative#^
and Monad#bind
:
Applicative#^
:f (a -> b) -> f a -> f b
Monad#bind
:m a -> (a -> m b) -> m b
.
Note the difference: in the Applicative#^
we applied a function inside the container to the value inside the container, while in Monad
we pass the function to transform the unpacked value of type a
to the value of type b
packed to the container m
.
Let’s see how to do it for Either
:
class Either
include Monad
def bind(&fn)
case self
in Right(value) then fn.(value)
in left then left
end
end
end
Let’s change fetch_email
: now it can also return the invalid email, so we need to validate it before formatting:
def fetch_email(user_id)
case user_id
when 42 then Right("john.doe@example.com")
when 666 then Right("invalid")
else Left("User #{user_id} not found")
end
end
def validate(email)
email.include?("@") ? Either::returnM(email) : Left("invalid email")
end
def format_email(email) = Right("Email: #{email}")
We can write a function that fetches and validates the email using the monad interface of Either
:
def fetch_validate_and_format(user_id)
fetch_email(user_id).bind { |email|
# we get here only if `fetch_email` returned Right,
# but email is a String, not Either!
validate(email).bind { |validated_email|
format_email(validated_email)
}
}
end
fetch_validate_and_format(42) # => #<Either::Right:… @value="Email: john.doe@example.com">
fetch_validate_and_format(666) # => #<Either::Left:… @error="invalid email">
fetch_validate_and_format(1) # => #<Either::Left:… @error="User 1 not found">
Monads are kind of extension for Applicatives, and give us more features. Can we use them always? Not really, because it’s possible to create more applicatives than monads (check out this article to learn more on that).
Service object in applicative style
Let’s get back to the service objects. This is what we had in the beginning of the post:
class ProcessOrder
include Dry::Monads[:result]
def initialize(order) = @order = order
def perform
ApplicationRecord.transaction do
deduct_from_user_account.bind {
prepare_shipment.bind {
update_order_status
}
}.tap { |result|
raise ActiveRecord::Rollback.new(result.failure) if result.failure?
}
end
end
private
def deduct_from_user_account
# ...
end
def prepare_shipment
# ...
end
def update_order_status
# ...
end
end
Note that all three actions are completely independent, which makes it the ideal candidate for the applicative approach! Let’s update the service object itself:
class ProcessOrder < MultiStepService
def initialize(order) = @order = order
add_step :deduct_from_user_account
add_step :prepare_shipment
add_step :update_order_status
def deduct_from_user_account
# ...
end
def prepare_shipment
# ...
end
def update_order_status
# ...
end
end
Now we need to add the base class:
def identity(value) = value # the most helpful function ever
def Right(value = method(:identity)) = Either::Right.new(value) # but we need it to make application work
def Left(error = method(:identity)) = Either::Left.new(error)
class MultiStepService
class << self
def add_step(step) = steps << step
def steps = @steps ||= []
end
def perform
ApplicationRecord.transaction do
self.class.steps
.reduce(Right()) { |result, step| result ^ send(step) }
.on_error { |error| raise ActiveRecord::Rollback.new(error) }
end
end
end
#add_step
collects a list of methods to be called. #perform
reduces steps using the Right()
as the initial value. Right()
holds the function that just returns a value passed to it (identity
), which makes the application work: ^
will run steps until we execute them all or get the first error.
You can find the full example here.
So the only use of applicatives is error handling?
Nope! In this post we explored a single container called Either
(and Maybe
, because it’s almost the same thing), but there are many more data structures that can implement Functor
and Applicative
interfaces! Also, the implementation of the applicative functor that follows the laws can be either used as the argument of other functions or have some extensions (like Monad
). These way of combining functions can lead us to more complex and interesting calculations.
Outro
Railway programming gives us a clearer way to handle errors: we describe a golden path and errors are handled by the external code in the predictable way. It is usually implemented using Either
/Maybe
monads, but we saw how to replace them with applicative functors in cases when steps are completely independent.
There is a common mistake to think that monads and applicative functors can be used only for that. In the next post we will see how different data structures can implement the Applicative
interface and what interesting behavior they can provide to us.