1. Overview

In this tutorial, we’ll learn about monads, and how they can help us deal with effects. We’ll learn about the essential methods enabling us to chain monads and operations: map() and flatMap(). Throughout the article, we’ll explore the APIs of a few popular monads from the Java ecosystem, focusing on their practical applications.

2. Effects

In functional programming, “effects” typically refer to operations that cause changes beyond the scope of the function or component.

To apply the functional programming paradigm while dealing with these effects, we can wrap our operation or data inside a container. We can think of monads as containers that allow us to handle the effects outside the function’s scope, preserving the function’s purity.

For instance, let’s say we have a function that divides two integer numbers:

double divide(int dividend, int divisor) {
    return dividend / divisor;
}

Although it looks like a pure function, when we pass zero as the value for the divisor parameter, the function produces a side effect by throwing an ArithmeticException. However, we can use a monad to wrap the function’s result and contain its effect.

Let’s change the function and make it return an Optional instead:

Optional<Double> divide(int dividend, int divisor) {
    if (divisor == 0) {
        return Optional.empty();
    }
    return Optional.of(dividend / divisor);
}

As we can see, the function is no longer producing side effects when we try to divide by zero*.*

Here are a few other popular Java examples of monads that help us deal with various effects:

  • Optional<> – dealing with nullability
  • List<>, Stream<> – managing collections of data
  • Mono<>, CompletableFuture<> – dealing with concurrency and I/O
  • Try<>, Result<> – dealing with errors
  • Either<> – dealing with duality

3. Functors

When we create a monad, we need to allow it to change its encapsulated object or operation, while keeping the same container type.

Let’s take Java Streams as an example. If in the “real world”, an instance of type Long can be converted to an Instant by calling the method Instant.ofEpochSeconds(), this relation must be preserved in the world of Streams.

To achieve this, the Stream API exposes a higher-order function that “lifts” the original relationship. This concept is also known as a “functor” and the method that allows transforming the encapsulated type is typically named “map:

Stream<Long> longs = Stream.of(1712000000L, 1713000000L, 1714000000L);
Stream<Instant> instants = longs.map(Instant::ofEpochSecond);

Although “map” is the typical term for this function type, the specific method name itself isn’t essential for an object to qualify as a functor. For example, the CompletableFuture monad provides a method called thenApply() instead:

CompletableFuture<Long> timestamp = CompletableFuture.completedFuture(1713000000L);
CompletableFuture<Instant> instant = timestamp.thenApply(Instant::ofEpochSecond);

As we can see, both Stream and CompletableFuture containers expose methods that enable us to apply all the operations supported by the encapsulated data:

monad functor

4. Binding

Binding is a key characteristic of a monad that allows us to chain multiple computations in a monadic context. In other words, we can avoid double nesting by replacing map() with binding.

4.1. Nested Monads

If we solely rely on functors to sequence the operations, we’ll eventually end up with nested containers. Let’s use Project Reactor‘s Mono monad for this example.

Let’s assume we have two methods that allow us to fetch Author and Book entities reactively:

Mono<Author> findAuthorByName(String name) { /* ... */ }
Mono<Book> findLatestBookByAuthorId(Long authorId) { /* ... */ }

Now, if we start with the author’s name, we can use the first method and fetch his details. The result is a Mono:

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    // ...
}

After that, we may be tempted to use the map() method to change the content of the container from an Author to his latest Book:

Mono<Mono<Book>> book = author.map(it -> findLatestBookByAuthorId(it.authorId());

But, as we can see, this results in a nested Mono container. This happens because findLatestBookByAuthorId() returns a Mono while map() wraps the result yet another time.

4.2. flatMap()

However, if we use binding instead, we eliminate the extra container and flatten the structure. The name “flatMap” has been commonly adopted for the bind method, although there are a few exceptions where it’s called differently:

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    Mono<Book> book = author.flatMap(it -> findLatestBookByAuthorId(it.authorId()));
    // ...
}

We can now simplify the code a bit by in-lining the operations, and introducing an intermediate map() that translates from the Author to its authorId:

void findLatestBookOfAuthor(String authorName) {
    Mono<Book> book = findAuthorByName(authorName)
      .map(Author::authorId)
      .flatMap(this::findLatestBookByAuthorId));
    // ...
}

As we can see, combining map() and flatMap() is an efficient way of working with monads, allowing us to define a sequence of transformations in a declarative fashion.

5. Practical Use-Cases

As we have seen in the previous code examples, monads help us deal with effects by offering an extra layer of abstraction. Most of the time, they enable us to focus on the main scenario and handle the corner cases outside of the main logic.

5.1. The “Railroad” Pattern

Binding monads like this is also known as the “railroad” pattern. We can visualize the main flow by imagining a railroad going into a straight line. Additionally, if something unexpected happens, we’ll switch from the main railroad to a secondary, parallel one.

Let’s think about validating a Book object. We start by validating the book’s ISBN, then check the authorId and, finally, we validate the book’s genre:

void validateBook(Book book) {
    if (!validIsbn(book.getIsbn())) {
        throw new IllegalArgumentException("Invalid ISBN");
    }
    Author author = authorRepository.findById(book.getAuthorId());
    if (author == null) {
        throw new AuthorNotFoundException("Author not found");
    }
    if (!author.genres().contains(book.genre())) {
        throw new IllegalArgumentException("Author does not write in this genre");
    }
}

We can use vavr’s Try monad and apply the railroad pattern to chain these validations together:

void validateBook(Book bookToValidate) {
    Try.ofSupplier(() -> bookToValidate)
      .andThen(book -> validateIsbn(book.getIsbn()))
      .map(book -> fetchAuthor(book.getAuthorId()))
      .andThen(author -> validateBookGenre(bookToValidate.genre(), author))
      .get();
}

void validateIsbn(String isbn) { /* ... */ }

Author fetchAuthor(Long authorId) { /* ... */ }

void validateBookGenre(String genre, Author author) { /* ... */ }

As we can see, the API exposes methods like andThen(), useful for functions where we don’t need their response. Their purpose is to check for failure, and, if required, to switch to the secondary channel. On the other hand, methods such as map() and flatMap() are meant to move the flow further, creating a new Try<> monad that wraps the response of the function, in this case, the Author object:

try rail road

5.2. Recovering

In some cases, the APIs allow us to recover from the secondary channel back to the main one. Most of the time, this requires us to supply a fallback value. For instance, when using the API of Try<>, we can use the method recover() to switch from the “failure” channel back into the main one:

try rail road recover

5.3. Other Examples

Now that we’ve learned how monads work and how to bind them using the railroad pattern, we understand that the actual names of the various methods are irrelevant. Instead, we should focus on their purpose. Most of the methods from a monad’s API:

  • transform the underlying data
  • if needed, switch between the channels

For example, the Optional monad uses map() and flatMap() to transform its data, respectively filter() and or() to potentially switch between “empty” and “present” states.

On the other hand, CompletableFuture uses methods like thenApply() and thenCombine() instead of map() and flatMap(), and allows us to recover from the failure channel via exceptionally().

6. Conclusion

In this article, we discussed about monads and their main characteristics. We used practical examples to understand how they can help us deal with effects such as managing collections, concurrency, nullability, exceptions etc. After that, we learned how to apply the “railroad” pattern to bind monads and push all these effects outside our component’s scope.

As always, the complete source code can be found over on GitHub.