CategoriesArchitecture

Android MVVM architecture with clean error handling – Part 1

MVVM architecture is a popular architecture among android developers due to it being more suitable for most of the project sizes which provide more flexibility to scale project with code separation. Google JetPack libraries also work with this architecture like bread and butter to make it easier to implement reactive solutions with less boilerplate code. Error handling is a huge part of any project when it’s come to giving a good user experience. In this article, I’m going to show my approach to handling errors which allows you to develop cleaner functional requirements while treating error handling is as a separate stream.

To have a good error handling solution first we need to understand which parts of our application generates errors and what we need to communicate to the user. Let’s start from the bottom layer of our architecture which is the repository layer. Application all network and database related business logic are situated in this layer. When it comes to network call-related errors we need to inform users with descriptive error messages almost all the time. The only exception to this is errors that cause because internal issues (Bad Json phrasing). In those cases providing a generic error with description is enough. When It comes to database-related error handling most of the time we will have to inform the success and fail status of the database operation. The only exception to this is errors that cause when writing optional recodes to the database. An example of this would be caching network calls to use on offline mode. When it comes to the middle layer which is the view model layer, Usually form-related validation is done in this layer. in case of validation fail we need to showcase those errors underneath the edit text or to other elements to guide users.

Architecture diagram

The above data flow chart shows how the data is processed which are the data coming from either database or network and how error detail is handled. You may have already noticed that the right side of this diagram represents the MVVM architecture. On top of MVVM architecture, I have defined base classes that have the responsibility of carrying error details to the top layer. This way we can eliminate a lot of repetitive code while having the freedom to customize behavior if needed. 

The example I’m using is a registration form that makes the network call to a remote server. This network call lets you know if the registration is successful or fails based on input data. I have implemented a network interceptor to emulate the remote server business logic. Let’s start to explore the code from the repository layer. The complete project can be found here. Please refer the “error_handling_part_1” branch.

Before I explain what happens in each layer I want you to show the wrapper class which uses to communicates the errors that occur in any layer of the application to the UI layer. 

class OperationError private constructor(
        val errorType: OperationErrorType,
        val errorId: Int? = 1,
        val messageTitle: String? = null,
        val message: String? = null,
        val fieldErrors: Map<String, Any>? = null) {

    data class Builder(
            var errorType: OperationErrorType,
            var errorId: Int? = 1,
            var messageTitle: String? = null,
            var message: String? = null,
            var fieldErrors: Map<String, Any>? = null) {

        fun errorId(errorId: Int) = apply { this.errorId = errorId }
        fun messageTitle(messageTitle: String) = apply { this.messageTitle = messageTitle }
        fun message(message: String) = apply { this.message = message }
        fun fieldError(fieldErrors: Map<String, Any>) = apply { this.fieldErrors = fieldErrors }

        fun build() = OperationError(
                errorType,
                errorId,
                messageTitle = messageTitle,
                message = message,
                fieldErrors = fieldErrors
        )
    }
}

This class is built using the builder design pattern for easy implementation. Usually, in android MVVM architecture design code example they use some sort of wrapper class to communicate data to the upper layer with operation status. Having a separate wrapper class for just for errors gives us three advantages. The most obvious one has been reusability. The second advantage is having more clear code when defining live data. For example, let’s say we have a network call that returns the cart item list. So the result will be a list of Items (List<Items>). If we are to use a traditional wrapper class for this data it will be its type will be Resource<List<Items>>. When we want this to be single event live data its type will have to define as MutableLiveData<SingleLiveEvent<Resource<List<Items>>>> . As you can see it’s not that readable. When having two live data streams for data and for error handling, In repository or view model we can focus on handling data or errors more clearly. The third advantage is not having to use SingleLiveEvent when it’s only required partially. In the same example above when we wrap both errors and data together, we have to use SingleLiveEvent even though there is no harm not using it for loading list item in UI. The result is wrapped into a SingleLiveEvent because in case of error it is needed to show that message as a dialog or toast message. If it’s not a SingleLiveEvent that dialog or toast message will display again in configuration changes.

Let’s start exploring the code from the repository layer. Network calls are called in this layer by using the data sent from the UI layer. When doing a network calls it can output several result states. If it is connected to the server without any issues and if the relevant server operation ends up being successful it is identified as a success statue. If it is connected to the server without any issues but if it’s relevant server operation ends up been failed it is identified as a response error statue. If it fails to connect to the server then it is Identified as connection error status. Any other error that could occur due to code or JSON phase error is under processing errors status. All above mentioned scenarios are handle in flowing code.

class RegisterRepository @Inject constructor(private val apiService: ApiService) :
    BaseRepository() {

    val registerLiveData = MutableLiveData<SingleLiveEvent<String>>()

    suspend fun registerUser(requestBody: RegisterRequest) {

        try {
            val response = apiService.registerUsers(requestBody)

            if (response.status) {
                registerLiveData.value = SingleLiveEvent(response.message)
            } else {

                val errors = mutableMapOf<String, String>()
                response.errors?.forEach { error ->
                    when (error.fieldKey) {
                        "user_name" -> {
                            errors[RegisterViewModel.usernameErrorKey] = error.errorMessage
                        }
                        "email" -> {
                            errors[RegisterViewModel.emailErrorKey] = error.errorMessage
                        }
                    }
                }

                responseError(response.message, errors)
            }

        } catch (throwable: Throwable) {

            when (throwable) {
                is IOException -> processingError()
                is HttpException -> {
                    connectionError(throwable.message())
                }
                else -> {
                    processingError()
                }
            }

        }

    }
}

As you can see this repository class is inheriting BaseRepository class. This class is responsible for creating live data which stores the error data and reusable functions to assign those error data. Let’s take a look at BaseRepository class.

open class BaseRepository {

    val operationErrorLiveDate = MutableLiveData<SingleLiveEvent<OperationError>>()

    fun responseError(
        errorMessage: String,
        fieldErrors: Map<String, Any> = mapOf(),
        errorId: Int = 1) {

        val operationError = OperationError
            .Builder(OperationErrorType.RESPONSE_ERROR)
            .errorId(errorId)
            .messageTitle("Response Error")
            .message(errorMessage)
            .fieldError(fieldErrors)
            .build()

        operationErrorLiveDate.value = SingleLiveEvent(operationError)
    }

    fun connectionError(errorMessage: String, errorId: Int = 1) {

        val operationError = OperationError
            .Builder(OperationErrorType.CONNECTION_ERROR)
            .errorId(errorId)
            .messageTitle("Connection Error")
            .message(errorMessage)
            .build()

        operationErrorLiveDate.value = SingleLiveEvent(operationError)
    }

    fun processingError(errorId: Int = 1) {

        val operationError = OperationError
            .Builder(OperationErrorType.PROCESSING_ERROR)
            .errorId(errorId)
            .messageTitle("Processing Error")
            .message("Something Went Wrong Please Try again later.")
            .build()

        operationErrorLiveDate.value = SingleLiveEvent(operationError)
    }
}

Functions are named according to their respective state. The responseError function takes three parameters. first parameter (errorMessage) is the error message which usually returns by the API to inform what went wrong with the input data. The second parameter (fieldErrors) usually only useful only in a scenario of form submission. It is back-end validation for the input data. In those cases, API will send you an array of errors that need to be shown under matching form input fields. In that cases, this parameter is used. Then third parameter (errorId) is used only when the same screen handles multiple network calls which are not having any relation to one another and when it is required to show error messages separately. The connectionError function is used in case of server connection failed. The first parameter of this function is the same as in responseError function used to store the message. But In this case, you do not get a JSON response usually. This message description can be obtained from throwable.message() . processingError is used when something is wrong on our end. Commonly occurs when the backend returns a non-expected response which is not handled in JSON phasing. next, we’ll move into the view model layer to see how things are handle in there.

This view model has two responsibilities. The first responsibility is to trigger the repository function to do the network call for user registration. The second one is to do the form validation before calling the registration network call.

class RegisterViewModel @Inject constructor(private val repository: RegisterRepository)
    :BaseViewModel(repository) {

    val registerStatusLiveData : LiveData<SingleLiveEvent<String>>

    init {
        registerStatusLiveData = Transformations.map(repository.registerLiveData){
            isProcessing.value = false
            return@map it
        }
    }

    fun registerUser(requestBody: RegisterRequest){

        isProcessing.value = true
        val isFormValid = validateRegisterForm(requestBody)

        if(isFormValid.first){
            viewModelScope.launch{
                repository.registerUser(requestBody)
            }
        }else{
            validationError(isFormValid.second)
        }

    }

    private fun validateRegisterForm(requestBody: RegisterRequest): Pair<Boolean, Map<String, Any>>{

        var isFormValid = true
        val fieldErrors = mutableMapOf<String, Any> ()

        if(requestBody.userName.isBlank()){
            fieldErrors[usernameErrorKey] = R.string.empty_user_name_error
            isFormValid = false
        }else if(requestBody.userName.matches(".*\\s.*".toRegex())){
            fieldErrors[usernameErrorKey] = R.string.user_name_with_space_error
            isFormValid = false
        }else{
            fieldErrors.remove(usernameErrorKey)
        }

        //------- Rest of the Validation -------- //

        return Pair(isFormValid, fieldErrors)
    }

    companion object {
        const val usernameErrorKey = "usernameFiled"
        const val emailErrorKey = "emailFiled"
        const val passwordErrorKey = "passwordField"
        const val confirmPasswordKey = "confirmPasswordField"
    }

}

Note the companion object in this class. These values are used to Identify which error message goes for which error filed. You may have noticed these values have been used in the previous repository layer too. The second thing to notice is the error message values. I have referred to the string resource file for that. I could have used AndroidViewModel and get the string values from the resource file instead of passing Id. But doing that has a downside as you can see in this article. I could not refer to the error messages from a file in the repository as I have done it here because those are dynamic error message. Let’s get into BaseViewModel which this class has been inherited from.

abstract class BaseViewModel(repository: BaseRepository): ViewModel() {

    private val _operationErrorLiveData =  MutableLiveData<SingleLiveEvent<OperationError>>()

    val isProcessing = MutableLiveData<Boolean>()

    val operationErrorLiveData: MediatorLiveData<SingleLiveEvent<OperationError>> = MediatorLiveData()

    init {

        operationErrorLiveData.addSource(repository.operationErrorLiveDate){
            operationErrorLiveData.value = it
            isProcessing.value = false
        }

        operationErrorLiveData.addSource(_operationErrorLiveData){
            operationErrorLiveData.value = it
            isProcessing.value = false

        }

    }

    fun validationError(fieldErrors: Map<String, Any> = mapOf(), errorId: Int = 1){

        val operationError = OperationError
            .Builder(OperationErrorType.VALIDATION_ERROR)
            .errorId(errorId)
            .fieldError(fieldErrors)
            .build()

        _operationErrorLiveData.value = SingleLiveEvent(operationError)
    }
}

Same as in the BaseRepository we have dedicated live data for handling errors. But in this class, it is little different than the implementation in the repository layer. I have used two live data objects. _operationErrorLiveData is for to set value when validationvalidateRegisterForm function on RegisterViewModel identify there is an error in form input data. I used validationError to assign the operation error. The operationErrorLiveData is a MediatorLiveData which can be used to merge two live data streams. You can see I used _operationErrorLiveData and repository class operationErrorLiveDate as the sources of it. This way from the UI layer it only has to observe one live data to catch if there are any errors that need to show the user. Additionally, I have introduced isProcessing live data to let UI know whether the data processing is still happening or not in the bottom layers. 

In the UI layer, I’m using a single activity approach with a navigation architecture component. I have created a fragment that contains the form as the UI. This Fragment is responsible for taking input data and let users know whether the user registration status is successful or not.

class HomeFragment : BaseFragment(R.layout.fragment_home) {

    @Inject
    override lateinit var navigationController: NavController
    override val viewModel by viewModel { fragmentSubComponent.registerViewModel }

    private lateinit var fragmentSubComponent: FragmentSubComponent
    private lateinit var viewBinding: FragmentHomeBinding

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        viewBinding = FragmentHomeBinding.bind(view)

        initialization()
        observeRegisterStatus()
        onClickRegisterButton()
    }

    private fun initialization() {

        fragmentSubComponent = injector.fragmentComponent().create(requireView())
        fragmentSubComponent.inject(this)

        super.initialization({ onDataProcessing() }, { onDataProcessingComplete() })
    }

    private fun observeRegisterStatus(){
        viewModel.registerStatusLiveData.observe(viewLifecycleOwner){
            it.getContentIfNotHandled()?.let { toastMessage ->
                showToast(toastMessage,Toast.LENGTH_LONG)
            }
        }
    }

    private fun onClickRegisterButton() {
        viewBinding.registerButton.setOnClickListener {

            val username = viewBinding.userNameEditText.text.toString()
            val email = viewBinding.emailEditText.text.toString()
            val password = viewBinding.passwordEditText.text.toString()
            val passwordConfirm = viewBinding.passwordConfirmEditText.text.toString()

            viewBinding.userNameInputLayout.error = null
            viewBinding.userNameInputLayout.isErrorEnabled = false
            viewBinding.emailInputLayout.error = null
            viewBinding.emailInputLayout.isErrorEnabled = false
            viewBinding.passwordInputLayout.error = null
            viewBinding.passwordInputLayout.isErrorEnabled = false
            viewBinding.passwordConfirmInputLayout.error = null
            viewBinding.passwordConfirmInputLayout.isErrorEnabled = false

            val registerRequest = RegisterRequest(username, email, password, passwordConfirm)
            viewModel.registerUser(registerRequest)

        }
    }

    private fun showFormErrors(fieldErrors: Map<String, Any>) {

        fieldErrors.forEach {
            when (it.key) {
                RegisterViewModel.usernameErrorKey -> {
                    viewBinding.userNameInputLayout.error = resolveErrorResource(it.value)
                    viewBinding.userNameInputLayout.isErrorEnabled = true
                }
                RegisterViewModel.emailErrorKey -> {
                    viewBinding.emailInputLayout.error = resolveErrorResource(it.value)
                    viewBinding.emailInputLayout.isErrorEnabled = true
                }
                RegisterViewModel.passwordErrorKey -> {
                    viewBinding.passwordInputLayout.error =
                        resolveErrorResource(it.value)
                    viewBinding.passwordInputLayout.isErrorEnabled = true
                }
                RegisterViewModel.confirmPasswordKey -> {
                    viewBinding.passwordConfirmInputLayout.error =
                        resolveErrorResource(it.value)
                    viewBinding.passwordConfirmInputLayout.isErrorEnabled = true
                }
            }
        }
    }

    override fun handleValidationError(operationError: OperationError) {
        if (!operationError.fieldErrors.isNullOrEmpty()) {
            showFormErrors(operationError.fieldErrors)
        }
    }

    override fun handleResponseError(operationError: OperationError) {
        
        if (!operationError.fieldErrors.isNullOrEmpty()) {
            showFormErrors(operationError.fieldErrors)
        }else{
            showDialog(operationError.messageTitle,operationError.message)
        }
    }

    private fun onDataProcessing(){
        viewBinding.loadingIndicator.visibility = View.VISIBLE
        viewBinding.registerButton.visibility = View.GONE
    }

    private fun onDataProcessingComplete() {
        viewBinding.loadingIndicator.visibility = View.GONE
        viewBinding.registerButton.visibility = View.VISIBLE
    }
}

I want to highlight a few functions in this fragment. First  showFormErrors function which use to show filed errors in the relevant input layout. Note it has used a function called resolveErrorResource to get the error message. As you may have recall there were two types of error values in previous layers which could contain string resource id or string value. This function is defined in BaseFragment to get the resource base on the type. Let’s take a look at BaseFragment.

abstract class BaseFragment(layoutResource: Int) : Fragment(layoutResource) {

    protected abstract val navigationController: NavController
    protected abstract val viewModel: BaseViewModel

    protected open fun initialization(onDataProcessing: (() ->Unit)?,
                                      onDataProcessingComplete: (() ->Unit)?) {
        observeOperationError()
        observeProcessingStatus(onDataProcessing,onDataProcessingComplete)
    }

    private fun observeProcessingStatus(
        onDataProcessing: (() -> Unit)?,
        onDataProcessingComplete: (() -> Unit)?
    ) {
        viewModel.isProcessing.observe(viewLifecycleOwner){
            if(it){
                onDataProcessing?.invoke()
            }else{
                onDataProcessingComplete?.invoke()
            }
        }
    }

    private fun observeOperationError() {

        viewModel.operationErrorLiveData.observe(viewLifecycleOwner){
            it.getContentIfNotHandled()?.also { operationError ->
                handleError(operationError)
            }
        }
    }

    protected open fun handleError(operationError: OperationError) {

        when (operationError.errorType) {

            OperationErrorType.VALIDATION_ERROR -> {
                handleValidationError(operationError)
            }
            OperationErrorType.RESPONSE_ERROR -> {
                handleResponseError(operationError)
            }
            OperationErrorType.CONNECTION_ERROR -> {
                handleConnectionError(operationError)
            }
            OperationErrorType.PROCESSING_ERROR -> {
               handleProcessingError(operationError)
            }
        }
    }

    protected open fun handleValidationError(operationError: OperationError){}

    protected open fun handleResponseError(operationError: OperationError){
        showDialog(operationError.messageTitle,operationError.message)
    }

    protected open fun handleConnectionError(operationError: OperationError){
        showDialog(operationError.messageTitle,operationError.message)
    }

    protected open fun handleProcessingError(operationError: OperationError){
        showDialog(operationError.messageTitle,operationError.message)
    }

    fun resolveErrorResource(value: Any): String{
        return if(value is Int){
            getString(value)
        }else{
            value.toString()
        }
    }

    protected open fun showDialog(title: String?, message: String?, navigateUp: Boolean = true) {

        if (title != null && message != null) {

            MaterialDialog(requireContext()).show {
                title(text = title)
                message(text = message)
                positiveButton(text = "OK")
                positiveButton {
                    if(navigateUp){
                        navigationController.navigateUp()
                    }
                }
            }
        }
    }

    protected open fun showToast(message: String, toastDisplayTime: Int){
        Toast.makeText(context,message,toastDisplayTime).show()
    }

}

This is the place where I created the function that handles the error coming from the bottom layers. Usually, throughout an application, there will be a common way to showcase the error messages. These functions in BaseFragment is implemented having that in mind. However, the way this code structured whenever it is required to handle errors differently we have total control to override the default behavior. In cases where the default error handling way is required, we can reuse these functions in BaseFragment without overriding them.

The base class initialization method takes two functions as parameters. which are nullable. These two functions are to decide what animation show when data is processing in the below layers and how to reset elements back to normal. The relevant function is called base on isProcessing live data in BaseViewModel. This behavior only can be defined in the fragment which define the layout. That’s why those functions’ control has been given to the HomeFragment. Those two parameters are nullable because there might be some fragments that do not do any data processing.

As you can see inside observeOperationError() code is observing operationErrorLiveData which we have been maintaining throughout the below layers for error handling. When there is data to be observed it calls handleError function. Note that the functions called inside handleError are functions that are open to override in the fragment which inherits  BaseFragment. This is done to give full control to that fragment when it’s needed.

This concludes all the layers of MVVM architecture. This is a scalable and flexible solution for error handling which can use in any project. 


Part 2 of this article is available now.

Published by Chathuranga Shan

I am a mobile application developer who loves to get into new technologies in the Industry. Specialized in Android mobile application development with 5+ years’ experience working for different types of products across many business domains.

3 comments on “Android MVVM architecture with clean error handling – Part 1”

  1. Hello! I’ve been reading your site for a while now and finally got the bravery to go ahead and give you a shout out from Huffman Texas! Just wanted to mention keep up the fantastic job!|

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.