How to return domain errors in Kotlin
Table of contents
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
andonFailure
receives a function that haskotlin.Unit
return type. So we can do method chaining likeonSuccess { ... }.map { ... }
(see style 1).There is no
exception()
orget()
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
, andFailure T?
(if the result type is not-nullable (T), it will have two instead -Success T
andFailure 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 useUser?
. 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.
T or Nothing
Result<T>
T?
Pair<Boolean, T>
sealed interface class
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.