Skip to content

Data Result Modeling Between Layers

Jung SeokJoon edited this page Mar 6, 2024 · 8 revisions

Introduction

GoonersApp is composed of several modules and has adopted some clean architecture.

The Network, Database, and Data modules are the Data layer, the Domain module is the Domain layer, and the feature module is the Presentation layer.

This article summarizes the modeling (mapping) that occurs when exchanging data between each layer and module.

This article is based on the following links : https://proandroiddev.com/modeling-retrofit-responses-with-sealed-classes-and-coroutines-9d6302077dfe

Benefits of Modeling

  1. It is possible to return consistently structured responses for success, errors, and exception cases.
  2. It is possible to write more concisely by using it together with extension functions.
  3. It is easy to handle in the observer (ex. ViewModel).

Network To Data

The Network module passes the API response received by Retrofit to the Data module.

Create a NetworkResult Sealed Class and convert the exception that occurs when calling the API and the value or error received in the response to a NetworkResult.

sealed class NetworkResult<T> {
    class Success<T>(val data: T) : NetworkResult<T>()
    class Error<T>(val code: Int, val message: String?) : NetworkResult<T>()
    class Exception<T>(val e: Throwable) : NetworkResult<T>()
}

According to our server API specification, the json data received in the response is contained in a key value called result.

@Serializable
data class BaseResponse<T>(
    val result : T,
    /// Maybe 'code' or some other key-value will be added.
)

Therefore, we add the BaseResponse class and convert it to the type of data we actually want in handleApi.

suspend fun <T : Any> handleApi(
    execute: suspend () -> Response<BaseResponse<T>>
): NetworkResult<T> {
    return try {
        val response = execute()
        val body = response.body()
        if (response.isSuccessful && body != null) {
            NetworkResult.Success(body.result)
        } else {
            NetworkResult.Error(code = response.code(), message = response.message())
        }
    } catch (e: HttpException) {
        NetworkResult.Error(code = e.code(), message = e.message())
    } catch (e: Throwable) {
        NetworkResult.Exception(e)
    }
}

Through handleApi, we can receive NetworkResult in the form of Success, Error, and Exception and send it from DataSource(Network Module) to Data Module.

const val TEAM_BASE_URL = "/apis/team"

interface TeamNetworkService {

    @GET(value = "$TEAM_BASE_URL/detail")
    suspend fun getTeamDetail(
        @Query("teamId") teamId : Int
    ) : Response<BaseResponse<RemoteTeamDetail>>

}
class TeamNetworkDataSourceImpl @Inject constructor(
    private val teamNetworkService: TeamNetworkService
) : TeamNetworkDataSource {
    override suspend fun getTeamDetail(): NetworkResult<RemoteTeamDetail> {
        return handleApi {
            teamNetworkService.getTeamDetail()
        }
    }
}

Database To Data

In the Database module, add a DatabaseResult, just like in the Network module.

sealed class DatabaseResult<T> {
    class Success<T>(val data: T) : DatabaseResult<T>()
    class Failure<T>: DatabaseResult<T>()
}

Unlike NetworkResult, there are only Success and Failure.

Since there is no Http Status Code or Error Code, and only success or exception is returned through the try - catch statement, there are only two types of Success and Failure.

suspend fun <T : Any> handleDatabase(
    execute: suspend () -> T?
): DatabaseResult<T> {
    return try {
        execute().let {
            if(it == null) DatabaseResult.Failure() else  DatabaseResult.Success(it)
        }
    } catch (e: Throwable) {
        DatabaseResult.Failure()
    }
}

The handleDatabase method is supposed to access the Database and return a DatabaseResult.

suspend fun <T : Any> DatabaseResult<T>.onSuccess(
    executable: suspend (T) -> Unit
): DatabaseResult<T> = apply {
    if (this is DatabaseResult.Success<T>) {
        executable(data)
    }
}

suspend fun <T : Any> DatabaseResult<T>.onFailure(
    executable: suspend () -> Unit
): DatabaseResult<T> = apply {
    if (this is DatabaseResult.Failure<T>) {
        executable()
    }
}

suspend fun DatabaseResult<Unit>.onComplete(
    executable: suspend () -> Unit
): DatabaseResult<Unit> = apply {
    executable()
}

We've also added an extension function to DatabaseResult to easily handle success and failure.

fun onComplete is used when we need to handle both successes and failures the same way.

Data To Domain

When passing from the data layer to the domain layer, it maps the NetworkResult and DatabaseResult received from the Network module and Database module to DataResult.

sealed class DataResult<T>  {
    class Success<T>(val data : T) : DataResult<T>()
    class Failure<T>(val code : Int, val message: String?) : DataResult<T>()
}
class MatchRepositoryImpl @Inject constructor(
    private val matchNetworkDataSource: MatchNetworkDataSource
) : MatchRepository {

    override fun getMatchesBySeason(season : String): Flow<DataResult<List<Match>>> = flow {
        emit(
            matchNetworkDataSource.getMatchesBySeason(season).toDataResult {
                it.map { remoteMatch ->
                    remoteMatch.toModel()
                }
            }
        )
    }

    ....
}
Clone this wiki locally