These last weeks, I had the chance to think about the best error handling solution for a new project at work. I read a lot about Railway Programming and the importance of considering the unhappy paths from the get-go.
I also read some articles and checked some GitHub projects Android/Kotlin specific to see how people in the community are solving this.
If I had to sum up my findings in a small code snippet, this would be it:
sealed class Result<T> { | |
data class Success<T>(val data: T) : Result<T>() | |
data class Error<T>(val error: SomeErrorType) : Result<T>() | |
} |
The concept is simple: you wrap the return values of your functions that can have errors with Result
and then the calling side needs to unwrap it to find if the operation was successful or not.
Note that SomeErrorType
above, can be a domain-specific error class — usually a sealed class in that case — or it can be simply Throwable
. I’ve seen both approaches.
Most times, if you are trying to follow the principles of Clean Architecture, there will be some domain logic component (like Use Cases) returning these “Results” to the presentation layer. It’s up to the presentation layer to decide if and what message to show the user. As always, code is our best friend, lets see some:
class GetMagicCardUseCase( | |
private val magicCardsRepository: MagicTheGatheringCardsRepository | |
) { | |
suspend operator fun invoke(cardName: String) : Result<MagicCard> { | |
if (!isCardNameValid(cardName)) { | |
return Error(InvalidCardNameFormat()) | |
} | |
//... | |
return magicCardsRepository.getCard(cardName) | |
} | |
} |
And then in the ViewModel:
class MagicCardSearchViewModel( | |
private val getCardUseCase: GetMagicCardUseCase | |
): ViewModel() { | |
fun onCardSearchClick() = viewModelScope.launch { | |
when (val result = getCardUseCase(cardName)) { | |
is Success -> // happy path 🎉 | |
is Error -> { | |
result.error // 🤔 | |
} | |
} | |
} | |
} |
Alright, at this point we probably want to let the user know what went wrong. But..all we know is that our error is of type SomeErrorType
, so:
- If that type is
Throwable
then what exceptions should we expect here? - If that type is a sealed class with all our app’s errors, would we handle them all here? Even though our Use Case is not going to return all those error types? Or maybe we could delegate that “mapping” from domain errors to messages on one component with that single responsibility. But then what if in this specific screen, our general message for a specific error is not what we want to show?
These were questions I kept asking myself while I was fiddling around with some sample code.
Around this time, I found kotlin-result, which is just an example (there are others) of a better way of handling errors, in my opinion. Here is a nice article about it by Adam Bennett.
For simplicity, we can change our initial Result
class to look more like what kotlin-result
offers us:
sealed class Result<out S, out E> { | |
data class Success<out S>(val data: S) : Result<S, Nothing>() | |
data class Error<out E>(val error: E) : Result<Nothing, E>() | |
} |
Besides this,
kotlin-result
also has some neat functions if you wanna chain a lot of “error-producing functions”. Even if you are using your own Result class, I definitely recommend creating some of those because you don’t want to end up having to checkif (result is Error) return result
for each of those function calls.
With this new Result 2.0, we now have typed Errors. Why does this help? Because now we can be explicit in what errors our Use Cases return. So the ideal scenario is for our Use Cases to return a specific set of errors (a sealed class with all errors as subtypes). Then in the ViewModel, we know exactly what errors we are expecting, and, if we use a when statement, we can be sure that if new errors are added, we will have to deal with them.
But this did not solve all my problems.
You see, most of our errors are not specific to a single Use Case and we also cannot expect to be able to reuse the whole hierarchy of errors always. Let’s see what I mean, again with some code:
class GetMagicCardUseCase( | |
private val mtgApi : MagicTheGatheringApi | |
) { | |
suspend operator fun invoke(cardName: String) : Result<MagicCard, GetMagicCardUseCaseError> { | |
if (!isCardNameValid(cardName)) { | |
return Error(InvalidCardNameFormat) | |
} | |
return try { | |
Success(mtgApi.getCard(cardName)) | |
} catch (t: Throwable) { | |
val error = when (t) { | |
is IOException -> RemoteServerNotReachedError | |
is HttpException -> HttpUnsuccessfulCodeError(t.code()) | |
else -> UnexpectedApiCommunicationError | |
} | |
return Error(error) | |
} | |
} | |
} | |
sealed class GetMagicCardUseCaseError | |
object InvalidCardNameFormat : GetMagicCardUseCaseError() | |
class HttpUnsuccessfulCodeError(val httpCode: Int) : GetMagicCardUseCaseError() | |
object RemoteServerNotReachedError : GetMagicCardUseCaseError() | |
object UnexpectedApiCommunicationError : GetMagicCardUseCaseError() |
Notice that we did not nest the errors inside the
GetMagicCardUseCaseError
. The only real difference is when using the classes. This way we don’t need to write the supertype name (example:GetMagicCardUseCaseError.InvalidCardNameFormat
) which would be quite verbose.
So far this is all great. But what happens if we add another Use Case which also needs to return some of these Errors? This is really not hard to imagine. In the example, we are using three HTTP-related error entries. These are useful in all Use Cases where an HTTP network request happens. Should we copy them for all of these sealed classes? In non-trivial projects, I feel that would be insanity!
Enter — Sealed interfaces for the rescue 🙌
This is the solution I came up with while trying to achieve the type-safety I wanted without losing the flexibility to mix and match errors in different Use Cases.
The reason sealed interfaces are a better fit for this is that each error can belong to multiple sealed hierarchies.
So for each new function we implement, we can pick errors we are already using somewhere else and create that function’s little hierarchy of errors by making them implement our new sealed interface.
Let’s expand the errors from the last example, considering a new Use Case, and using sealed interfaces instead of sealed classes:
sealed interface GetMagicCardUseCaseError | |
object InvalidCardNameFormat : GetMagicCardUseCaseError | |
// ----------- | |
sealed interface SaveNewDeckUseCaseError | |
object InvalidDeckSize : SaveNewDeckUseCaseError | |
// ----------- | |
sealed interface ApiCallError : GetMagicCardUseCaseError, SaveNewDeckUseCaseError | |
class HttpUnsuccessfulCodeError(val httpCode: Int) : ApiCallError | |
object RemoteServerNotReachedError : ApiCallError | |
object UnexpectedApiCommunicationError : ApiCallError |
With this change, we don’t have to repeat any error class ever again, and we even added a new error hierarchy for ApiCallError
.
This is not only for semantic reasons, we can now have our own wrapper for API calls that always returns
ApiCallError
in case of error. Maybe we’ll even have some Use Cases that can useApiCallError as the return error type (if they don’t have any other unhappy path).
But, wait a second… Is this really right? Should ApiCallError
really implement each UseCaseError
and not the other way around? 🤔
When I arrived at this solution, it felt weird for some reason. Instinctively, I almost wanted to do it the other way around. In fact, I asked two great developers of our dear community to take a look at this article after I finished the first draft, and they too felt the exact same way! (Thank you so much Adam McNeilly and Adam Bennett ❤️)
But this is in fact how we can achieve what we want. Let’s take it step by step and maybe it will click for us.
We start with one of the hierarchies above in a way we are used to writing sealed hierarchies:
sealed class GetMagicCardUseCaseError { | |
object InvalidCardNameFormat : GetMagicCardUseCaseError() | |
sealed class ApiCallError : GetMagicCardUseCaseError() { | |
class HttpUnsuccessfulCodeError(val httpCode: Int) : ApiCallError() | |
object RemoteServerNotReachedError : ApiCallError() | |
object UnexpectedApiCommunicationError : ApiCallError() | |
} | |
} | |
// Then handling one error would mean: | |
fun handleGetMagicCardUseCaseError(error: GetMagicCardUseCaseError) { | |
when (error) { | |
is GetMagicCardUseCaseError.ApiCallError.HttpUnsuccessfulCodeError -> //TODO | |
is GetMagicCardUseCaseError.ApiCallError.RemoteServerNotReachedError -> //TODO | |
is GetMagicCardUseCaseError.ApiCallError.UnexpectedApiCommunicationError -> //TODO | |
is GetMagicCardUseCaseError.InvalidCardNameFormat -> //TODO | |
} | |
} |
So far, it makes sense. Our Use Case could return a GetMagicCardUseCaseError
and we would handle it in a way that we’d have to consider all its entries. Just as we want to. Notice that for this to work, the ApiCallError
needs to extend the GetMagicCardUseCaseError
, which is hinting at us already…
We still have some inconveniences though: first is the way we access each of these entries which is quite verbose, and second is that each entry of ApiCallError
cannot belong to any other hierarchy.
The first is easy, we just remove the nestings and place each entry at top level. Everything will work as we expect it to.
To solve the second, we can replace class
keyword with interface
. In Kotlin (and in Java), a class can implement multiple interfaces but it can only extend from a single class, so here we can take advantage of the interface feature. We don’t have any reason to need a class anyway!
This is what we get once we do that:
sealed interface GetMagicCardUseCaseError | |
object InvalidCardNameFormat : GetMagicCardUseCaseError | |
sealed interface ApiCallError : GetMagicCardUseCaseError | |
class HttpUnsuccessfulCodeError(val httpCode: Int) : ApiCallError | |
object RemoteServerNotReachedError : ApiCallError | |
object UnexpectedApiCommunicationError : ApiCallError | |
fun handleError(error: GetMagicCardUseCaseError) { | |
when (error) { | |
is HttpUnsuccessfulCodeError -> //TODO | |
is RemoteServerNotReachedError -> //TODO | |
is UnexpectedApiCommunicationError -> //TODO | |
is InvalidCardNameFormat -> //TODO | |
} | |
} |
Job Offers
Ahh, things are making sense… Now if we have other “UseCaseErrors” that should contain ApiCallError
as their entries, we need to make ApiCallError
implement those “UseCaseErrors” sealed interface
.
It’s definitely a little bit weird at first, but the more I get used to the idea, the more I see how it all makes sense.
Let’s see what other advantages we get with this approach:
- “Free” documentation that cannot become obsolete for your UseCases
Ok, I cheated a little bit with this one. You can technically have the same without using sealed interfaces, but as we saw previously we’d have to copy all common errors or have some other ugly workaround. So.. I’ll keep my cookie 🍪 😁
Jokes aside, this is true value for your project that cannot be understated. If you do this, you’ll always know all possible unhappy paths each Use Case has. Just imagine entering a new team and finding that they have this system in place: a set of Use Cases that are very telling of the system, and for each of them a little defined set of errors that can occur 😍.
The IDE “Hierarchy” window (ctrl +H on macOS while selecting one of the classes) will help you to easily see what errors it contains, even if they are on different files:
Sealed interfaces allow for subclasses to be defined in different files, as long as they are contained in the same package. I already made use of this feature in my Jetpack Compose navigation library (shameless plug 😄)
Maybe you’ll like having all errors in the same package, maybe you won’t. Personally, I like it and I feel is a small price to pay to have this safety and flexibility combined, even if you don’t.
If somewhere down the line, we need to add a new unhappy path in the use case, we’ll have to make the new error implement the sealed interface we had already defined as the return error type, and then consuming classes will have a compile-time error until we go there and deal with the error.
How cool is that?
- Error types can extend other classes besides implementing your sealed interface
Note that I am using objects here for simplicity, but you can use whatever you want. For example, you can use Exceptions to have a stack trace to print out. Subtypes can extend from whatever and still be part of the sealed hierarchy!
- Flexibility
- Need a sub-hierarchy of errors related to API calls? Sure, do it! (we did that in the last examples)
- Have multiple layers that each can produce errors and all those errors should end up in the ViewModel? No problem:
class PostNewDeckUseCase( | |
private val decksRepository: DecksRepository | |
) { | |
suspend operator fun invoke(newDeck: Deck) : Result<Unit, PostNewDeckUseCaseError> { | |
if (newDeck.cards.size != 60) { | |
return Failure(InvalidDeckSizeError) | |
} | |
//... | |
return decksRepository.createNewDeck(newDeck) | |
} | |
} | |
class DecksRepository( | |
private val someMagicTheGatheringApi : MagicTheGatheringApi | |
) { | |
suspend fun createNewDeck(newDeck : Deck) : Result<Unit, CreateNewDeckError> = withContect(Dispatchers.IO) { | |
someMagicTheGatheringApi.postNewDeck(newDeck.mapToDto()) | |
//catch api exceptions and map them to `MagicTheGatheringApiCallError` | |
} | |
} | |
sealed interface PostNewDeckUseCaseError | |
object InvalidDeckSizeError: PostNewDeckUseCaseError | |
sealed interface CreateNewDeckError: PostNewDeckUseCaseError | |
object DeckAlreadyExistsError : CreateNewDeckError | |
sealed interface ApiCallError : GetSomethingUseCaseError, UpdateSomethingUseCaseError, CreateNewDeckError | |
//... |
Notice how the repository sealed interface is a subtype of the corresponding use case one. This is for the same reason we saw before with
ApiCallError
being a subtype of the Use Case types.
With this, all errors that might be added to the repository will be automatically proxied to the View Model and it will be a compile-time error if we don’t handle them. But only if we want to: we could also deal with the errors at the use case level and maybe proxy fewer of them to the UI if we feel like we don’t need as much granularity of errors to show the user (we have to remove the PostNewDeckUseCaseError
supertype out of CreateNewDeckError
if we do that though).
Conclusion
Maybe it looks weird at first, as the project grows the ApiCallError
(for example) could implement a LOT of sealed interfaces, but if you think about it, that is exactly right: those errors “belong” to each of those use cases.
That is even another bit of documentation, we can check what use cases an error can occur in by looking at the interfaces it implements. Just as we can check all errors that a use case can return by looking at all the children of that use case’s error hierarchy.
I won’t finish the post without showing you the same example we started with but now with the type-safety and flexibility of our approach:
class GetMagicCardUseCase( | |
private val magicCardsRepository: MagicTheGatheringCardsRepository | |
) { | |
suspend operator fun invoke(cardName: String) : Result<MagicCard, GetMagicCardUseCaseError> { | |
if (!isCardNameValid(cardName)) { | |
return Error(InvalidCardNameFormat) | |
} | |
//... | |
return magicCardsRepository.getCard(cardName) | |
} | |
} |
Considering the repository error type is ApiCallError
, then this error hierarchy would suffice for our Use Case.
sealed interface GetMagicCardUseCaseError | |
object InvalidCardNameFormat : GetMagicCardUseCaseError | |
sealed interface ApiCallError : GetMagicCardUseCaseError | |
class HttpUnsuccessfulCodeError(val httpCode: Int) : ApiCallError | |
object RemoteServerNotReachedError : ApiCallError | |
object UnexpectedApiCommunicationError : ApiCallError |
And in the ViewModel:
class MagicCardSearchViewModel( | |
private val getCardUseCase: GetMagicCardUseCase | |
): ViewModel() { | |
fun onCardSearchClick() = viewModelScope.launch { | |
when (val result = getCardUseCase(cardName)) { | |
is Success -> // happy path 🎉 | |
is Error -> onGetCardError(result.error) | |
} | |
} | |
private fun onGetCardError(error: GetMagicCardUseCaseError) { | |
when (error) { | |
is InvalidCardNameFormat -> //TODO | |
is HttpUnsuccessfulCodeError -> //TODO | |
is RemoteServerNotReachedError -> //TODO | |
is UnexpectedApiCommunicationError -> //TODO | |
} | |
} | |
} |
And that is it for today!
I don’t want to pretend like this is some silver bullet solution that is the best fit for all projects. As is the case with almost anything related to programming, “it depends” is your answer. That said, I think it is a cool approach that might be beneficial for some projects and has a lot of upsides.
Let me know in the comments what you feel about the approach. If I am missing something obvious, and if I was able to explain it in a nice way.
Hopefully, some of you noticed my “Magic the Gathering” related code 😃. It has been a while since I played it. Maybe now, after writing this I can do some games! (if my newborn allows me to 👶 🍼)