Library / SDK
skydoves/retrofit-adapters avatar
skydoves/retrofit-adapters

Retrofit adapters: returning Kotlin Result, PagingSource and Either from a Retrofit service

🚆 Retrofit call adapters for modeling network responses using Kotlin Result, Jetpack Paging3, and Arrow Either.

559 stars19 forksKotlinApache-2.0

At a glance

What is it?
skydoves/retrofit-adapters is a set of Retrofit call adapters that let a service method return Kotlin's Result, Jetpack Paging3's NetworkPagingSource, or Arrow's Either instead of a bare response body. It is a Kotlin library for Android, Apache-2.0 licensed, and it exists to make the failure case part of a method's type signature rather than an exception.
Who is it for?
Retrofit adapters suit an Android team that wants network failures to be visible in the type rather than thrown at the call site, because the adapters turn a Retrofit suspend function into a Result or an Either and let the compiler force a decision on the failure path.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 37 days ago.
What is it written in?
Mainly Kotlin, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Call adapters are the extension point Retrofit already had

Retrofit adapters is a set of call adapters for modelling network responses with Kotlin Result, Jetpack Paging3 and Arrow Either. The primary language is Kotlin, the licence is Apache-2.0, and the minimum API level is 23.

The mechanism is Retrofit's own call adapter factory, which is the extension point Retrofit provides for exactly this: changing what a service method's return type means. A normal Retrofit suspend function returns the parsed body and throws on failure, so an HTTP 500 or a dropped connection arrives as an exception from somewhere far away in your call stack.

These adapters change that contract. A service method declared to return Result gets a Result back, with the failure captured rather than thrown.

kotlin
interface PokemonService {

  @GET("pokemon")
  suspend fun fetchPokemonList(
    @Query("limit") limit: Int = 20,
    @Query("offset") offset: Int = 0
  ): Result<PokemonResponse>
}

And the caller has to acknowledge the failure branch.

kotlin
viewModelScope.launch {
  val result = pokemonService.fetchPokemonList()
  if (result.isSuccess) {
    val data = result.getOrNull()
    // handle data
  } else {
    // handle error case
  }
}

That is the whole value proposition in one snippet. The failure is now a value the compiler makes you deal with, rather than an exception you have to remember to catch. Whether you consider that a win depends on how your team already handles failures, and the README points at the alternative for people who want more structure.

The repository is by skydoves, the default branch is main, and the last push was on 2026-09-16 with the 1.2.0 release tagged the same day, after 1.1.0 in August 2025 and 1.0.13 in November 2024.

Three adapters, three separate artifacts

The repository is a monorepo with a module per adapter, and the top level directory listing shows the shape: retrofit-adapters-arrow/, retrofit-adapters-core/, retrofit-adapters-gson/, retrofit-adapters-moshi/, retrofit-adapters-paging/, retrofit-adapters-paging-compiler/, retrofit-adapters-result/, retrofit-adapters-serialization/ and retrofit-adapters-test/, plus a buildSrc/ directory, a spotless/ directory and the usual Gradle files.

The gson and moshi modules are the interesting ones for anyone thinking about the design. Retrofit's converter factory is what turns a response body into a typed object, and there are two common converter implementations for Android, Gson and Moshi. Having separate adapters for each means the Result or Either wrapper composes correctly with whichever converter your project already uses, rather than forcing a choice.

The serialization module is separate again, and the compiler module for the paging adapter exists because the paging support works through annotation processing.

So a project takes only the pieces it needs. The Result adapter, for instance, is published as its own artifact.

gradle
dependencies {
    implementation("com.github.skydoves:retrofit-adapters-result:1.2.0")
}

The paging adapter is published separately too, with the version left as a placeholder in the README.

gradle
dependencies {
    implementation "com.github.skydoves:retrofit-adapters-paging:<version>"
}

The build system is Gradle with Kotlin DSL, with settings.gradle.kts, build.gradle.kts, a gradle.properties and a gradle wrapper committed. The spotless/ directory tells you formatting is enforced by Spotless rather than left to convention, and the .editorconfig file sets the base conventions. There is a build status badge pointing at an android.yml workflow, so the modules are built as an Android library.

Wiring an adapter is one factory call on the Retrofit builder

For the Result adapter, the setup is a single call to addCallAdapterFactory, and everything else is unchanged.

kotlin
val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("BASE_URL")
    .addConverterFactory(..)
    .addCallAdapterFactory(ResultCallAdapterFactory.create())
    .build()

Notice the ordering. The converter factory comes first, because the converter is what turns the response body into your data class, and the call adapter sits on top of that, wrapping the result. The result is that the retrofit instance is built exactly as before, with one extra line.

The factory also takes arguments, and they are the interesting part of the API.

kotlin
val retrofit: Retrofit = Retrofit.Builder()
  .baseUrl("BASE_URL")
  .addConverterFactory(..)
  .addCallAdapterFactory(ResultCallAdapterFactory.create(nullBodyAsFailure = true))
  .build()

The nullBodyAsFailure flag addresses a real problem, and the README describes it precisely. A successful response with no body is reported as Result.success(null) by default, even when the service method declares a non-null type. That null then surfaces far away from the call site.

That is a genuinely nasty failure mode. Your method says it returns PokemonResponse, the server returns 204 with an empty body, you get a success wrapping null, and the crash happens when something downstream dereferences it, possibly in a different screen.

With the flag set, you get a NullBodyException failure instead, at the call site, where you can handle it.

There is also an option for empty responses as a deliberate design rather than a failure, which is a separate thing. You can confine the response type as Unit when you need to handle empty body content API requests.

kotlin
@POST("/users/info")
suspend fun updateUserInfo(@Body userRequest: UserRequest): Result<Unit>

So there are two distinct ways to deal with a body that is not there: declare the type as Unit when that is what you meant, or turn the null into a failure when the type says otherwise and the server got it wrong.

The paging adapter needs annotations and a mapper class

The Jetpack Paging adapter is the most involved of the three, because returning a PagingSource from a Retrofit method requires the library to know how the pages are keyed.

The service method returns NetworkPagingSource, and the paging key is marked with an annotation.

kotlin
interface PokemonService {

  @GET("pokemon")
  @PagingKeyConfig(
    keySize = 20,
    mapper = PokemonPagingMapper::class
  )
  suspend fun fetchPokemonListAsPagingSource(
    @Query("limit") limit: Int = 20,
    @PagingKey @Query("offset") offset: Int = 0,
  ): NetworkPagingSource<PokemonResponse, Pokemon>
}

Two annotations do the work. The @PagingKeyConfig holds the paging configuration for the network request and delivers it to the call adapter internally, and you set its keySize and mapper parameters. The @PagingKey marks the parameter in the service interface method as the paging key, and that parameter will be paged by incrementing the page values continuously.

So the offset query parameter is what gets incremented as the user scrolls, and keySize is how many items a page holds. That is the whole contract between your API's pagination scheme and Paging3's.

The mapper is your own class, and it is where the response shape gets translated into the item shape.

kotlin
class PokemonPagingMapper : PagingMapper<PokemonResponse, Pokemon> {

  override fun map(value: PokemonResponse): 

The mapper implements the PagingMapper interface and transforms the original network response into the list of paging items, and it is referenced by the @PagingKeyConfig annotation.

This is the part of the library that depends on Jetpack's Paging library rather than on Retrofit alone, and it is also the part that needs the separate retrofit-adapters-paging-compiler module, since the configuration travels through annotation processing.

The Arrow Either adapter is the third option, and the README's visible text does not document its usage beyond listing it in the description and pointing at the retrofit-adapters-arrow/ module in the tree. Its purpose is the same as Result's, with Arrow's own error type rather than Kotlin's.

Injecting a CoroutineScope makes the adapter testable

One feature deserves attention because it changes how the library behaves under test rather than in production.

You can inject your own CoroutineScope into the ResultCallAdapterFactory and execute network requests on the scope.

kotlin
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
val testScope = TestScope(testDispatcher)
val retrofit: Retrofit = Retrofit.Builder()
  .baseUrl("BASE_URL")
  .addConverterFactory(..)
  .addCallAdapterFactory(ResultCallAdapterFactory.create(testScope))
  .build()

With the scope supplied, the request runs on the scope you gave it rather than on whatever the adapter would otherwise pick. In a test that scope is a TestScope on an UnconfinedTestDispatcher, which means the coroutine runs eagerly and deterministically instead of being scheduled onto a background dispatcher.

The practical consequence is that you can test a service method that returns Result without the test hanging, and without the flakiness of waiting on a real dispatcher. The README links to the Android documentation on testing Kotlin coroutines for the background on the pattern.

This is a small API surface with a disproportionate effect, and it is the kind of thing that shows the library is written by people who test their own code. There is a retrofit-adapters-test/ module in the repository as well, which suggests the same thinking applied more broadly.

One thing the README mentions in passing is the alternative: the Sandwich library, described as a more specified and lightweight Monad sealed API library for modelling Retrofit responses and handling exceptions, for anyone who wants something more opinionated than Result or Either provides. Both live under the same author, so the comparison is between two designs from the same mind rather than between unrelated projects.

Where Retrofit adapters is the wrong tool

The first wrong-tool case is a team that needs structured error information. The adapters give you a failure, and with Result you get the exception inside it, but the library does not define an error type carrying a status code, a body or a field-level validation detail. If your API has a documented error envelope that your UI needs to render, you are going to model that yourself on top, and at that point you have built most of what Sandwich offers.

The second case is a project that has already chosen a monad sealed API. There are three adapters here, Result, Either and the Arrow integration, and choosing between them is a decision about which error model your whole codebase will speak. If your organisation already standardises on Arrow, adding a Kotlin Result adapter alongside it means two error conventions in one app.

The third case is a Retrofit version or Android setup outside the supported range. The minimum API level is 23, the build is an Android library with a Kotlin DSL Gradle setup, and the paging adapter depends on Jetpack Paging3 and on the annotation processing module. This is an Android library, not a general JVM library, so a server-side Kotlin service is out of scope even though Retrofit itself is used there.

The fourth case is a team that does not want Retrofit at all. These are call adapters for Retrofit specifically. A project using OkHttp directly, or Ktor, or a plain HTTP client, gets nothing from this library.

There is a behavioural point worth flagging that is easy to get wrong. The default of reporting a null body as a success is a deliberate choice, and it is the wrong default for most services. The README documents the nullBodyAsFailure option for exactly this reason, but a team that installs the adapter and does not read that section will have a Result<PokemonResponse> that can hold null, which contradicts its own type. That is the one thing to check first when adopting.

On maintenance, the release cadence is steady but slow: 1.2.0 in September 2026, 1.1.0 in August 2025 and 1.0.13 in November 2024, against a last push on 2026-09-16. The version is past 1.0, so the artifact coordinates are stable and the API is not expected to break in the way a 0.x library's would.

On licence, Apache-2.0 with a LICENSE file at the root, which is the grant most Android libraries use and imposes no obligation on a proprietary application that depends on it.

Editorial conclusion

Retrofit adapters suit an Android team that wants network failures to be visible in the type rather than thrown at the call site, because the adapters turn a Retrofit suspend function into a Result or an Either and let the compiler force a decision on the failure path. It does not suit a project that needs error details beyond a message, since the adapter gives you the failure but not a structured error type, and it does not suit a team already using a monad sealed API library with its own Retrofit integration. Before adopting, decide which of the three adapters you actually need, since each is a separate Maven artifact, and read the nullBodyAsFailure option because the default behaviour of reporting a missing body as success is the one thing most likely to surprise you.

Frequently asked questions

What is skydoves/retrofit-adapters?

It is a set of Retrofit call adapters for modelling network responses with Kotlin Result, Jetpack Paging3 and Arrow Either. The adapters let a Retrofit service method return a Result, a NetworkPagingSource or an Either instead of a bare response body, with the failure carried in the return value.

How do I add the Retrofit Result adapter?

Add the dependency com.github.skydoves:retrofit-adapters-result:1.2.0 to your module's build.gradle.kts, then call addCallAdapterFactory(ResultCallAdapterFactory.create()) on your Retrofit builder after addConverterFactory, and declare service methods as suspend functions returning Result.

What does nullBodyAsFailure do in the ResultCallAdapterFactory?

By default a successful response with no body is reported as Result.success(null) even when the service method declares a non-null type, and that null surfaces far from the call site. Setting nullBodyAsFailure to true gives a NullBodyException failure instead.

How do I return a PagingSource from a Retrofit service with this library?

Annotate the service method with @PagingKeyConfig setting keySize and mapper, mark the paging key parameter with @PagingKey, and return NetworkPagingSource. The mapper must implement the PagingMapper interface, and you add PagingCallAdapterFactory.create() to the Retrofit builder.

Can I test Retrofit calls that return Result?

Yes. You can inject your own CoroutineScope into the ResultCallAdapterFactory, and in a test pass a TestScope on an UnconfinedTestDispatcher so the request runs eagerly and deterministically on the scope you supplied.

What license are the Retrofit adapters released under?

The library is released under the Apache 2.0 license, with the LICENSE file at the root of the repository. The current release is 1.2.0, tagged on 2026-09-16, and the minimum Android API level is 23.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. README
  4. Releases
  5. skydoves/retrofit-adapters on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/skydoves-retrofit-adapters.svg)](https://hysenlabs.com/projects/skydoves-retrofit-adapters)

Was this useful?