Blog Infos
Author
Published
Topics
, ,
Published
Photo by Rohit Tandon on Unsplash

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:

  1. You know exactly what the code does
  2. You implicitly avoid wanting to add unrelated code inside it
  3. 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!

Accurate visual representation of when I learnt how to correctly use UseCases

 

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>>
}
view raw FlowUseCase.kt hosted with ❤ by GitHub

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>
}
view raw UseCase.kt hosted with ❤ by GitHub

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:

  1. Success data class which contains the underlying data for a successful operation.
  2. An Error data class which contains non-business errors in the form of a sealed AppError class.
  3. BusinessRuleError data class which contains the business errors.
  4. 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"
}
}
}
view raw Result.kt hosted with ❤ by GitHub

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

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 underlying Dispatchers.IO dispatcher.

What you’ll notice here is the generic parameters of the FlowUseCase which respectively represent the: inputsuccess 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:

  1. We return a cold flow with the flow function.
  2. We emit the Result.Loading value to the flow to indicate to the collector that no data has been received yet.
  3. We call the appropriate suspend functions in the repositories to get the data.
  4. For the received data lists, we check if the list is empty with the ifEmpty block and return null if so.
  5. We use the let function to extract the list if it is not null and emit the Result.Success value to the flow with the list in the appropriate GetForYouDataSuccess class.
  6. If the list was empty, we will run the right side of the elvis operator (?:) and emit the appropriate Result.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.

https://gist.github.com/0008f106a711f185549376d85bcdc71

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 404422500503 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
}
}
}
}
view raw SafeApiCall.kt hosted with ❤ by GitHub

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]"
}
}
}
view raw DataResult.kt hosted with ❤ by GitHub

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:

  1. Call the onError lambda with the provided status code.
  2. 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 objectnothing 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 Interceptoralongside 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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu