From time to time, I embark on a quest to clean up code in our project that hosts a decent amount of legacy code. Recently, I’ve run into code that looked akin to this:

fun doSomething(
    onSuccess: (Data) -> Unit,
    onFailure: (Exception) -> Unit,
)

The actual function accepted a couple more parameters and its body was non-trivially complex. Nothing too crazy but long enough to require more than a moment to check how are these callbacks used and what invariants they hold.

Turns out that these callbacks were always called right before the function returned. What are the callbacks for then?

Callbacks give us a lot of flexibility at the cost of making our programs more difficult to reason about. Let’s have a look at a few questions about callbacks and their invariants that may potentially arise.

Sync or async

Without inspecting the implementation of the function it is unclear whether these callbacks will be called synchronously before the function returns or called asynchronously and return control to the caller before either of the callbacks are executed.

E.g.

var message = "Default message"
doSomething(
    onSuccess = { message = "Something succeeded" },
    onFailure = { message = "Something failed" },
)
println(message)

Can print:

  • Something succeeded
  • Something failed
  • Default message

Number of invocations

Yet again, without inspecting the implementation of doSomething function, it is unclear whether these callbacks will be called once, multiple times or even not called at all.

E.g.

var counter = 0
doSomething(
    onSuccess = { counter += 1 },
    onFailure = { counter -= 1 },
)
println(counter)

Can produce:

  • 1
  • -1
  • 2
  • 42
  • and virtually any other integer

Mutual exclusion

Similarly to the previous point, it is unclear whether only one of the callbacks can be executed at a time or both can.

E.g.

doSomething(
    onSuccess = { println("Something succeeded") },
    onFailure = { println("Something failed") },
)

Can output:

  • Something succeeded
    
  • Something failed
    
  • Something succeeded
    Something failed
    
  • virtually any other combination

Did you know that in RxJava it is possible to observe the execution of both callbacks of the subscribe method just like in the last example above? For example, the following code:

Observable.just(1, 2, 3, 4, 5).subscribe(
    { value ->
        println("Success $value")
        require(value <= 2) { "$value is too high!" }
    },
    { ex ->
        println("Error ${ex.message}")
    }
)

would print:

Success 1
Success 2
Success 3
Error 3 is too high!

This will make sense once we inspect the signature of the subscribe method.

The two callback parameters in the example above are actually named onNext and onError, not onSuccess and onError. Exceptions thrown in the onNext block will still be delivered to the onError callback, the stream will terminate and no more elements will be processed.

It certainly left me perplexed when I first encountered this behaviour of RxJava.

Solution

We can solve the aforementioned problems by representing the result as a sealed class (or a sealed interface) and returning a subclass from the sealed hierarchy.

sealed interface Result {
    class Success(val data: Data) : Result
    class Failure(val ex: Exception) : Result
}

fun doSomething(): Result

With this approach, it is impossible run into any of the aforementioned problems.

val result = doSomething()
val message = when (result) {
    is Result.Success -> "Something succeeded"
    is Result.Failure -> "Something failed"
}
println(message)

The success or failure branches are mutually exclusive.

Both branches are definitely synchronous.

Either of the branches can be executed only once. Not twice, not zero times. Just once.

Conclusion

In the asynchronous world, we have no other option than to use callbacks. But in the synchronous world, callbacks are not necessary and are better avoided. As such, do not use callbacks for conceptually synchronous code as they may raise unnecessary questions.

Note: In Kotlin we can use coroutines that make asynchronous code look synchronous! Callbacks are nicely abstracted away from the developer (but still are present in the generated bytecode).