How to return domain errors in Kotlin

RoenissRoeniss
5 min read

Our domain, business, or core logic should be able to handle multiple cases. For that, I wanted to use some easy, expressive, and consistent approaches for returning values in a domain context. Here are the things that I've found.

tl;dr

(1) null as failure (2) sealed interface (3) 3rd-party libs like Arrow.

Choose one according to how granular control you need.

The approaches that I know

1. T or Nothing - "throw on failure"

fun getUser(userId: Long): User {
    val user: User = userRepo.findById(userId) ?: throw UserNotFoundException()
    return user
}


fun main() {
    try {
        val user = getUser(1)
        // Success flow
    } catch (e: UserNotFoundException) {
        // Failure flow
    }
}

Pros

  • If you're going to return immediately the response when the user is not found, this might be a good choice. With some upper-level response handlers.

Cons

  • The pros I said seems not the desired way. It hides control flow and the more serious problem is that business corner cases are mixed with system errors.

2. Result<T> - "wrapped throw on failure"


fun getUser(userId: Long): Result<User> {
    val getUserResult: Result<User> = runCatching {
        userRepo.findById(userId) // throw error if not exists
    }

    return getUserResult
}

// style 1
fun main() {
    val user: Result<User> = getUser(1)
            .onSuccess { /** Success flow */ }
            .onFailure { /** Failure flow */ }
}

// style 2
fun main() {
    val user: Result<User> = getUser(1)

    if(user.isFailure) {
        val exception: Throwable? = user.exceptionOrNull()
        // Failure flow
    } else {
        val user: User? = user.getOrNull()
        // Success flow
    }
}

Pros

  • I can't find one, especially for domain context.

Cons

  • KEEP said this is not for domain context.

  • onSuccess and onFailure receives a function that has kotlin.Unit return type. So we can do method chaining like onSuccess { ... }.map { ... } (see style 1).

  • There is no exception() or get() properties. So we must put additional null check logics (see style 2).

  • (style 1 and 2) In the Failure part, the argument is always Throwable type, which is too ambiguous (broad) for usual cases.

  • From what I know, using exceptions in expected situations is generally anti-pattern.

3. T? - "null on failure"

fun getUser(userId: Long): User? {
    val user: User? = userRepo.findByIdOrNull(userId)
    return user
}

fun main(){
    val user = getUser(1)
    if (user == null){
        // Failure flow
    } else {
        // Success flow
    }
}

Pros

  • Simple

Cons

  • Can't distinguish between success null and failure null when success null could exist in the context.

  • This can't be used if there are many failure cases and the caller should know which one it is.

4. Pair<Boolean, T> - "value with isSuccess"

fun getUser(userId: Long): Pair<Boolean, User> {
    val user: User? = userRepo.findByIdOrNull(userId)

    if (user == null) {
        return false to DummyUser() // I can't help but make it
    }
    return true to user
}


fun main() {
    val (found, user) = getUser(1)

    if (!found) {
        // Failure flow
    } else {
        // Success flow
    }
}

Pros

  • Not like the previous one, this can differentiate the success null from failure null, because now the result has four possibilities: Success T, Success T?, Failure T, and Failure T? (if the result type is not-nullable (T), it will have two instead - Success T and Failure T)

Cons

  • Whether you need it or not, you always have those possibilities. In the above code, it would be more intuitive to return nothing on failure. But the expected type is User, so I have no choice but to make a dummy instance. Instead, I can use User?. Sadly this time we need a not-null assertion (!!) in the success branch flow.

5. sealed interface class - "polymorphic return values"


sealed interface UserFindResult {
    data class Success(val user: User) : UserFindResult
    data class FailureByDatabaseError(val errorCode: Int) : UserFindResult
    data object FailureByNotFound : UserFindResult
}


fun getUser(userId: Long): UserFindResult {
    val user = try {
        userRepo.findByIdOrNull(userId)
    } catch (e: DatabaseException) {
        return UserFindResult.FailureByDatabaseError(e.errorCode)
    }

    if (user == null) {
        return UserFindResult.FailureByNotFound
    }
    return UserFindResult.Success(user)
}

fun main() {
    val user = getUser(1)
    when (user) {
        is UserFindResult.FailureByDatabaseError { /* Failure flow case 1 */ } 
        is UserFindResult.FailureByNotFound -> { /* Failure flow case 2 */ } 
        is UserFindResult.Success -> { /* Success flow */ } 
    }
}

Pros

  • Each implementation of the sealed interface can have its own property types and count.

  • Using when expression, every case can be handled without missing

Cons

  • Quite verbose

6. 3rd-party libs - "more functionally"

I didn't try those things but just took a glance. So I will not explain deeply. But as far as I can see, the core idea seems that the original Kotlin lacks of functional programming approach, which brings a lack of expression.

Result4k


data class UserFindFailure(val reason: String)

fun getUser(userId: Long): Result4k<User, UserFindFailure> {
    val user = try {
        userRepo.findByIdOrNull(userId)
    } catch (e: DatabaseException) {
        return Failure(UserFindFailure("Database error: ${e.errorCode}"))
    }

    if (user == null) {
        return Failure(UserFindFailure("User not found: $userId"))
    }
    return Success(user)
}

fun main() {
    getUser(1)
            .map { it.changePassword("2") }
            .peekFailure { alert(it.reason) }
}

kotlin-result and Kittinunf's Result show similar approaches.

Arrow

sealed interface UserFindFailure {
    data class FailureByDatabaseError(val errorCode: Int) : UserFindFailure
    data object FailureByNotFound : UserFindFailure
}

fun getUser(userId: Long): Either<UserFindFailure, User> {
    val user = try {
        userRepo.findByIdOrNull(userId)
    } catch (e: DatabaseException) {
        return UserFindFailure.FailureByDatabaseError(e.errorCode).left()
    }

    if (user == null) {
        return UserFindFailure.FailureByNotFound.left()
    }
    return user.right()
}

fun main() {
    when (getUser(1)) {
        is Either.Left -> { /* Failure flow */ }
        is Either.Right -> { /* Success flow */ }
    }
}

These approaches have many eye-opening features like chaining, lazy evaluation, higher-order, etc. Some people call them 'Railway-oriented programming' tools.

Conclusion

I described six approaches in total.

  1. T or Nothing

  2. Result<T>

  3. T?

  4. Pair<Boolean, T>

  5. sealed interface class

  6. 3rd-party libs

I currently stick to 3rd and 5th. The 3rd is a bit unreliable, it is enough for relatively simple cases. For other cases I use 5th.

I'm interested in 6th of course, but it would be so challenging to bring this paradigm to my team without permeating the concept.

Other options - 1st, 2nd, and 4th - seem the wrong approach because they are using exceptions for business flow control or too cumbersome.

0
Subscribe to my newsletter

Read articles from Roeniss directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Roeniss
Roeniss

Yes, we are here. https://youtu.be/5pz4bh3lA_M