Modeling Retrofit Responses With Sealed Classes and Coroutines

Handling Retrofit responses and exceptions are an essential part of modern Android app architecture. In this article, you’ll cover modeling responses with sealed classes and coroutines in a multi-layered architecture.

Jaewoong E.
Jaewoong E.
Published April 20, 2022
Modeling Retrofit Responses image

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 Retrofit dependencies.

The author of this article gave a talk about 'Modeling Retrofit responses with sealed classes and coroutines', so if you're interested in listening to the whole presentation, check out the video below:

Retrofit API Calls With Coroutines

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

kt
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:

Multi-layer architecture

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:

Sealed Classes

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:

kt
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:

kt
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:

kt
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:

kt
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 when expression as seen in the ViewModel example below:

kt
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:

Retrofit, Repo, Domain, Presentation

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:

RetroFit Call Adapter

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

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:

kt
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))
            }

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 onFailure functions for the enqueue function:

  • OnResponse: Will be invoked if an API call receives a Success or Failure response 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:

kt
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 NetworkResultCall class 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:

kt
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
        }

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:

kt
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:

kt
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.

Final RetorFit image

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:

kt
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)
    }
}

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

kt
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.

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