Blog Infos
Author
Published
Topics
,
Published

As the rate of data communication increases, the complexity of the application architecture also increases. How an application handles API responses will determine its overall architectural design and code complexity.

In this post, you will cover how to model Retrofit responses with Coroutines and Sealed classes to reduce code complexity and make your application architecture consistent.

Before you dive in, make sure your project includes Coroutines and Retrofitdependencies.

Retrofit API Calls With Coroutines

First things first, let’s see an example of Retrofit API calls. The fetchPosterfunction requests a list of posters to the network, and the PosterRemoteDataSource returns the result of the fetchPosters function:

interface PosterService {
@GET("DisneyPosters.json")
suspend fun fetchPosters(): List<Poster>
}
class PosterRemoteDataSource(
private val posterService: PosterService
) {
suspend operator fun invoke(): List<Poster> = try {
posterService.fetchPosters()
} catch (e: HttpException) {
// error handling
emptyList()
} catch (e: Throwable) {
// error handling
emptyList()
}
}

This snippet is a basic example of calling the Retrofit API and handling the response. It works well. But suppose you need to handle the response and exceptions in a multi-layer architecture as in the API data flow below:

 

 

In this architecture, you will face the following problem: results are ambiguous on call sites.

The fetchPoster function may return an empty list if the body of the API response is empty. So if you return an empty list or null when the network request fails, other layers have no idea how to figure out whether the request was successful or not.

You also need to handle exceptions somewhere in this multi-layer architecture, because API calls may throw an exception and it can be propagated to the call site. This means you should write lots of try-catch boilerplate code for each API request.

So how do you solve this problem? It’s simple: Wrap every possible scenario of an API response with a sealed class as in the figure below:

By passing a wrapper class to the call site, the presentation layer can handle results depending on the response type. For example, configuring UI elements and displaying a different placeholder/toast depending on error types.

Let’s see how to construct the wrapper class with a sealed class.

Modeling Retrofit Responses With Sealed Classes/Interfaces

Sealed classes represent quite more restricted class hierarchies than normal classes in Kotlin. All subclasses of a sealed class are known at compile-time, which allows you to use them exhaustively in the when expression.

As you’ve seen in the figure above, there are typically three scenarios where you’d want to construct a sealed class:

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

Each scenario represents different API results from a Retrofit API call:

  • NetworkResult.Success: Represents a network result that successfully received a response containing body data.
  • NetworkResult.Error: Represents a network result that successfully received a response containing an error message.
  • NetworkResult.Exception: Represents a network result that faced an unexpected exception before getting a response from the network such as IOException and UnKnownHostException.

If you use Kotlin version 1.5 or higher, you can also design the wrapper class with a Sealed Interface:

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

With a sealed interface, subclasses don’t need to be placed in the same package, which means you can use the class name as it is. However, sealed interfaces must have public visibility for all properties and they can expose unintended API surfaces.

This article covers modeling Retrofit responses with a sealed class, but you can use a sealed interface instead depending on your architectural design.

Handling Retrofit API Responses and Exceptions

Let’s see how to get the NetworkResult sealed class from a Retrofit response with the handleApi function:

suspend fun <T : Any> handleApi(
execute: suspend () -> Response<T>
): NetworkResult<T> {
return try {
val response = execute()
val body = response.body()
if (response.isSuccessful && body != null) {
NetworkResult.Success(body)
} 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)
}
}

The handleApi function receives an executable lambda function, which returns a Retrofit response. After executing the lambda function, the handleApi function returns NetworkResult.Success if the response is successful and the body data is a non-null value.

If the response includes an error, it returns NetworkResult.Error, which contains a status code and error message. You also need to handle any exceptional cases a Retrofit call may throw, like HttpException and IOException.

This is an example of the handleApi function in a data layer:

interface PosterService {
@GET("DisneyPosters.json")
suspend fun fetchPosters(): Response<List<Poster>>
}
class PosterRemoteDataSource(
private val posterService: PosterService
) {
suspend operator fun invoke(): NetworkResult<List<Poster>> =
handleApi { posterService.fetchPosters() }
}

PosterRemoteDataSource returns a NetworkResult by executing the handleApi function, which executes fetchPosters network requests. After getting a NetworkResult, you can handle the response exhaustively in the whenexpression as seen in the ViewModel example below:

viewModelScope.launch {
when (val response = posterRemoteDataSource.invoke()) {
is NetworkResult.Success -> posterFlow.emit(response.data)
is NetworkResult.Error -> errorFlow.emit("${response.code} ${response.message}")
is NetworkResult.Exception -> errorFlow.emit("${response.e.message}")
}
}
Improving Wrapping Processes With a Retrofit CallAdapter

We improved the process of handling responses and exceptions with the handleApi function as seen in the data flow below:

Everything works fine, but you still need to write the handleApi function repeatedly for each network request. It means not only the data layer has a dependency on the handleApi, but also the responsibility of handling the API responses.

So, how do we improve this process? One of the best ways is by building a custom Retrofit CallAdapter, which allows you to delegate call responses; you can also return a preferred type in the Retrofit side as seen in the figure below:

Let’s see how to implement and adopt a custom Retrofit CallAdapter step by step.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

How to Implement a Custom Retrofit Call

To delegate Retrofit responses, you need to write a custom Retrofit Call class, which implements a Call interface as seen below:

class NetworkResultCall<T : Any>(
private val proxy: Call<T>
) : Call<NetworkResult<T>> {
override fun enqueue(callback: Callback<NetworkResult<T>>) {
proxy.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val networkResult = handleApi { response }
callback.onResponse(this@NetworkResultCall, Response.success(networkResult))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val networkResult = ApiException<T>(t)
callback.onResponse(this@NetworkResultCall, Response.success(networkResult))
}
})
}
override fun execute(): Response<NetworkResult<T>> = throw NotImplementedError()
override fun clone(): Call<NetworkResult<T>> = NetworkResultCall(proxy.clone())
override fun request(): Request = proxy.request()
override fun timeout(): Timeout = proxy.timeout()
override fun isExecuted(): Boolean = proxy.isExecuted
override fun isCanceled(): Boolean = proxy.isCanceled
override fun cancel() { proxy.cancel() }
}

Let’s take a look at the enqueue function first:

The enqueue function sends a request asynchronously and notifies the callback of its response. As you can see in the code above, it delegates API responses to the callback of the NetworkResultCall class.

For getting API responses, you should override OnResponse and onFailurefunctions for the enqueue function:

  • OnResponse: Will be invoked if an API call receives a Success or Failureresponse from the network. After receiving the response, you can use the handleApi function for getting a NetworkResult and pass it to the callback.
  • OnFailure: Will be invoked if an error occurred when talking to the server, creating the request, or processing the response. It will create a NetworkResult.Exception with a given throwable, and pass it to the callback.

For other functions, you can just delegate API behaviors to the NetworkResultCall class. Now, let’s see how to implement a Custom Retrofit CallAdapter.

How to Implement a Custom Retrofit CallAdapter

Retrofit CallAdapter adapts a Call with response type, which delegates to call. You can implement the CallAdapter as seen below:

class NetworkResultCallAdapter(
private val resultType: Type
) : CallAdapter<Type, Call<NetworkResult<Type>>> {
override fun responseType(): Type = resultType
override fun adapt(call: Call<Type>): Call<NetworkResult<Type>> {
return NetworkResultCall(call)
}
}

In the adapt function, you can just return an instance of the NetworkResultCallclass that you’ve implemented in the previous step.

Now let’s see how to implement the Retrofit CallAdapterFactory.

How to Implement a Custom Retrofit CallAdapterFactory

Retrofit CallAdapterFactory creates CallAdapter instances based on the return type of the service interface methods to the Retrofit builder.

You can implement a custom CallAdapterFactory as seen below:

class NetworkResultCallAdapterFactory private constructor() : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
return null
}
val callType = getParameterUpperBound(0, returnType as ParameterizedType)
if (getRawType(callType) != NetworkResult::class.java) {
return null
}
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return NetworkResultCallAdapter(resultType)
}
companion object {
fun create(): NetworkResultCallAdapterFactory = NetworkResultCallAdapterFactory()
}
}

In the get function, you should check the return type of the service interface methods, and return a proper CallAdapter. The NetworkResultCallAdapterFactory class above creates an instance of NetworkResultCallAdapter if the return type of the service interface method is Call<NetworkResult<T>>.

That’s all. Now let’s see how to apply the Retrofit CallAdapterFactory to the Retrofit builder.

How to Apply CallAdapterFactory to Retrofit Builder

You can apply the NetworkResultCallAdapterFactory to your Retrofit builder:

val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()

Now, you can use the NetworkResult as a return type of the service interface methods with the suspend keyword:

interface PosterService {
@GET("DisneyPosters.json")
suspend fun fetchPosters(): NetworkResult<List<Poster>>
}
class PosterRemoteDataSource(
private val posterService: PosterService
) {
suspend operator fun invoke(): NetworkResult<List<Poster>> {
return posterService.fetchPosters()
}
}

As a result, the data flow will look like the figure below. The Retrofit CallAdapter handles Retrofit responses and exceptions, so the responsibility of the data layer has been significantly reduced.

 

Handling Retrofit Responses With Kotlin Extension

Each layer can expect the result type of the Retrofit API call to be NetworkResult, so you can write useful extensions for the NetworkResult class.

For example, you can perform a given action on the encapsulated value or exception if an instance of the NetworkResult represents its dedicated response type as seen in the example below:

suspend fun <T : Any> NetworkResult<T>.onSuccess(
executable: suspend (T) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Success<T>) {
executable(data)
}
}
suspend fun <T : Any> NetworkResult<T>.onError(
executable: suspend (code: Int, message: String?) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Error<T>) {
executable(code, message)
}
}
suspend fun <T : Any> NetworkResult<T>.onException(
executable: suspend (e: Throwable) -> Unit
): NetworkResult<T> = apply {
if (this is NetworkResult.Exception<T>) {
executable(e)
}
}

Those extensions return themselves by using the apply scope function, so you can use them sequentially:

viewModelScope.launch {
val response = posterRemoteDataSource.invoke()
response.onSuccess { posterList ->
posterFlow.emit(posterList)
}.onError { code, message ->
errorFlow.emit("$code $message")
}.onException {
errorFlow.emit("${it.message}")
}
}
Other Solutions

If you want to use a reliable solution that has been used by lots of real world products and significantly save your time for modeling Retrofit responses, you can also check out the open-source library, Sandwich.

Sandwich is a sealed API library that provides a Retrofit CallAdapter by default and lots of useful functionalities such as operators, global response handling, and great compatibility with LiveData and Flow. For further information, check out the README.

If you want to handle more comprehensive responses from different resources, Kotlin’s Result class is also a great candidate.

The Result class is included in Kotlin’s standard library by default, so you can use the Result in your project without further steps. For more details, check out the Encapsulate successful or failed function execution README.

Live Conference

 

 

You can watch a live talk about “Modeling Retrofit Responses with Sealed class and Coroutines” at Android Worldwide soon!

Conclusion

In this article, you saw how to model Retrofit responses with sealed classes and coroutines.

If you want to see the code covered in the article, check out the open-source repository Sandwich on GitHub, which has been used in lots of real-world products.

You can find the author of this article on Twitter @github_skydoves if you have any questions or feedback. If you’d like to stay up to date with Stream, follow us on Twitter @getstream_io for more great technical content.

As always, happy coding!

Jaewoong

Originally published at https://getstream.io.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Switch case has made our lives so much easier since the day we started…
READ MORE
blog
When writing use case classes, an important thing to consider is the output for…
READ MORE
blog
In the previous article we flew some jets to fight our enemies , in…
READ MORE
blog
In my previous post, I described how RxJava actually works. For a long time,…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu