Clean (Reactive) Code

Clean Code is an excellent book. One of the key takeaways from the book is that we code not only for the computer but also for the other developers we work alongside.
I believe that Clean Code is a subjective topic even though it’s shadowed by the popularity of the book. Many people would have their way of writing code in a more readable and maintainable way. They simply would have not published a book about the topic or they did but not gained as much attention as Uncle Bob’s one.

Ever since I started using WebFlux in 2018 from stock trading service, I’ve been quite content with it. As I started to review more and more code written in reactive functional style including both Reactor and RxJava, I realized that many people tend to write (reactive) code in their way which makes me spend more time to understand the logic behind the various types of looking.

Putting aside all pros/cons of reactive programming, discussion of which would take more time than the subject I’m handling here, reactive programming indeed lacks a common style guide to help speed up the cycle of development and review.

So, here I am listing some of the practices I follow while writing reactive code and find it easier to read.
The code below is written in Kotlin, but I believe it’s not much different from Java otherwise I mention it separately.

1. Reactive Operator is the key to Reactive programming

In reactive programming, an operator is a basic unit. Consider arranging operators from left-to-right then top-to-bottom so it is much easier to understand the whole flow of data when code is undergoing a review.

//Bad
userService.getFavorites(userId).map(Favorite:toRequestModel)
           .flatMap(favoriteService::getDetails) 
 
// Good
userService.getFavorites(userId) 
           .map(Favorite:toRequestModel)
           .flatMap(favoriteService::getDetails) 

2. Reduce the distance between Operators

Like rule number 1, in an effort to emphasize operators, Keep the lambda function short and simple inside the operator such as map or flatMap. If it is going to be longer, extract the it as a separate function.

//Bad
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               val userRequest = user.toUserRequest()
               GetFavoriteDetailRequest(
                   favorites = favorites,
                   user = userRequest
               )
            }
           .flatMap(favoriteService::getDetails) 
 
//Good
userService.getFavorites(userId)
           .map(this:toRequestModel)
           .flatMap(favoriteService::getDetails) 
 
fun toRequestModel(input: Tuple2<Favorites, Users>) {
     val (favorites, user) = input
     userRequest = user.toUserRequest()
     GetFavoriteDetailRequest(
        favorites = favorites,
        user = userRequest
     )
} 

3. Use Operators matching the name

The name of operators is as much important as how it operates. For instance, map should be used for mapping a value to another value, not for side effects. Similarly, flatMap should be used for mapping a value to Publisher type that is an abstraction of computation, inherently asynchronous.
The consistency between the name and the usage will reduce the cognitive load when reading code.

//Bad
userService.getFavorites(userId)
           .map { 
               log.info("Received favoirtes, $it")
               it.toRequestModel()
            }
           .flatMap(favoriteService::getDetails) 
 
//Good
userService.getFavorites(userId)
           .doOnNext { log.info("Received favoirtes, $it") }
           .map(this:toRequestModel)
           .flatMap(favoriteService::getDetails) 

4. Use Reactive type discreetly

Like I mentioned in rule number 3, Publisher, whose type is a supertype of Mono and Observable, is an abstraction of future computation. Check whether it’s accessible in runtime before declaring the property as a subtype of Publisher otherwise it brings more complexity into code without much gain.

//Bad
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               GetRequestModel(
                   Mono.just(favorites),
                   Mono.just(user)
               )
            }
           .flatMap(favoriteService::getDetails) 
 
data class GetRequestModel(
     val favorites: Mono<Favorites>,
     val user: Mono<User>
)
 
//Good
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               GetRequestModel(favorites, user)
           }
           .flatMap(favoriteService::getDetails) 
 
data class GetRequestModel(
     val favorites: Favorites,
     val user: User
)

5. Null equivalent in reactive is Empty

If you return null when the function is expected to return Publisher type, it will throw an exception because null equivalent in reactive is an empty Publisher. For example, it is allowed to return null in map operator but in flatMap.
In Kotlin, we would rarely come across such situation since it’s supporting non-nullable type, whereas, in Java, one need to take care more about null handling in reactive programming.

// Bad
Mono
   .just("test")
   .flatMap { testFunc(it) }
 
// Good
Mono
  .just("test")
  .flatMap { 
      testFunc(it) ?: Mono.empty()
   }
 
private fun testFunc(seed: String): Mono<String>? =
    if (seed  == "test") {
        null
    } else {
        Mono.just("Mono - test")
    }

6. Use a method reference

It is hard to avoid using nested functions also it’s not helping readability since variable scopes start to be affecting each other. Soon code base is likely to be covered with it, an implicit name of a lambada parameter in Kotlin, or with the series of a similar variable name in Java. Always prefer method reference that allows us to skip the boilerplate. If you could use SAM(Single Abstract Method) instead, It’s even better! We don’t even have to look at the method name. The type name will tell us spontaneously.

// Bad
userService.getUser(userId)
           .map { it.toFavoriteReq() }
           .flatMap { 
               favoriteService
                       .getFavorites(it)
                       .flatMap { 
                          favoriteService.getDetails(it.toDetailRequest) 
                       }
 
           }
 
// Good 
userService.getUser(userId)
           .map(User::toFavoriteReq)
           .flatMap { favoriteReq ->
               favoriteService
                       .getFavorites(favoriteReq)
                       .map(Favorite:toDetailRequest)
                       .flatMap(favoriteService::getDetail) 
                           
           }

7. Don’t let Collection API shadow Operators

The name of most reactive operators make a pair with that of Collection API. map, flatMap, reduce, filter, the list goes on. If these names appear in the same place, it will confuse the reviewer. Which is which? Separate the usage of API or use an unambiguous name from the other.

// val books: List<Book>
// Bad
Flux.merge(
  books.map { book ->
     if  (book.id == null) {
       Mono.just(card.copy(id = UUID.randomUUID()))
     } else {
       Mono.just(book)
     }
  }
)
 
// Good
Flux.merge(
  books.jdkMap { book ->
     if  (book.id == null) {
       Mono.just(card.copy(id = UUID.randomUUID()))
     } else {
       Mono.just(book)
     }
  }
)
 
private fun <T, R> Iterable<T>.jdkMap(transform: (T) -> R): List<R> = this.map(transform)

8. Make variable name distinguishable

It’s unnecessary to include Mono or Flux for the variable name unless the reader needs attention. When there are Publisher and non-publisher type in one place, or performing an operation between Flux and Mono, we better to include flux or mono in the variable name. Sometimes, the operation between two types would end up in an unexpected result. For example, the result of zipping Flux and Mono will emit only one item ignoring the rest part flux.

// Bad
val numberKor = Mono.just("hana")
val numberEng = Flux.just("one", "two")
numberEng
      .zipWith(numberKor)
      .doOnNext { zipped ->
         val (eng, kr) = zipped
         log.info("English:$eng, Korean:$kr")
       }
 
// Good
val numberKorMono = Mono.just("hana")
val numberEngFlux = Flux.just("one", "two")
 
numberEngFlux
     .zipWith(numberKorMono)
     .doOnNext { zipped ->
        val (eng, kr) = zipped
        log.info("English:$eng, Korean:$kr")
     }

9. Avoid calling subscribe() directly

Avoid calling subscribe() inside of reactive chain except in a clear case, such as spawning Zombie process. There is a danger that the process would be lingered over longer than we had expected because there is no way for us to control over the dangling process. Ideally, we should able to adjust the flux of data by handling Disposable type.

// Bad
userService
         .getUser(req)
         .flatMap(userService::changePassword)
         .doOnNext {
              auditLogger
                      .auditLog(it)
                      .subscribe()
         }

10. Key operator to the performance is flatMap

Speaking of performance improvement in reactive programming, the parallelization approach is usually suggested and is dealt with by flatMap(). Positioning operators that change data flow such as delayElement() around flatMap should be carefully considered as the flux of data might be changed after passing through flatMap. So if you seek to improve the performance in reactive programming, take a gander at the usage of flatMap.

Reference

https://projectreactor.io/docs/core/release/reference/

http://reactivex.io/documentation/operators.html