Project Reactor is not powerful enough to express asynchronous computations which always return a value (a Mono<T> which is never empty at the type level) or return no value (Mono<Void> returning methods).
I discovered this design problem while trying to debug corner cases when using Spring 5 Security. An empty Mono<T> inside the internals of Spring 5 Security made it almost impossible to tell where the emptiness was coming from, without understanding almost the whole implementation of the framework itself.
Comparing it to Kotlin coroutines and RxJava3, we have:
suspend () -> UnitCompletableProject Reactor: Mono<T> (Mono<Void> - no explicit type)
for an asynchronous computation which returns a possibly missing value (or an error):
suspend () -> T?Maybe<T>Project Reactor: Mono<T>
for an asynchronous computation which always returns a value (or an error):
suspend () -> TSingle<T>Mono<T> (no explicit type)Apart from the problem that there is no equivalent type for RxJava3's Single<T>, the behaviour of the Mono<T> type is equivalent to the Maybe<T>, which means that operators like .map() and .flatMap() silently have no effect if the Mono<T> happens to be empty.
If you have even a rather simple application and a Mono<T> somewhere deep in your code accidentally happens to be empty, it will be almost impossible to say where that missing value is coming from. As an example, the following Mono<T> and Maybe<T> code samples have the following equivalent Kotlin suspending function code control flow:
Mono<T> example:
private fun monoStep1(input: String): Mono<String> {
return Mono.just(input)
}
private fun monoStep2(input: String): Mono<String> {
return Mono.just(input)
}
private fun monoStep3(input: String): Mono<String> {
return Mono.just(input)
}
fun monoExample(input: String?): Mono<String> {
return Mono
.justOrEmpty<String>(input)
.flatMap { monoStep1(it) }
.flatMap { monoStep2(it) }
.flatMap { monoStep3(it) }
}
Maybe<T> example:
private fun maybeStep1(input: String): Maybe<String> {
return Maybe.just(input)
}
private fun maybeStep2(input: String): Maybe<String> {
return Maybe.just(input)
}
private fun maybeStep3(input: String): Maybe<String> {
return Maybe.just(input)
}
fun maybeExample(input: String?): Maybe<String> {
return Maybe
.fromOptional(Optional.ofNullable(input))
.flatMap { maybeStep1(it) }
.flatMap { maybeStep2(it) }
.flatMap { maybeStep3(it) }
}
Kotlin suspending function example:
private suspend fun monoSuspendStep1(input: String): String? {
return input
}
private suspend fun monoSuspendStep2(input: String): String? {
return input
}
private suspend fun monoSuspendStep3(input: String): String? {
return input
}
suspend fun monoSuspendExample(input: String?): String? {
if (input == null) {
return null
}
val monoSuspendStep1 = monoSuspendStep1(input)
if (monoSuspendStep1 == null) {
return null
}
val monoSuspendStep2 = monoSuspendStep2(monoSuspendStep1)
if (monoSuspendStep2 == null) {
return null
}
return monoSuspendStep3(monoSuspendStep2)
}
This simple code snippet can result in an empty Mono<T>, empty Single<T> or a null value returned by monoSuspendExample from 4 different reasons, without being able to tell where the null is coming from, especially in the first two examples, because there is no possibility of setting a breakpoint.
Always using the Mono<T> type, without having an alternative, is like writing code where all the values returned by the methods are nullable (the possibly empty Monos) and if an unexpected null value is returned, instead of throwing a NullPointerException, it will just propagate the null value to the current method's caller and so on.
Single<T> example:
private fun singleStep1(input: String): Single<String> {
return Single.just(input)
}
private fun singleStep2(input: String): Single<String> {
return Single.just(input)
}
private fun singleStep3(input: String): Single<String> {
return Single.just(input)
}
fun singleExample(input: String): Single<String> {
return Single
.just(input)
.flatMap { singleStep1(it) }
.flatMap { singleStep2(it) }
.flatMap { singleStep3(it) }
}
Kotlin suspending function equivalent:
private suspend fun singleSuspendStep1(input: String): String {
return input
}
private suspend fun singleSuspendStep2(input: String): String {
return input
}
private suspend fun singleSuspendStep3(input: String): String {
return input
}
suspend fun singleSuspendExample(input: String): String {
val singleSuspendStep1 = singleSuspendStep1(input)
val singleSuspendStep2 = singleSuspendStep2(singleSuspendStep1)
return singleSuspendStep3(singleSuspendStep2)
}
The Mono<T> should have been an equivalent of the RxJava3's Single<T> type, because most code by default either returns a value or throws an exception. This is the equivalent to using Kotlin nullable types only when needed and not from the beginning of a project.
Given the current Mono<T> implementation (equivalent to RxJava3 Maybe<T> type), a new type called Single<T> should be added to Project Reactor, which is functionally equivalent to a never-empty Mono<T>, and it should be used by default instead of Mono<T>, which should become an option only when a possibly missing value is expected, which happens way less often.
Conversion operators between Mono<T> and Single<T> should be added to both types: Mono.toSingle() (just as Maybe.toSingle(), mapping an empty Mono<T> to a NoSuchElementException) and Single.toMono() (just as Single.toMaybe()).
In our codebase, we have decided to avoid using empty Monos, because of how hard it is to determine where they are coming from. When a computation returns a possibly missing value, we use Mono<Optional<T>> as a method return type and a Mono<T> returned by a method is considered never empty. However, this is just a convention used by us, which is not enforced in other projects using Project Reactor, such as Spring 5.
Have you looked at mono.single() method? This throws an exception if the mono is empty, making it an equivalent of RxJava's Single type in that aspect... Mono<Void> is a way of expressing a mono that either completes or errors, equivalent to Completable. Note also that unlike these RxJava3 classes, Mono is a full-blown Publisher following the Reactive Streams specification, which facilitates interactions between a Mono and other RS libraries.
We have no intention as of now to introduce more reactive types in Reactor.
I did not know about the Mono.single() operator, but having it means that I, as a careful developer, have to use it everywhere where a non-empty Mono<T> is expected, which is almost always desired and there should be a type expressing that _default_ behaviour. I have never seen this operator being used in any project so far, maybe because people did not know about it.
The new Single<T> type can also be made a Reactive Streams Publisher, I don't see it as a reason against adding the new type.
Project Reactor as it is now promotes unnecessarily complicated control flow in our programs, because of the problem described above.
but having it means that I, as a careful developer, have to use it everywhere where a non-empty Mono
is expected
when it comes to asynchronous programming, it is always good to be explicit about your expectations. single() isn't the only operator that can help with it, defaultIfEmpty() is another one, for example, that helps dealing with missing value.
Mono#single could return Single<T> or similar, but, as you said, you didn't know about the existence of it. Not sure adding a new type will dramatically improve the situation here, but will indeed make it much harder to maintain Reactor's code base, given all the permutations and operators.
when it comes to asynchronous programming, it is always good to be explicit about your expectations
I'd suggest that this can be generalised: "when it comes to programming, it is always good to be explicit".
I assign a lot of value to explicit code, and (because that takes a huge toll without them) strong types. Mono is weak: a potential absence of data, not represented on the typesystem... I thought we all agreed null was a mistake, and while that sentiment is one that Reactor authors seem to share, Mono ensures that I still have to deal with something morally equivalent. This is not what I would have expected from a modern, FP-influenced framework.
If Single existed, Mono<A> could be expressed as Single<Maybe<A>>. You'd still only have two reactive types, but consumers would be able to be much more explicit at a lower cost.
It's your project and I understand that you probably don't want to make heavy changes to it. But the insinuation that these concerns can be in any way assuaged through the use of functions like Mono::single and Mono::defaultIfEmpty is kind of insulting.
Edit: I'm coming back to this because it's not nearly as constructive as it should be. I'm very new to the project, and for what it's worth, I really like what I've seen for the most part. This just hit me today and I was more frustrated than I should have been because I've come to enjoy the framework and felt kind of let down.
we favored the cases where there actually IS a value that is expected,
which we thought would be more relevant most of the time. what you said
about single of maybe can be applied to monos as well: use a Mono of
optional if you want to be fully explicit about the empty case.
Yeah, that's what I intend to do. I actually don't have cause to model the empty case right now, so one of my concerns is that once there _is_ call for one, other contributors to the codebase might do so using empty Monos rather than be explicit, but it wouldn't be fair to make that your problem 馃槄
Having picked up Reactor for the first time through another framework, my other cause for concern here was that it might be yielding empty Monos and I'd have no way of knowing. I'd suggest that (despite favoring the defined value case) Reactor's first class handling of the undefined case isn't doing the community's collective discipline or consistency any favors, but I also get that you don't make friends by being too opinionated, and that "good style" is far from well-defined 馃槈
First of all, I am very impressed by the work that's been put in the Reactor framework. However, it really surprises me @andreisilviudragnea's greatly described pain point of the framework gets dismissed as 'has workaround'.
The Mono<A> can roughly be seen as an Either<E, Optional<A>> (either having an error or optionally having a value), making it impossible to actually enforce having that value on the type level. Using single() does not change that as there is no way to see the difference without reading the implementation. Though, if Mono<A> would be roughly equivalent to Either<E, A> (either having an error or strictly a value) it would still allow you to model the optionality yourself by using an Optional<B> for A.
That being said, it would be great to at least understand what's the rationale behind modeling the Mono as being either 0 or 1 values.
Our project is written in Spring Reactive and I've had multiple cases where an accidentality/unexpectedly empty Mono was propagated all the way to the client (resulting in an empty 200 OK response).
I can also probably count on one hand the number of places where I actually want to allow empty Monos. As mentioned, using a a Mono<Optional<T>> is an option, but in my opinion this leads to code that is very difficult to read and work with. I'm often tempted to call .flatMap(Mono::justOrEmpty) in places this was used.
Using .single() feels like a complete hack. Calling .single() on the returning side still doesn't provide any context on the calling side. Calling it on the calling side just leads to doing 'null' checks everywhere.
I think it's unfortunate these types where never added, basically creating the equivalent of a NPE, which was already avoided in RxJava. If this is not up for discussion, I hope at least @Nullable/@NonNull equivalent annotations can be considered.
Most helpful comment
First of all, I am very impressed by the work that's been put in the Reactor framework. However, it really surprises me @andreisilviudragnea's greatly described pain point of the framework gets dismissed as 'has workaround'.
The
Mono<A>can roughly be seen as anEither<E, Optional<A>>(either having an error or optionally having a value), making it impossible to actually enforce having that value on the type level. Usingsingle()does not change that as there is no way to see the difference without reading the implementation. Though, ifMono<A>would be roughly equivalent toEither<E, A>(either having an error or strictly a value) it would still allow you to model the optionality yourself by using anOptional<B>forA.That being said, it would be great to at least understand what's the rationale behind modeling the
Monoas being either 0 or 1 values.