UseCases or Interactors with Kt Flow and Retrofit
Asked Answered
S

1

7

Context

I started working on a new project and I've decided to move from RxJava to Kotlin Coroutines. I'm using an MVVM clean architecture, meaning that my ViewModels communicate to UseCases classes, and these UseCases classes use one or many Repositories to fetch data from network.

Let me give you an example. Let's say we have a screen that is supposed to show the user profile information. So we have the UserProfileViewModel:

@HiltViewModel
class UserProfileViewModel @Inject constructor(
    private val getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
    sealed class State {
        data SuccessfullyFetchedUser(
            user: ExampleUser
        ) : State()
    }
    // ...
    val state = SingleLiveEvent<UserProfileViewModel.State>()
    // ...
    fun fetchUserProfile() {
        viewModelScope.launch {
            // ⚠️ We trigger the use case to fetch the user profile info
            getUserProfileUseCase()
                .collect {
                    when (it) {
                        is GetUserProfileUseCase.Result.UserProfileFetched -> {
                            state.postValue(State.SuccessfullyFetchedUser(it.user))
                        }
                        is GetUserProfileUseCase.Result.ErrorFetchingUserProfile -> {
                            // ...
                        }
                    }
                }
        }
    }
}

The GetUserProfileUseCase use case would look like this:

interface GetUserProfileUseCase {
    sealed class Result {
        object ErrorFetchingUserProfile : Result()
        data class UserProfileFetched(
            val user: ExampleUser
        ) : Result()
    }

    suspend operator fun invoke(email: String): Flow<Result>
}

class GetUserProfileUseCaseImpl(
    private val userRepository: UserRepository
) : GetUserProfileUseCase {
    override suspend fun invoke(email: String): Flow<GetUserProfileUseCase.Result> {
        // ⚠️ Hit the repository to fetch the info. Notice that if we have more 
        // complex scenarios, we might require zipping repository calls together, or
        // flatmap responses.
        return userRepository.getUserProfile().flatMapMerge { 
            when (it) {
                is ResultData.Success -> {
                    flow { emit(GetUserProfileUseCase.Result.UserProfileFetched(it.data.toUserExampleModel())) }
                }
                is ResultData.Error -> {
                    flow { emit(GetUserProfileUseCase.Result.ErrorFetchingUserProfile) }
                }
            }
        }
    }
}

The UserRepository repository would look like this:

interface UserRepository {
    fun getUserProfile(): Flow<ResultData<ApiUserProfileResponse>>
}

class UserRepositoryImpl(
    private val retrofitApi: RetrofitApi
) : UserRepository {
    override fun getUserProfile(): Flow<ResultData<ApiUserProfileResponse>> {
        return flow {
            val response = retrofitApi.getUserProfileFromApi()
            if (response.isSuccessful) {
                emit(ResultData.Success(response.body()!!))
            } else {
                emit(ResultData.Error(RetrofitNetworkError(response.code())))
            }
        }
    }
}

And finally, the RetrofitApi and the response class to model the backend API response would look like this:

data class ApiUserProfileResponse(
    @SerializedName("user_name") val userName: String
    // ...
)

interface RetrofitApi {
    @GET("api/user/profile")
    suspend fun getUserProfileFromApi(): Response<ApiUserProfileResponse>
}

Everything has been working fine so far, but I've started to run into some issues when implementing more complex features.

For example, there's a use case where I need to (1) post to a POST /send_email_link endpoint when the user first signs in, this endpoint will check if the email that I send in the body already exists, if it doesn't it will return a 404 error code, and (2) if everything goes okay, I'm supposed to hit a POST /peek endpoint that will return some info about the user account.

This is what I've implemented so far for this UserAccountVerificationUseCase:

interface UserAccountVerificationUseCase {
    sealed class Result {
        object ErrorVerifyingUserEmail : Result()
        object ErrorEmailDoesNotExist : Result()
        data class UserEmailVerifiedSuccessfully(
            val canSignIn: Boolean
        ) : Result()
    }

    suspend operator fun invoke(email: String): Flow<Result>
}

class UserAccountVerificationUseCaseImpl(
    private val userRepository: UserRepository
) : UserAccountVerificationUseCase {
    override suspend fun invoke(email: String): Flow<UserAccountVerificationUseCase.Result> {
        return userRepository.postSendEmailLink().flatMapMerge { 
            when (it) {
                is ResultData.Success -> {
                    userRepository.postPeek().flatMapMerge { 
                        when (it) {
                            is ResultData.Success -> {
                                val canSignIn = it.data?.userName == "Something"
                                flow { emit(UserAccountVerificationUseCase.Result.UserEmailVerifiedSuccessfully(canSignIn)) }
                            } else {
                                flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                            }
                        }
                    }
                }
                is ResultData.Error -> {
                    if (it.exception is RetrofitNetworkError) {
                        if (it.exception.errorCode == 404) {
                            flow { emit(UserAccountVerificationUseCase.Result.ErrorEmailDoesNotExist) }
                        } else {
                            flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                        }
                    } else {
                        flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                    }
                }
            }
        }
    }
}

Issue

The above solution is working as expected, if the first API call to the POST /send_email_link ever returns a 404, the use case will behave as expected and return the ErrorEmailDoesNotExist response so the ViewModel can pass that back to the UI and show the expected UX.

The problem as you can see is that this solution requires a ton of boilerplate code, I thought using Kotlin Coroutines would make things simpler than with RxJava, but it hasn't turned out like that yet. I'm quite sure that this is because I'm missing something or I haven't quite learned how to use Flow properly.

What I've tried so far

I've tried to change the way I emit the elements from the repositories, from this:

...
    override fun getUserProfile(): Flow<ResultData<ApiUserProfileResponse>> {
        return flow {
            val response = retrofitApi.getUserProfileFromApi()
            if (response.isSuccessful) {
                emit(ResultData.Success(response.body()!!))
            } else {
                emit(ResultData.Error(RetrofitNetworkError(response.code())))
            }
        }
    }
...

To something like this:

...
    override fun getUserProfile(): Flow<ResultData<ApiUserProfileResponse>> {
        return flow {
            val response = retrofitApi.getUserProfileFromApi()
            if (response.isSuccessful) {
                emit(ResultData.Success(response.body()!!))
            } else {
                error(RetrofitNetworkError(response.code()))
            }
        }
    }
..

So I can use the catch() function like I'd with RxJava's onErrorResume():

class UserAccountVerificationUseCaseImpl(
    private val userRepository: UserRepository
) : UserAccountVerificationUseCase {
    override suspend fun invoke(email: String): Flow<UserAccountVerificationUseCase.Result> {
        return userRepository.postSendEmailLink()
            .catch { e ->
                if (e is RetrofitNetworkError) {
                    if (e.errorCode == 404) {
                        flow { emit(UserAccountVerificationUseCase.Result.ErrorEmailDoesNotExist) }
                    } else {
                        flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                    }
                } else {
                    flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                }
            }
            .flatMapMerge {
                userRepository.postPeek().flatMapMerge {
                    when (it) {
                        is ResultData.Success -> {
                            val canSignIn = it.data?.userName == "Something"
                            flow { emit(UserAccountVerificationUseCase.Result.UserEmailVerifiedSuccessfully(canSignIn)) }
                        } else -> {
                            flow { emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail) }
                        }
                    }
                }
            }
        }
    }
}

This does reduce the boilerplate code a bit, but I haven't been able to get it working because as soon as I try to run the use case like this I start getting errors saying that I shouldn't emit items in the catch().

Even if I could get this working, still, there's way too much boilerplate code here. I though doing things like this with Kotlin Coroutines would mean having much more simple, and readable, use cases. Something like:

...
class UserAccountVerificationUseCaseImpl(
    private val userRepository: AuthRepository
) : UserAccountVerificationUseCase {
    override suspend fun invoke(email: String): Flow<UserAccountVerificationUseCase.Result> {
        return flow {
            coroutineScope {
                val sendLinksResponse = userRepository.postSendEmailLink()
                if (sendLinksResponse is ResultData.Success) {
                    val peekAccount = userRepository.postPeek()
                    if (peekAccount is ResultData.Success) {
                        emit(UserAccountVerificationUseCase.Result.UserEmailVerifiedSuccessfully())
                    } else {
                        emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail)
                    }
                } else {
                    if (sendLinksResponse is ResultData.Error) {
                        if (sendLinksResponse.error == 404) {
                            emit(UserAccountVerificationUseCase.Result.ErrorEmailDoesNotExist)
                        } else {
                            emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail)
                        }
                    } else {
                        emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail)
                    }
                }
            }
        }
    }
}
...

This is what I had pictured about working with Kotlin Coroutines. Ditching RxJava's zip(), contact(), delayError(), onErrorResume() and all those Observable functions in favor of something more readable.

Question

How can I reduce the amount of boilerplate code and make my use cases look more Coroutine-like?

Notes

I know some people just call the repositories directly from the ViewModel layer, but I like having this UseCase layer in the middle so I can contain all the code related to switching streams and handling errors here.

Any feedback is appreciated! Thanks!

Edit #1

Based on @Joffrey response, I've changed the code so it works like this:

The Retrofit API layer keeps returning suspendable function.

data class ApiUserProfileResponse(
    @SerializedName("user_name") val userName: String
    // ...
)

interface RetrofitApi {
    @GET("api/user/profile")
    suspend fun getUserProfileFromApi(): Response<ApiUserProfileResponse>
}

The repository now returns a suspendable function and I've removed the Flow wrapper:

interface UserRepository {
    suspend fun getUserProfile(): ResultData<ApiUserProfileResponse>
}

class UserRepositoryImpl(
    private val retrofitApi: RetrofitApi
) : UserRepository {
    override suspend fun getUserProfile(): ResultData<ApiUserProfileResponse> {
        val response = retrofitApi.getUserProfileFromApi()
        return if (response.isSuccessful) {
            ResultData.Success(response.body()!!)
        } else {
            ResultData.Error(RetrofitNetworkError(response.code()))
        }
    }
}

The use case keeps returning a Flow since I might also plug calls to a Room DB here:

interface GetUserProfileUseCase {
    sealed class Result {
        object ErrorFetchingUserProfile : Result()
        data class UserProfileFetched(
            val user: ExampleUser
        ) : Result()
    }

    suspend operator fun invoke(email: String): Flow<Result>
}

class GetUserProfileUseCaseImpl(
    private val userRepository: UserRepository
) : GetUserProfileUseCase {
    override suspend fun invoke(email: String): Flow<GetUserProfileUseCase.Result> {
        return flow {
            val userProfileResponse = userRepository.getUserProfile()
            when (userProfileResponse) {
                is ResultData.Success -> {
                    emit(GetUserProfileUseCase.Result.UserProfileFetched(it.toUserModel()))
                }
                is ResultData.Error -> {
                    emit(GetUserProfileUseCase.Result.ErrorFetchingUserProfile)
                }
            }
        }
    }
}

This looks much more clean. Now, applying the same thing to the UserAccountVerificationUseCase:

interface UserAccountVerificationUseCase {
    sealed class Result {
        object ErrorVerifyingUserEmail : Result()
        object ErrorEmailDoesNotExist : Result()
        data class UserEmailVerifiedSuccessfully(
            val canSignIn: Boolean
        ) : Result()
    }

    suspend operator fun invoke(email: String): Flow<Result>
}

class UserAccountVerificationUseCaseImpl(
    private val userRepository: UserRepository
) : UserAccountVerificationUseCase {
    override suspend fun invoke(email: String): Flow<UserAccountVerificationUseCase.Result> {
        return flow { 
            val sendEmailLinkResponse = userRepository.postSendEmailLink()
            when (sendEmailLinkResponse) {
                is ResultData.Success -> {
                    val peekResponse = userRepository.postPeek()
                    when (peekResponse) {
                        is ResultData.Success -> {
                            val canSignIn = peekResponse.data?.userName == "Something"
                            emit(UserAccountVerificationUseCase.Result.UserEmailVerifiedSuccessfully(canSignIn)
                        }
                        else -> {
                            emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail)
                        }
                    }
                }
                is ResultData.Error -> {
                    if (sendEmailLinkResponse.isNetworkError(404)) {
                        emit(UserAccountVerificationUseCase.Result.ErrorEmailDoesNotExist)
                    } else {
                        emit(UserAccountVerificationUseCase.Result.ErrorVerifyingUserEmail)
                    }
                }
            }
        }
    }
}

This looks much more clean and it works perfectly. I still wonder if there's any more room for improvement here.

Sierra answered 6/12, 2021 at 14:19 Comment(0)
M
6

The most obvious problem I see here is that you're using Flow for single values instead of suspend functions.

Coroutines makes the single-value use case much simpler by using suspend functions that return plain values or throw exceptions. You can of course also make them return Result-like classes to encapsulate errors instead of actually using exceptions, but the important part is that with suspend functions you are exposing a seemingly synchronous (thus convenient) API while still benefitting from asynchronous runtime.

In the provided examples you're not subscribing for updates anywhere, all flows actually just give a single element and complete, so there is no real reason to use flows and it complicates the code. It also makes it harder to read for people used to coroutines because it looks like multiple values are coming, and potentially collect being infinite, but it's not the case.

Each time you write flow { emit(x) } it should just be x.

Following the above, you're sometimes using flatMapMerge and in the lambda you create flows with a single element. Unless you're looking for parallelization of the computation, you should simply go for .map { ... } instead. So replace this:

val resultingFlow = sourceFlow.flatMapMerge {
    if (something) {
        flow { emit(x) }
    } else {
        flow { emit(y) }
    }
}

With this:

val resultingFlow = sourceFlow.map { if (something) x else y }
Matteroffact answered 6/12, 2021 at 14:46 Comment(5)
Thanks for the response! Yeah, I'm definitely lacking some Coroutine knowledge, after moving forward with your proposed changes I was able to implement a much cleaner solution. Please, let me know if you see anything else I could improve here and I'll mark this as done. Thanks again for the help!Sierra
@Sierra the only other thing that I find a bit confusing in the question's code is the fact that use cases are interfaces that are used as functions via invoke. I think it would be clearer (at least to me) if invoke was replaced with a name from the domain and thus used with regular .call() syntax. That said, it really does look like those use cases are in fact representing functions, but then why not use function types instead? I think it might just be me not being familiar with this use case architecture :DMatteroffact
Thanks! Actually I used to have an execute() function instead of the invoke() honestly, I don't have any strong arguments to override the invoke() operator other than it makes the use case call look like someUseCase() instead of someUseCase.call() or someUseCase.execute(). Here's a SO post from some years ago from where I took the idea.Sierra
About function types, I'm not actually quite sure how I'd be able to apply that in this scenario. I mean, I'm used to use function types as callbacks for the UI, but I'm not sure how I'd be able to apply them here, and inject them via Dagger.Sierra
@Sierra good point with the dependency injection. I don't know how dagger would deal with function types to be honest. In any case this is just nitpicking on my sideMatteroffact

© 2022 - 2024 — McMap. All rights reserved.