Using dry-monad
March 19, 2018
I originally wrote this as a getting started guide for other engineers at the company I was working at. I’m posting it here for archival purposes.
Imagine that we have an application where user’s have a location. We want to find the weather for their current location using an external service call. We want to provide this functionality in a json api. A basic implementation might look like this.
def create
user = User.find(params[:id])
location = user.location
if location
zip = location.zip_code
Circuit.circuit(:weather)
.command {
weather = WeatherAPI.current_weather(zip)
render json: { current_weather: weather }
}
.fallback { render json {errors: "Couldn't talk to weather"} }
.run
else
render json: { errors: "No location" }
end
rescue ActiveRecord::RecordNotFound => e
render json: { errors: "User not found" }
end
There are multiple places where this code can fail and depending on that failure we need to take different actions. We may want to halt execution or render specific errors.
An even more incidious problem is that none of the logic in this action is re-usable. All of our logic for gathering data, acting on that data, and returning results is coupled together.
We want to decouple the core logic of looking up a users weather report from the controller logic of rendering json. This allows our weather lookup code to be more reusable and easier to test. A first pass might look something like this.
module WeatherLookup
class Result
attr_reader :value
def initialize(value, success)
@value = value
@success = success
end
def success?
@success
end
end
def self.for_user(user_id)
user = User.find(user_id)
location = user.location
if location
zip = location.zip_code
Circuit.circuit(:weather)
.command {
weather = WeatherAPI.current_weather(zip)
Result.new(weather, true)
}
.fallback { Result.new("Weather api is down", false) }
.run
else
Result.new("No location", false)
end
rescue ActiveRecord::RecordNotFound => e
Result.new("No user", false)
end
end
def create
result = WeatherLookup.for_user(params[:id])
if result.success?
render json: { current_weather: result.value }
else
render json: { errors: result.value }
end
end
Our module can now return a Result
type and the controller can decide
what it wants to do with that result. What we’ve really done here is
inverted control of this operation and given the control back to the
caller. This refactor certainly cleans up the controller code. But our new
weather module could still use some work.
Results
It turns out that our Result
class is remarkably close to a very common
pattern. This pattern is nicely encapsulated in the dry-monads gem. Using
the gem we can re-factor our code like so.
def create
result = WeatherLookup.for_user(params[:id])
if result.success?
render json: { current_weather: result.value! }
else
render json: { errors: result.failure }
end
end
module WeatherLookup
def self.for_user(user_id)
Try(ActiveRecord::RecordNotFound) { User.find(params[:id]) }
.to_maybe
.fmap { |user| user.location }
.fmap { |location| location.zip_code }
.bind { |zip| get_weather(zip) }
end
def self.get_weather(zip)
Circuit.circuit(:weather)
.command { Success(WeatherAPI.current_weather(zip)) }
.fallback { Failure("The weather api is down") }
.run
end
end
Each of our seperate actions now returns either a Success
or
a Failure
. Both of these define a common interface which allows
operations to be composed together using the functions fmap
and bind
.
While these names aren’t very descriptive the functions are actually
straightforward.
fmap
allows you to take a value out of its context (in this case our
Result
), apply a function to the unwrapped value, and then place it back
inside the context.
bind
provides a way to take a Result
and apply a function on it that
returns a new Result
. In our example the WeatherAPI
call will return
a Success
or Failure
depending on if the circuit has been broken or
not. We can use bind
to compose this method with the result of getting
a users zip code.
The magic of this interface is that the blocks passed to fmap
and bind
will only be called if the Result
is a Success
. If the Result
is
a Failure
then both fmap
and bind
return the Failure
and won’t
yield the block. What this means in practice is that if any of our
operations fail the rest of the operations will be skipped and we’ll
return the original failure. For instance if a user doesn’t have
a location then we’ll return None
and skip looking up the zip code and
making a weather api call.
Interfaces and Monads
fmap
and bind
aren’t specific to the Result
type. They’re part of
a more generic interface known as a Monad. There are several other types
of monads including Maybe, List, and State. For our purposes we’ll mostly
be concerned with Results and Maybes.
The power of this interface is that it allows for easy composition of operations. If we wanted to provide clothing recommendations for a user based on their current weather we could do something like this:
def recommendations
result = WeatherLookup
.for_user(params[:id])
.bind { |weather| ProductRecommender.for_weather(weather) }
if result.success?
render json: { clothes: result.value! }
else
render json: { errors: result.failure }
end
end
Looking up a users weather is completely decoupled from any sort of product recommendations. Using our interface we’re able to compose and extend functionality without sacrificing the re-usability of these functions.
Conclusion
The Monad interface provides a powerful tool for composing small, side-effecting operations. My hope is that this gives you a better understanding of how they work and how we might benefit from them.
The documentation for the dry-monads gem is here: http://dry-rb.org/gems/dry-monads/
If you wanna learn more about the theory behind Functors and Monads then this is a great resource: http://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html.