When writing use case classes, an important thing to consider is the output for failures. For example, we would probably expect a PostCommentUseCase
to throw some sort of network exception when it fails. Since Kotlin does not have checked exceptions (i.e. the compiler does not enforce you to catch anything), it is easy to forget to add the required error handling in calling locations. The Result class can solve this issue and help make your code more resilient, but there are some pitfalls to be aware of.
Try-catch
First, let us see what problem Result solves. Writing plain try-catches around every use case you call works fine, but has some disadvantages:
- No obligation to catch exceptions, easy to forget. ❌
- Inconsistent API if some use cases throw exceptions while others don’t require any try-catch. ❌
- Difficult to find where try-catch is missing. Airplane mode could prevent code that actually throws exceptions from ever being reached. ❌
Result class and its extensions
The Result class is a simple data structure to wrap data using Result.success(myData)
or an exception using Result.failure(ex)
. There are useful methods to handle either of these values. With the runCatching extension you do not have to manually instantiate the Result class:
class PostCommentUseCase() { fun execute(comment: Comment) = runCatching { // If any of this code throws an exception, // this block wrap it in a Result. validate(comment) // Else, the return value of upload() will be wrapped in a Result. upload(comment) } // validate() & upload() methods left out }
This can now be easily executed:
PostCommentUseCase.execute() .onSuccess { // Success! showCommentPosted() } .onFailure { e-> // Log the exception and inform the user. showError(e) }
This is a very clear API and it fixes the issues listed above:
- No obligation to catch: ✅ Fixed! You either write
.onSuccess { }
or.onFailure { }
. Or both, or none! - Inconsistent API: ✅ Fixed! Every use case returns the Result wrapper. No more surprises! If you use Flow, you could make use cases return either Result or Flow directly. That way, you have only two solid return types to deal with.
- Difficult to locate missing try-catches: ✅ Fixed! You can focus on writing success/error handling when needed only without having to worry about exceptions popping up unexpectedly.
If you simply want to use the return value inside an existing try-catch or don’t care about if it fails, you can take a look at getOrThrow()
and getOrNull()
.
Downsides
While using Result with this runCatching method is very simple, there are some flaws:
runCatching
catchesThrowable
. This also means it catchesError
s likeOutOfMemoryError
. See StackOverflow – Difference between using Throwable and Exception in a try catch for why this should not be always be done. I share a proposed solution for this further down.runCatching
does not rethrow cancellation exceptions. This makes it a bad choice for coroutines as it breaks structured concurrency. Read more about this including a proposed solution further down.- It is not possible to specify a non-
Exception
error type or a custom base exception class. Alternatives like kotlin-result do offer this functionality.
The issue with suspend runCatching
The example below shows a use case that simply waits 1000 ms before returning inside runCatching
. It launches it, then waits 500 ms and cancels the scope — while the use case is running. You would expect the entire coroutine block to stop executing, but it does not:
class MyUseCase { | |
suspend operator fun invoke() = runCatching { delay(1000) } | |
} | |
scope.launch { | |
myUseCase() | |
.onSuccess { println("Success") } | |
.onFailure { println("Failure") } | |
println("We're not stopping!") | |
} | |
Thread.sleep(500) // Cancel while executing use case | |
scope.cancel() | |
// This will output the following: | |
// "Failure" | |
// "We're not stopping!" |
This is because the CancellationException
being thrown while the use case is executing, is swallowed by runCatching
and is thus never propagated to the outer scope. The result is that your code continues running even when, for example, a ViewModel’s scope is cancelled. It could lead to bugs and/or crashes.
Proposal: resultOf
While the Kotlin team is figuring out what to do with this issue (https://github.com/Kotlin/kotlinx.coroutines/issues/1814), we can write a variant of the runCatching
(and mapCatching
) method that fixes these issues:
import kotlinx.coroutines.CancellationException | |
/** | |
* Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. | |
* | |
* Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. | |
*/ | |
inline fun <R> resultOf(block: () -> R): Result<R> { | |
return try { | |
Result.success(block()) | |
} catch (e: CancellationException) { | |
throw e | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
/** | |
* Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. | |
* | |
* Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. | |
*/ | |
inline fun <T, R> T.resultOf(block: T.() -> R): Result<R> { | |
return try { | |
Result.success(block()) | |
} catch (e: CancellationException) { | |
throw e | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
/** | |
* Like [mapCatching], but uses [resultOf] instead of [runCatching]. | |
*/ | |
inline fun <R, T> Result<T>.mapResult(transform: (value: T) -> R): Result<R> { | |
val successResult = getOrNull() | |
return when { | |
successResult != null -> resultOf { transform(successResult) } | |
else -> Result.failure(exceptionOrNull() ?: error("Unreachable state")) | |
} | |
} |
Job Offers
It works mostly the same, is equally simple to use, but fixes the issues mentioned above:
- Catching
Throwable
: ✅ Fixed! This one catchesException
instead, not swallowing runtime errors. - Catching cancellation exceptions: ✅ Fixed! It passes through the
CancellationException
, making coroutines behave as expected.
Updating the example from above is easy:
class PostCommentUseCase() { fun execute(comment: Comment) = resultOf { // Only this line changed. // The rest of the code is the same as above. } }
Bonus: @CheckResult
annotation
While I listed not leaking exceptions as a plus, it can result in kicking off a use case and forgetting to do something with the returned Result value. For this reason, I always add @CheckResult
above my use case invocation methods. Now the IDE will make sure to remind me to do at least something with the returned value of your use case.
This will make our final snippet look like this:
class PostCommentUseCase() { @CheckResult fun execute(comment: Comment) = resultOf { // If any of this code throws an exception, // this block wrap it in a Result. validate(comment) // Else, the return value of upload() will be wrapped in a Result. upload(comment) } }
Further reading
- The full Result API: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/
- The kotlin-result alternative to the official
kotlin.Result
: https://github.com/michaelbull/kotlin-result - Structured Concurrency Explained: https://www.thedevtavern.com/blog/posts/structured-concurrency-explained/
This article was originally published on proandroiddev.com on April 03, 2022