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<Double> 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<Author>:
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<Author> 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.
The code backing this article is available on GitHub. Once you're
logged in as a Baeldung Pro Member, start learning and coding on the project.