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:
123456789101112131415161718interface 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:
12345sealed 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:
12345sealed 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:
1234567891011121314151617suspend 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:
1234567891011interface 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:
1234567viewModelScope.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.
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:
1234567891011121314151617181920212223242526class 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 onFailure
functions for the enqueue
function:
-
OnResponse: Will be invoked if an API call receives a
Success
orFailure
response from the network. After receiving the response, you can use thehandleApi
function for getting aNetworkResult
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:
12345678910class 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:
123456789101112131415161718192021222324class 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:
12345val 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:
123456789101112interface 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:
1234567891011121314151617181920212223suspend 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:
12345678910viewModelScope.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