Mastering Monadical Syntax in Ruby: A Practical Guide to Functional Elegance
Tags: ruby, rails, tutorial, programming
Published: 2023-11-22 15:18:00

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:

```ruby
# 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:

1. It evaluates its content, here `User.find(user_id)`.
2. If the return value is not `nil`, it returns a **Success** result, encapsulating that return value.
3. 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](https://dry-rb.org/gems/dry-monads/1.6/).

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:

```ruby
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!

```ruby
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:

```ruby
result = find_user(1)
result.success? # => true
result.failure? # => false
result.failure # => nil
result.value! # => #<User 0x000....>
```

When we don't find the user:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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 the `call` function returns that Failure with the included error message.
- If `find_user` returns a Success, we continue execution with `user` 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`:

```ruby
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`:

```ruby
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:

```ruby
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:

```ruby
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:

1. We execute the code in the block.
2. If an exception is raised, we check if it belongs to the list of errors given as an argument to the Try monad.
3. If the exception is known, we return a Failure with the error as content.
4. If the exception is unknown, we raise the error.
5. 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:

```ruby
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:

```ruby
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.

```ruby
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!