I previously wrote an article on implementing MVI in Jetpack Compose so if you haven’t read it, be sure to do so as I’ll be referring back to some elements from it in this article.
As a preface, I’d like to put point out that this article is library-agnostic! I won’t be talking about any specific implementations for integrating APIs or local databases and thus, everything described will be useable everywhere!
Why “Correctly”?
Well firstly, because clickbait 😇
Secondly, because I’ve spent a long time testing different ways of integrating APIs (and other network-related operations) and have found positives and negatives to each approach. This article will combine all the positives I’ve found into a single, correct, implementation.
Integration with Clean Architecture
I’m assuming you’re all well aware of the concept of Clean Architecture and the notion of Separation of Concern. You’re all hopefully familiar with which role each layer has, if not, check out the official Android guide.
To implement Clean Architecture, you need a way to define what we want to do, usually with an Interface. Once we have this, we then define how we want to do it by implementing the interface.
Following my article on implementing MVI, I will again take inspiration from the Now In Android project, specifically, the Topics feature.
Repository interface
The repository interface lies within the Domain layer as it handles the business logic part of getting data from a given source.
As stated previously, the interface defines what we want to do. Here we simply want to either get a list of topics or a single topic given an ID.
interface TopicsRepository { | |
suspend fun getTopics(): List<Topic> | |
suspend fun getTopic(id: String): Topic | |
} |
Repository implementation
Now that we have an interface defined, we need to implement this interface in the Data layer, where all our data-related operations are located.
class TopicsRepositoryImpl @Inject constructor( | |
private val topicsDataSource: TopicsDataSource | |
) : TopicsRepository { | |
override suspend fun getTopics(): List<Topic> { | |
return topicsDataSource.getTopics() | |
} | |
override suspend fun getTopic(id: String): Topic { | |
return topicsDataSource.getTopic(id) | |
} | |
} |
Focus on the Business-specific objects
It is generally frowned upon to use repositories directly in the ViewModel in the Presentation layer. Why you ask? Honestly, I don’t know, and there are cases where I find it better to do it but in nearly all other cases, I agree.
The specific cases where I don’t agree are, as stated, specific, and hence I won’t detail them. I’d rather let you leverage or build your own experience on the matter.
My experience has been very positive when using what I call UseCases. A UseCase is, as the name suggests, a piece of code responsible for handling a specific use case. Some examples include:
- LoginUseCase
- UpdateProfileUseCase
- DoSomethingReallySpecificWithLotsOfBusinessLogicUseCase
As you can see, they are usually named after what they do which has multiple advantages:
- You know exactly what the code does
- You implicitly avoid wanting to add unrelated code inside it
- You expect it to contain all the related logic and business logic
Point 3 is what I’m the biggest fan of. It’s something I took quite some time to understand but once I did, everything clicked!
To handle all possible scenarios when it comes to business logic, I implement two versions of a UseCase:
FlowUseCase
A FlowUseCase is an abstract class intended to implement, as stated previously, the associated business logic. The use of Kotlin Flows here enables the emission of multiple values over a period of time. The strength of this version is its ability to combine multiple data-related calls from different repositories if needed.
abstract class FlowUseCase<in Parameters, Success, BusinessRuleError>(private val dispatcher: CoroutineDispatcher) { | |
operator fun invoke(parameters: Parameters): Flow<Result<Success, BusinessRuleError>> { | |
return execute(parameters) | |
.catch { e -> | |
Log.e("FlowUseCase", "An error occurred while executing the use case", e) | |
emit(Result.Error(e.mapToAppError())) | |
} | |
.flowOn(dispatcher) | |
} | |
abstract fun execute(parameters: Parameters): Flow<Result<Success, BusinessRuleError>> | |
} |
If you try to copy this Gist, your code will blow up… Not entirely but the
Result
object will not be the correct one as this is a custom class I’ll detail below.
UseCase
A UseCase, contrary to its Flow counterpart, is intended to return a single value and, therefore, is more suited to unidirectional data flow scenarios. I often use them in cases where I interact with POST APIs or when updating stored application data. Cases where the return value is less significant than the potential business-related logic.
abstract class UseCase<in Parameters, Success, BusinessRuleError>(private val dispatcher: CoroutineDispatcher) { | |
suspend operator fun invoke(parameters: Parameters): Result<Success, BusinessRuleError> { | |
return try { | |
withContext(dispatcher) { | |
execute(parameters) | |
} | |
} catch (e: Throwable) { | |
Log.e("UseCase", "An error occurred while executing the use case", e) | |
Result.Error(e.mapToAppError()) | |
} | |
} | |
protected abstract suspend fun execute(parameters: Parameters): Result<Success, BusinessRuleError> | |
} |
No code blow up this time, just plain old application crash… No seriously, just wait 2 minutes before blindly copying the code…
Result
To finally tie everything together, we need this specific Result class. The class is sealed (for reasons you’ll see later on) and contains 4 internal components:
- A
Success
data class which contains the underlying data for a successful operation. - An
Error
data class which contains non-business errors in the form of a sealedAppError
class. - A
BusinessRuleError
data class which contains the business errors. - A
Loading
data object to represent the loading state where necessary.
sealed class Result<out D, out E> { | |
data class Success<out D>(val data: D) : Result<D, Nothing>() | |
data class Error(val error: AppError) : Result<Nothing, Nothing>() | |
data class BusinessRuleError<out E>(val error: E) : Result<Nothing, E>() | |
data object Loading : Result<Nothing, Nothing>() | |
fun isSuccessful() = this is Success | |
fun hasFailed() = this is Error || this is BusinessRuleError<*> | |
fun isLoading() = this is Loading | |
override fun toString(): String { | |
return when (this) { | |
is Success<*> -> "Success[data=$data]" | |
is Error -> "Error[exception=$error]" | |
is BusinessRuleError<*> -> "BusinessRuleError[error=$error]" | |
Loading -> "Loading" | |
} | |
} | |
} |
The
AppError
sealed class will not be described here. You can implement a version yourself if needed or (most likely) replace it with an existing class you may have in your codebase with a similar role. It can be used to detect JSON format errors for example without causing a crash.
Job Offers
Business errors in a UseCase
From this point on, we have everything we need to integrate proper business rules and their associated errors into our very own UseCase.
I’ll start by defining the “header” of our UseCase and explaining what’s going on. I’ll use our previously defined TopicsRepository
here along with another one to demonstrate the behaviour of a UseCase, and create a UseCase intended to load the initial data for a screen from these two repositories.
class GetForYouDataUseCase @Inject constructor( | |
private val topicsRepository: TopicsRepository, | |
private val newsRepository: NewsRepository, | |
@IoDispatcher private val dispatcher: CoroutineDispatcher | |
) : FlowUseCase<Unit, GetForYouDataSuccess, GetForYouDataErrors>(dispatcher) { | |
// Implementation will be detailed further below | |
} |
The
@IoDispatcher
annotation simply injects the underlyingDispatchers.IO
dispatcher.
What you’ll notice here is the generic parameters of the FlowUseCase
which respectively represent the: input, success output and business error output of the use case. They are defined as two distinct sealed classes, each containing the necessary classes to handle the appropriate behaviour for the use case.
These are defined within the use case for clarity so that if other classes have the same names, the underlying import can help distinguish the appropriate one to use.
sealed class GetForYouDataErrors { | |
data object NoTopicsFound : GetForYouDataErrors() | |
data object NoNewsFound : GetForYouDataErrors() | |
} | |
sealed class GetForYouDataSuccess { | |
data class TopicsData(val topics: List<Topic>) : GetForYouDataSuccess() | |
data class NewsData(val news: List<News>) : GetForYouDataSuccess() | |
} |
With these in place, the actual execute
function can be overridden and implemented in the UseCase in the following manner:
override fun execute(parameters: Unit): Flow<Result<GetForYouDataSuccess, GetForYouDataErrors>> { | |
return flow { | |
emit(Result.Loading) | |
val topics = topicsRepository.getTopics() | |
val news = newsRepository.getNews() | |
topics.ifEmpty { | |
null | |
}?.let { | |
emit(Result.Success(GetForYouDataSuccess.TopicsData(it))) | |
} ?: emit(Result.BusinessRuleError(GetForYouDataErrors.NoTopicsFound)) | |
news.ifEmpty { | |
null | |
}?.let { | |
emit(Result.Success(GetForYouDataSuccess.NewsData(it))) | |
} ?: emit(Result.BusinessRuleError(GetForYouDataErrors.NoNewsFound)) | |
} | |
} |
I’ll acknowledge that the code above may not be easily read by everyone and I’ll explain what’s going on step by step:
- We return a cold flow with the
flow
function. - We emit the
Result.Loading
value to the flow to indicate to the collector that no data has been received yet. - We call the appropriate suspend functions in the repositories to get the data.
- For the received data lists, we check if the list is empty with the
ifEmpty
block and returnnull
if so. - We use the
let
function to extract the list if it is not null and emit theResult.Success
value to the flow with the list in the appropriateGetForYouDataSuccess
class. - If the list was empty, we will run the right side of the elvis operator (
?:
) and emit the appropriateResult.BusinessRuleError
value to the flow.
Integration in a ViewModel
This is the last step to have an end-to-end solution, from getting data from a source to returning said data (or the appropriate business errors) to the user.
As you can see, with the current implementation, we can easily handle all cases we need in terms of success, business errors and other errors!
Business errors in the data layer
If we’re being honest, we could stop here and we’d have a very well structured system. We handle different cases with our two versions of a UseCase and we return business errors directly so they can be handled by the ViewModel.
I’m not going to stop because there are still some parts of this flow that I’m not totally happy with and that have caused me issues in the past. I’m talking about HTTP status codes.
Everyone knows that when you call an API, you want the holy grail of status codes as a response, the glorious 200 OK (Or any variant of it). However, we also know that more often than not, during development, you’re bound to get some other unpleasant response like 404, 422, 500, 503 and many more (the bane of my existence honestly).
You’ll hopefully have noticed now that we don’t handle these cases anywhere and rightly so, this is usually handled by our network client (OkHttp usually).
To continue my examples, I’ll assume you know about OkHttp3’s Interceptor interface which allows developers to access the requests and responses that transit through OkHttp. Namely, the
code
variable of the Response
object which represents the HTTP status code.
Long story short, what I do is create a custom IOException
that takes this code as a parameter and intercept it in the UseCase implementations with the mapToAppError()
function you’ve seen above.
I now have a way to convert an IOException
into a class with a HTTP status code for me to handle how I want! Now we need a way to leverage this when calling APIs with a custom function.
suspend fun <D, E> safeApiCall( | |
apiCall: suspend () -> D, | |
onError: suspend (StatusCode) -> E? = { null } | |
): DataResult<D, E> { | |
return withContext(Dispatchers.IO) { | |
try { | |
DataResult.Success(apiCall.invoke()) | |
} catch (e: IOException) { | |
when (val appError = e.mapToAppError()) { | |
is AppError.NetworkError -> when (val error = onError(appError.statusCode)) { | |
null -> throw e | |
else -> DataResult.Error(error) | |
} | |
else -> throw e | |
} | |
} | |
} | |
} |
I know what you’re thinking and let me explain, it’s really not as complicated as it looks. Lets break it down:
Parameters
apiCall
represents a lambda function that actually calls the API and returns the corresponding data.
onError
is a lambda that takes in a StatusCode
and returns a nullable error.
The
StatusCode
is an enum that represents all HTTP status codes.
Return type
The DataResult
class is similar to the Result
class we defined previously, it looks like this:
sealed class DataResult<out D, out E> { | |
data class Success<out D>(val data: D) : DataResult<D, Nothing>() | |
data class Error<out E>(val error: E) : DataResult<Nothing, E>() | |
fun isSuccessful() = this is Success | |
fun hasFailed() = this is Error | |
override fun toString(): String { | |
return when (this) { | |
is Success<*> -> "Success[data=$data]" | |
is Error -> "Error[error=$error]" | |
} | |
} | |
} |
Here, D
represents the success data type and E
the error data type.
Function body
We execute the whole function with Dispatchers.IO
for obvious reasons and inside this, we open a try-catch block which catches IOException
errors.
The try
block executes the apiCall
lambda and returns it as a DataResult.Success
if no exceptions have been caught, which will behave exactly like the getTopics()
function defined in our TopicsRepositoryImpl
.
The catch
block is where things get interesting and is what I’m happy about. We convert the exception to our AppError
class and based on the type, we either:
- Call the
onError
lambda with the provided status code. - Throw the exception again to be handled as a general error by the UseCase classes.
In the first case, what we want to do is check whether or not the provided status code represents an error we want to handle. If it does, the onError
lambda return an instance of the E
class, otherwise, it returns null and the exception is thrown again.
What does it look like now?
The change in the repository implementation is relatively minor but does highlight how effective this solution can be.
class TopicsRepositoryImpl @Inject constructor( | |
private val topicsDataSource: TopicsDataSource | |
) : TopicsRepository { | |
override suspend fun getTopics(): DataResult<List<Topic>, TopicsRepositoryError> { | |
return safeApiCall( | |
apiCall = { topicsDataSource.getTopics() }, | |
onError = { statusCode -> | |
when (statusCode) { | |
StatusCode.NoContent -> TopicsRepositoryError.NoTopics | |
else -> null | |
} | |
} | |
) | |
} | |
// Other API calls | |
} |
What you’ll notice here is that instead of directly returning the List<Topic>
like we had previously, this is now wrapped in our new DataResult
class along with a custom error type for this repository. The error is not defined in the implementation of the repository but in the interface as otherwise, it is not accessible in both the Data and Domain layers.
This does also make the error accessible in the Presentation layer but you’ll see further down how we avoid exposing it.
interface TopicsRepository { | |
suspend fun getTopics(): DataResult<List<Topic>, TopicsRepositoryError> | |
suspend fun getTopic(id: String): Topic | |
sealed class TopicsRepositoryError { | |
data object NoTopics : TopicsRepositoryError() | |
} | |
} |
You’ll also notice that now that our function signature has changed, our current UseCase implementation will no longer work. But you’ll be happy to know that the adaptations necessary are quite light and have no impact on the integration of our UseCase!
override fun execute(parameters: Unit): Flow<Result<GetForYouDataSuccess, GetForYouDataErrors>> { | |
return flow { | |
emit(Result.Loading) | |
val topicsResult = topicsRepository.getTopics() | |
val newsResult = newsRepository.getNews() | |
when (topicsResult) { | |
is DataResult.Error -> when (topicsResult.error) { | |
TopicsRepository.TopicsRepositoryError.NoTopics -> | |
emit(Result.BusinessRuleError(GetForYouDataErrors.NoTopicsFound)) | |
} | |
is DataResult.Success -> emit(Result.Success(GetForYouDataSuccess.TopicsData(topicsResult.data))) | |
} | |
// Equivalent implementation for newsResult | |
} | |
} |
As you can see, we check the repository business errors that originated from a given HTTP status code and emit the appropriate UseCase business error instead.
You could argue that this conversion is unnecessary and we could directly use the repository error and you’d be somewhat correct. It is (at least for this example) a 1-to-1 mapping, but don’t forget that the repository errors are associated to a given repository whereas UseCases errors are associated to a given UseCase (which can use multiple repositories).
The above example is very simple and does very little, but in a real world scenario with actual business rules and specifications to follow from Project Managers (or equivalent), the mapping will most likely be more complex and require additional verifications.
Something to take into account is that although here the repository error is a simple data object
, nothing stops you from using a data class
and have additional data passed down to the UseCase along with the error. Data that you could potentially extract from the response in the Interceptor
, alongside the associated HTTP status code.
That’s all folks!
There we have it, an end-to-end flow from a data source to a ViewModel that properly handles business errors at every possible stage!
It’s taken me quite some time to write this article but I’m very happy with how it has turned out! I hope you enjoyed reading it as much as I did writing it and that you learnt something useful along the way!
You can find the full implementation on GitHub:
https://github.com/worldline/Compose-MVI/tree/NETWORK?source=post_page—–d4a4eb6e36e8——————————–
This article is previously published on proandroiddev.com