Rethinking Exception Handling with Kotlin's Result Type

Rethinking Exception Handling with Kotlin's Result Type
https://www.freepik.com/icon/error_9691778

Exception handling is a fundamental aspect of robust software development. In Kotlin, while traditional try-catch blocks are common, the Result type offers an alternative by encapsulating both success and failure. But is this approach always the best? Let's explore the pros and cons of using Result and consider whether it might be overused in certain scenarios.

An introduction to Kotlin's Result Type

If you're familiar with Kotlin, you've probably encountered Result. Dealing with code that can throw exceptions is a common scenario. Traditionally, we would propagate these exceptions in the codebase, and then handle them with a try-catch.

The Kotlin Result type provides a structured way to encapsulate exceptions into a simple API. Instead of propagating exceptions, we try-catch the exception, and map the result into either Result.success() or Result.failure(): :

class UserDataSource(private val networkApi: NetworkApi) {
    fun getUser(): Result<NetworkUser> {
        return try {
            Result.success(networkApi.getUser())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

This approach leaves us with a concise, easy to understand API, eliminating the need for any further exception handling at higher levels:

class UserRepository(private val dataSource: UserDataSource) {
    fun getUser(): Result<User> = dataSource.getUser()
        .map { networkUser -> networkUser.toDomainUser() }
}

class UserViewModel(private val userRepository: UserRepository) {
    fun getUserState(): UserState =
        userRepository.getUser().fold(
            onSuccess = { user ->
                UserState.Authenticated(user = user)
            },
            onFailure = { throwable ->
                UserState.Unauthenticated(throwable = throwable)
            }
        )
}

Advantages of using Result

If you're like me, you like Result. You've been using it everywhere for ages. You like how it reminds you to handle failure cases. The .onSuccess() block looks nice. It comes with its own .map() function and other convenient functions like getOrNull().

I liked Result so much that I had my own version before Kotlin officially introduced it. I even wrote a blog post on creating a custom Retrofit CallAdapter to return Result directly from Retrofit interfaces. I'm not the only one who finds this approach valuable.

For a long time, I felt good about this approach. With Result, I didn't need to remember to try-catch API calls. If I saw a Result, I knew the potential exception had already been caught. I could bubble that Result up from my data layer to the Repository, then up to the ViewModel, where I would reduce it to a state and conveniently render success or failure accordingly.

Drawbacks

Recently, a colleague asked me to explain why we use the Result type for API calls in our current project. Why not just allow exceptions to throw? My initial thought was, "Because that's how we've always done it." But that's never satisfying. So, let's rethink this.

We found that orchestrating multiple API calls often led to situations where we had a Result<TypeA> and a Result<TypeB>, and mapping these together to some Result<TypeC> felt clunky. For example:

class RewardsRepository(private val dataSource: RewardsDataSource) {
    fun getRewards(user: User): Result<List<Reward>> = dataSource.getRewards(user.id)
        .map { networkRewards ->
            networkRewards.map { networkReward ->
                networkReward.toDomainReward()
            }
        }
}

class GetUserRewardsUseCase(
    private val userRepository: UserRepository,
    private val rewardsRepository: RewardsRepository
) {
    operator fun invoke(): Result<UserRewards> {
				val user = userRepository.getUser() // Result<User>
        val rewards = rewardsRepository.getRewards(user) // Result<List<Reward>>

				val userRewards = ???
    }
}

Trying to combine two results is painful. You might write a function to handle this, but it often ends up feeling like a workaround. I found myself reaching for this pattern:

class GetUserRewardsUseCase(
    private val userRepository: UserRepository,
    private val rewardsRepository: RewardsRepository
) {
    operator fun invoke(): Result<UserRewards> {
        return try {
            val user = userRepository.getUser().getOrThrow()
            val rewards = rewardsRepository.getRewards(user).getOrThrow()

            Result.success(
                UserRewards(
                    user = user, 
                    rewards = rewards
                )
            )
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

This works, but let's think about it. We have a UseCase that try-catches, and returns a Result. We call getOrThrow() on our repository Result functions - essentially re-throwing their exceptions. Then we catch those exceptions, and map back into a Result. Gah! What have we gained from having userRepository.getUser() return a Result?

It's not the worst, and if you like this pattern or prefer to hide this logic behind an extension function, you might be content. But is this really the best approach?

So, What's the Problem?

Here are a few issues with propagating Result everywhere:

  1. Propagating Result Everywhere: Using Result deep in your data source layer means you propagate the Result type throughout your architecture. Consumers of your API have no choice but to handle Result. Orchestrating multiple Result types together can be cumbersome and adds unnecessary boilerplate.
  2. Reinventing Checked Exceptions: In some ways, Result essentially reinvents Java's checked exceptions. Kotlin deliberately avoided checked exceptions to keep the language concise. By encapsulating success and failure into a single type, developers are forced to handle both scenarios, leading to verbosity and complexity.
  3. Redundant Syntax: Result makes all existing try-catch syntax redundant. By wrapping everything in a try-catch at the lowest layers, we no longer throw exceptions. This can make constructs like try-catch, runCatching(), and Flow.catch() less useful.
  4. False Sense of Security: Result can provide a false sense of security. Although it encourages handling errors, it doesn't guarantee that errors are managed correctly. It's still possible to forget to implement .onFailure(), which might leave the app in an invalid state without any crash reports.

Write Exceptional Code

💡 The solution is simple: let your code throw exceptions naturally.

Consider refactoring the earlier example:

class UserDataSource(private val networkApi: NetworkApi) {
    fun getUser(): Result<NetworkUser> {
        return try {
            Result.success(networkApi.getUser())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

class UserRepository(private val dataSource: UserDataSource) {
    fun getUser(): Result<User> = dataSource.getUser()
        .map { networkUser -> networkUser.toDomainUser() }
}

Becomes:

class UserDataSource(private val networkApi: NetworkApi) {
    fun getUser(): NetworkUser = networkApi.getUser()
}

class UserRepository(private val dataSource: UserDataSource) {
    fun getUser(): User = dataSource.getUser().toDomainUser()
}

Now, I know what you're thinking. Change is scary. What if someone forgets to try-catch this code? Won't that result in a crash in production? 😱

There are valid concerns, but let's think about this for a minute:

Where Should You Handle Exceptions Anyway?

In a traditional MVVM app, exceptions should be handled thoughtfully at appropriate layers:

  1. In the Repository. Often you’ll need to map from a specific network exception to a more generic domain exception. This sort of mapping is usually the responsibility of the Repository.
  2. In a UseCase. UseCases encapsulate units of business logic, orchestrating multiple repositories and exposing data for consumption by the ViewModel. They are a good place to handle exceptions if necessary.
  3. In the ViewModel. Deciding how to surface an exception to the user is presentation logic, which usually belongs in the ViewModel.
💡 Prematurely catching exceptions, or mapping to Result at lower layers like the DataSource introduces unnecessary boilerplate.

If you like the Result pattern and the API it provides, you can still use it - but think about where you want to stop using traditional exception propagation and start introducing Result.

Writing Good Code Requires Discipline

Any external dependency that you call has the potential to throw an exception. Even a function that returns Result could potentially throw if a particular exception isn’t caught. So whenever you're interacting with a Repository or any other dependency from your ViewModel, you should always consider possible exceptions. What if there's a server error? What if there's no space on disk? What if the user is not authenticated? Whether the return type is Result or not, exception handling should always be front of mind.

Final Thoughts

Kotlin’s Result type is a powerful way to handle exceptions. But, it’s important to consider how and where you use it throughout your app’s architecture. For lower layers like DataSources, traditional exception propagation might be more appropriate, preserving the clarity and simplicity of your code. Reserve Result for higher-level orchestration where it can genuinely add value without introducing unnecessary boilerplate.

💡 How you handle exceptions is up to you, but don't be afraid to rely on plain old try-catch.