Understanding Vert.x: compose vs. map

Learn when to use map and when to use compose

Alexey Soshin
Better Programming

--

Photo by Hendrik Morkel on Unsplash

Vert.x is an asynchronous framework. As such, it needs a way to represent values that may not be ready yet but will be available in the future, otherwise known as deferred values. You may be familiar with deferred values under different names: Promise, Future, Deferred, Mono, and Uni are all implementations of the Deferred value design pattern.

Vert.x has its own implementation of a deferred value, simply called Future. This shouldn’t be confused with Java’s Future, though. The implementations are completely separate.

Although Vert.x Future can be converted to a Java Future using toCompletionStage() method.

Vert.x Future has two methods that are described very similarly and may confuse you: compose and map.

I’d like to examine how each of them works and when you should use which.

Let’s start with the simplest scenario and look at a completed Future:

Future<String> f = Future.succeededFuture("a");

Here, f represents a deferred value that was successfully calculated.

Now, let’s see how compose() and map() methods behave on that deferred value if we pass them a trivial function: given X, return X.

Future<String> composeResult = f.compose(a -> Future.succeededFuture(a));

Future<String> mapResult = f.map(a -> a);

While map() allows us to return the value as-is, compose() forces us to wrap the result in a new Future. That is because map() can return any type of result while compose() must return some kind of Future.

At first glance, compose() seems like more work for no apparent benefit. Both methods receive the value of type String, but to return the most trivial result, compose() needs an extra step.

But before we discard compose() method completely, let’s look at another scenario:

Future<Future<String>> f = Future.succeededFuture(Future.succeededFuture("a"));

Here we have Future wrapped in another Future. That example may seem far-fetched initially, but later I’ll show you that this nesting is very common in asynchronous frameworks.

What happens when we try to invoke map() and compose() on this nested Future?

Future<String> composeResult = f.compose(a -> a);

Future<String> mapResult = f.map(a -> a.result());

While previously, the compose() code was more complicated, now it’s the map() code that needs to perform some extra work.

To unnest the Future using map(), we need to invoke the result() method. With composeResult, though the value is readily accessible. That’s because compose flattens the Future for us.

For the same reason, in Vert.x flatMap() is just an alias of compose().

So, whenever you work with Futures, compose() is actually a better way to work with them than map().

Another way to look at that is to compare the results if we pass the identity function to both methods:

Future<String> composeResult = f.compose(a -> a);

Future<Future<String>> mapResult = f.map(a -> a);

It may be useful to visualise what happens here:

Pay attention to the colors. Judging just by the result types, you could assume that compose() would return the nested Future, while Map would return the same object it received. This is incorrect. Both compose() and map() return a new Future.

But compose wraps the actual value into a Future, while Map wraps the underlying Future into a new one.

I promised to demonstrate where Future nesting can occur in real Vert.x code. Let’s look at the following slightly simplified example from the official documentation:

var future = client
.request(HttpMethod.GET, "some-uri")
.map(request -> request.send()
.map(response -> response
.body()
.map(buffer -> buffer.toJsonObject())));

This code sends a request using the standard Vert.x HttpClient, then reads response body and parses it into JSON. I’m sure you’ve written similar code hundreds of times.

The question is, what is the type of the result here?

Let’s make the type explicit:

Future<Future<Future<JsonObject>>> future = client
.request(HttpMethod.GET, "some-uri")
.map(request -> request.send()
.map(response -> response
.body()
.map(buffer -> buffer.toJsonObject())));

Since we used map(), no unnesting is being done. So, to read the JSON, we would need to unnest it manually:

var json = future.result().result().result();

Let’s replace map() with compose() now, keeping the result type explicit:

Future<JsonObject> future = client
.request(HttpMethod.GET, "some-uri")
.compose(request -> request.send()
.compose(response -> response
.body()
.map(buffer -> buffer.toJsonObject())));

The result type is shallow now and easy to work with:

var json = future.result();

Notice that the last method is still a map() . That’s because the body() method returns a Buffer object, not a Future, so there’s no need to flatten it.

Use compose when the argument of your lambda is a Future. Use map() when it’s a simple object.

Final Words

I hope you enjoyed the read! If you have more questions about Vert.x, you can find me and other Vert.x contributors and maintainers on Stack Overflow.

--

--

Solutions Architect @Depop, author of “Kotlin Design Patterns and Best Practices” book and “Pragmatic System Design” course