My first encounter with Monadical writing was with the Haskell language. I immediately fell in love with the functional syntax. The challenge of the Haskell code I had to produce was simple: Never use "if-then-else." It was an incredible challenge.
My primary tool for avoiding "if-then-else" is Monadical writing.
So, first of all... What is a Monad?
A Monad is an abstraction that facilitates the management of sequences of complex operations by wrapping them in a specific context. This allows for clear and modular composition while maintaining centralized management of side effects.
Still sound vague? Let's take a very simple example:
# Maybe is defined in the gem "dry-monads". Will talk about it later :)
def find_user(user_id)
Maybe(
User.find_by(id: user_id)
).to_result(:user_not_found)
end
There you go!
Our function uses the Maybe monad. Specifically, Maybe works as follows:
- It evaluates its content, here
User.find(user_id)
. - If the return value is not
nil
, it returns a Success result, encapsulating that return value. - If the return value is
nil
, it returns a Failure result, encapsulating the error message:user_not_found
.
The goal of Monadical writing in Ruby is, concretely, to have functions that all return a Success / Failure result.
This is straightforward, but Success is the result type called in case of success, and Failure in case of failure.
So, if we look back at find_user
:
- If we find the User, the function returns a Success object containing the user.
- If we don't find the User, the function returns a Failure object containing the symbol
:user_not_found
.
Setting up Monads with Dry-monads
In this article, I will rely entirely on the implementation of monads by the dry-monads
gem. You can find the documentation here.
If you've never heard of the dry-rb
gem suite, I strongly encourage you to look into it. The philosophy of dry-rb is to guide you in writing simple, flexible, and maintainable code. It consists of 25 small gems, each bringing a simple yet incredibly powerful concept.
But let's get back to the topic at hand, dry-monads
.
Add the gem to your Gemfile with bundle add dry-monads
.
Consider the following class, which we will expand throughout the article:
require 'dry/monads'
class UpdateUserService
include Dry::Monads[:maybe, :do, :try]
def call(user_id:)
end
def find_user(user_id)
end
def update_user(user)
end
end
And there you go, we're ready to use dry-monads
!
Let’s play with Monads!
First, we'll explore three types of monads, and then we'll discuss the benefits of using monads in your Rails project.
First of all, how to use Results?
Maybe is the monad we saw in the introduction.
Let's go back to the previous example and see how to interact with it!
def find_user(user_id)
Maybe(User.find_by(id: user_id)).to_result(:user_not_found)
end
We'll call our method and analyze its return value based on whether we find the user.
When we find the user, we get a result:
result = find_user(1)
result.success? # => true
result.failure? # => false
result.failure # => nil
result.value! # => #<User 0x000....>
When we don't find the user:
result = find_user(0)
result.success? # => false
result.failure? # => true
result.failure # => :user_not_found
result.value! # => raises a Dry::Monads::UnwrapError
We get a Result
object, either of type Success
or Failure
, responding to several methods.
At this point, you should find this cool, but not quite enough to use it.
Wait and see 👀
Let's revisit the UpdateUserService
class and implement our find_user
method:
require 'dry/monads'
class UpdateUserService
include Dry::Monads[:maybe, :do, :try]
def call(user_id:)
result = find_user(user_id)
end
def find_user(user_id)
Maybe(User.find_by(id: user_id)).to_result(:user_not_found)
end
end
Now you might be thinking, the promise of "no more if-else" doesn't hold because a naive implementation would be:
def call(user_id:)
result = find_user(user_id)
return result.failure if result.failure?
user = result.value!
Success(user)
end
But there, we're using the power of monadic writing in the worst way possible.
Since find_user
returns a monad (Maybe), we can use the following syntax:
def call(user_id:)
find_user(user_id).bind do |user|
Success(user)
end
end
This writing does exactly the same thing, but without using any if
statements!
- If
find_user
returns a Failure, we stop execution, and thecall
function returns that Failure with the included error message. - If
find_user
returns a Success, we continue execution withuser
being what was encapsulated in the Success.
You can expose results of your monads with different notations:
In the previous example, we used .bind
on our Maybe monad. In reality, there are several ways to exploit the result of a Maybe.
There is .fmap
:
def call(user_id:)
find_user(user_id).fmap do |user|
user
end
end
In essence, it's the same as .bind
, but the return value of the block is automatically a Success.
There is the "Do notation" with the use of yield
:
def call(user_id:)
user = yield find_user(user_id)
Success(user)
end
This notation is very concise and remains clear when chaining .bind
/ .fmap
.
The bind, fmap, and do notations should be used as you see fit and depending on the context of use:
def bind_function
function_a.bind do
function_b.bind do
function_c.fmap do
'hello!'
end
end
end
end
def do_notation_function
yield function_a
yield function_b
value = yield function_c
Success(value)
end
In this use case, we prefer the second writing style, the Do notation.
You can rescue errors with monads!
In the monads I often use, there is also the Try monad. Specifically, Try allows you to encapsulate your begin rescue
block to have a
monad as output.
Let's see a stupid but simple example:
def update_user(user)
Try[ActiveModel::UnknownAttributeError] do
user.update!(attribute_that_does_not_exist: true)
end
end
With the explanations given earlier, you should have an idea of the behavior of this piece of code.
Let's look at it together when we execute the update_user
function:
- We execute the code in the block.
- If an exception is raised, we check if it belongs to the list of errors given as an argument to the Try monad.
- If the exception is known, we return a Failure with the error as content.
- If the exception is unknown, we raise the error.
- If no exception is raised, we return a Success with the return value of the block as content.
Similar to Maybe, Try has monadic functions like fmap, bind, the Do notation, and many other functions that I won't talk about in this article but are worth your interest.
One more thing
So far, we've seen monads independently. But in the definition given in the introduction, we talked about composition. Let's see how to compose with monads!
Let's say we want to integrate the following logic: After updating our user, we want to notify them of this update.
We really want to make sure that this notification is received.
For this, we will send a push notification, an SMS notification, and a notification to the company's Slack.
Fortunately for us, all these logics are already written in separate services, and they all return monads!
For our implementation, we want to call all the services, and if at least one fails, return a generic error.
A first implementation would be:
def notify_user(user)
result_1 = PushNotificationService.call(user: user)
result_2 = SmsNotifier.call(user: user)
result_3 = SlackNotifier.call(user: user)
[result_1, result_2, result_3].any? { |monad| monad.to_result.failure? }
end
Feel that there's something odd about it?
We're missing out on a wonderful feature of monads: chaining operators!
It's exactly the same concept as when you do your ActiveRecord queries with .where
.
We can use and
to achieve exactly the same behavior:
def notify_user(user)
PushNotificationService.call(user: user)
.and(SmsNotifier.call(user: user))
.and(SlackNotifier.call(user: user))
end
We evaluate each of the 3 services, and if at least one returns a Failure, then that Failure will be returned.
PushNotificationService.call(user: user) # will return Success
.and(SmsNotifier.call(user: user)) # will return Failure
.and(SlackNotifier.call(user: user)) # will return Success
# So we can simplify by
# => Success.and(Failure).and(Success)
In this case, since SmsNotifier will return a Failure, the entire function will return a Failure.
There are other chaining functions between your monads such as .or
, .maybe
, .filter
, and more.
I invite you once again to check the documentation and experiment to learn more.
TL;DR
Monads are:
- Abstractions that help reduce the conditional structure of your code.
- Useful for standardizing communication between your classes.
- Equipped with notations like
.bind
and.fmap
to concisely compose your function. - Endowed with chaining operators like
.and
or.or
to flexibly manage the composition of your monads.
Conclusion
Exploring Monadical writing in the context of Ruby offers a powerful perspective to enhance the elegance and simplicity of your code. By avoiding traditional conditional constructs, you adopt a more declarative and predictable approach to handling results and errors.
Monads, such as Maybe and Try, presented in this article, allow you to compose functions clearly and concisely while promoting standardized communication between your classes.
By integrating this approach into your Rails project, you can not only reduce code complexity but also promote a more modular and maintainable structure. In the end, monads emerge as essential tools for elegantly managing side effects, standardizing communication between different parts of your application, and creating robust and flexible processing pipelines.
I hope this article has inspired you to start writing monads in your Rails project and, most importantly, that the phrase:
-"A Monad is an abstraction that facilitates the management of sequences of complex operations by wrapping them in a specific context, allowing for clear and modular composition while maintaining centralized management of side effects."
holds no more secrets for you!
Top comments (10)
Hi,
although I too am fascinated by the abstraction of Dry::Monads, I share the suspicions of others about readability and introducing just more »syntax sugar«.
The monads-concept feels natural in rust, where its an integrated part of the language, but is a kind of artificial for a pure oop-language like ruby.
Rubyists must not fall into the same traps, perl developers did in the past, by just implementing anything form other languages just because its "Zeitgeist".
Its definitively not a substitute of if then else branching, however, in certain circumstances a smart enhancement .
Thanks for sharing!
Hi !
Returning a String, a Boolean, or an Integer do not give enough context. Using monad is a simple way to identify if it's a Success or a Failure, depending on your business logic.
There are other gems that help you to encapsulate the logic. The concept of encapsulating logic in order to give more context is not new.
The idea behind all that is to improve readability. I agree it's a new syntax. But once someone explained you / show you some example, it's very easily understandable.
Of course, you cannot just erase all the
if
in your code with using Monads, it is not THAT magic ahah. And for sure, I still use somereturn Failure() if xxx
.Let me know if you want to talk more about it, and maybe I can find some more complex codes example ?
Amazing didactics and introduction to the monad theme!
On the
Try
example, why should I use this method instead of just rescuing? seems strange to me since it's already an exception instead of a monadActually, the Try monads is just a mapping rescue
So it is "just" sugar syntax
Oh this is awesome actually, so it's possible to use this as a way to always have the provided monads
I like the implementation of getting rid of if-else statements but are you worried about readability for devs who’ve never experienced this kind of pattern?
Hi Daniel, thanks for your feedback !
IMO, Monad concept is not so hard to understand but hard to master.
When we introduced
dry-monads
and monads concepts to our peers at wecasa, the majority of them never used FP before. But in fact, people loved that instantly.As a friend said "
return Success()
is the newreturn true
".Once we show you basic examples, you may be able to understand the basic (as showed in the article).
The bind / fmap / do notation concept is something we needed to explain deeper because they mean nothing for an OO programmer.
But when you have the fundamentals (Maybe, Success, Failure, bind) in fact you're good to go. Harder concepts will come when you code a bigger architecture, or when you need to interact with bridges.
I'm a great fan of your content !
Keep up the great work
Hi Éric, that's very kind thanks <3
I keep fighting with dry-monads due to weird
yield
behaviours that conflicts with rescue and other annoying stuff. So I built to-result, a wrapper over dry-monads that offer theToResult
method. It's likeTry
but on steroids but you can use all thedry-monads
stuff at the same time 😎🚀