DEV Community

Cover image for The Complete Retrofit Lifecycle in a Real Android App
Aalaa Fahiem
Aalaa Fahiem

Posted on

The Complete Retrofit Lifecycle in a Real Android App

Most Retrofit tutorials show you isolated code snippets. This article traces the entire lifecycle of a Retrofit call — from app startup to data appearing on screen — using real code from a Pokedex app built with Kotlin, Hilt, Coroutines, and Jetpack Compose.

By the end, you'll understand every link in the chain!

What Is Retrofit?

Retrofit is a type-safe HTTP client for Android and Java. Instead of writing raw HTTP requests, you describe your API as a Kotlin interface, and Retrofit generates the networking code for you behind the scenes.

interface PokeApi {
    @GET("pokemon")
    suspend fun getPokemonList(
        @Query("limit") limit: Int,
        @Query("offset") offset: Int,
    ): PokemonList

    @GET("pokemon/{name}")
    suspend fun getPokemonInfo(
        @Path("name") name: String,
    ): Pokemon
}
Enter fullscreen mode Exit fullscreen mode

That's it. No manual HttpURLConnection, no manual JSON parsing. Retrofit handles building the request and Gson (or Moshi) handles converting the JSON response into your Kotlin objects.


The 3 Building Blocks

Every Retrofit setup needs exactly three things:

  1. The Interface — what requests can be made
  2. The Builder — where to send them and how to parse responses
  3. The Response model — what shape the data comes back in
val retrofit = Retrofit.Builder()
    .baseUrl("https://pokeapi.co/api/v2/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()

val api = retrofit.create(PokeApi::class.java)
Enter fullscreen mode Exit fullscreen mode

The Full Lifecycle, Step by Step

Here's the entire request lifecycle traced through a real app architecture: Compose UI → ViewModel → Repository → Retrofit → OkHttp → Server → Gson → back up the chain.

Step 1 — App Starts, Hilt Wakes Up

@HiltAndroidApp
class PokedexApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        Timber.plant(Timber.DebugTree())
    }
}
Enter fullscreen mode Exit fullscreen mode

@HiltAndroidApp tells Hilt to start managing dependency injection across the app.

Step 2 — Hilt Builds Retrofit Once (Singleton)

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Singleton
    @Provides
    fun providePokeApi(): PokeApi {
        val okHttpClient = OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .retryOnConnectionFailure(true)
            .addInterceptor { chain ->
                val request = chain.request().newBuilder()
                    .header("User-Agent", "Mozilla/5.0 ...")
                    .header("Accept", "application/json")
                    .build()
                chain.proceed(request)
            }
            .build()

        return Retrofit.Builder()
            .baseUrl(Constants.BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(PokeApi::class.java)
    }

    @Singleton
    @Provides
    fun providePokemonRepository(api: PokeApi) = PokemonRepository(api)
}
Enter fullscreen mode Exit fullscreen mode

Building a Retrofit instance is expensive — connection pools, thread pools, parsers. @Singleton ensures it's built once and reused everywhere, instead of recreated per screen.

Step 3 — ViewModel Is Created, Requests Data Immediately

@HiltViewModel
class PokemonListViewModel @Inject constructor(
    private val repository: PokemonRepository,
) : ViewModel() {

    var pokemonList = mutableStateOf<List<PokedexListEntry>>(listOf())
    var isLoading = mutableStateOf(false)

    init {
        loadPokemonPaginated()
    }

    fun loadPokemonPaginated() {
        viewModelScope.launch {
            isLoading.value = true
            val result = repository.getPokemonList(PAGE_SIZE, curPage * PAGE_SIZE)
            // handle result below
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Hilt injects the PokemonRepository automatically — no manual instantiation needed. viewModelScope.launch starts a coroutine so the network call doesn't block the main thread.

Step 4 — Repository Calls the API and Handles Failure

@Singleton
class PokemonRepository @Inject constructor(
    private val api: PokeApi,
) {
    suspend fun getPokemonList(limit: Int, offset: Int): Resource<PokemonList> {
        val response = try {
            api.getPokemonList(limit, offset)
        } catch (e: Exception) {
            return Resource.Error("Couldn't load Pokemon")
        }
        return Resource.Success(response)
    }
}
Enter fullscreen mode Exit fullscreen mode

The Repository pattern decouples the ViewModel from the data source. The ViewModel doesn't know — or care — whether data comes from a network call, a cache, or a database.

Step 5 — Retrofit Builds the URL and OkHttp Sends It

Given:

@GET("pokemon")
suspend fun getPokemonList(
    @Query("limit") limit: Int,
    @Query("offset") offset: Int,
): PokemonList
Enter fullscreen mode Exit fullscreen mode

Calling getPokemonList(20, 0) produces:

https://pokeapi.co/api/v2/pokemon?limit=20&offset=0
Enter fullscreen mode Exit fullscreen mode

OkHttp (the engine underneath Retrofit) opens the connection, attaches any interceptor headers, and sends the request over the wire.

Step 6 — Server Responds With JSON

{
  "count": 1302,
  "next": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
  "previous": null,
  "results": [
    { "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 7 — Gson Converts JSON Into Kotlin Objects

data class PokemonList(
    @SerializedName("count") val count: Int,
    @SerializedName("next") val next: String,
    @SerializedName("previous") val previous: Any,
    @SerializedName("results") val results: List<Result>,
)
Enter fullscreen mode Exit fullscreen mode

@SerializedName maps a JSON key to a Kotlin property — essential when the server uses snake_case and your code uses camelCase.

Step 8 — Result Flows Back Up, Wrapped in a Sealed Class

sealed class Resource<T>(val data: T? = null, val message: String? = null) {
    class Success<T>(data: T) : Resource<T>(data)
    class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
    class Loading<T>(data: T? = null) : Resource<T>(data)
}
Enter fullscreen mode Exit fullscreen mode

This wrapper lets the ViewModel handle success, error, and loading as explicit states rather than nullable guesswork.

Step 9 — ViewModel Updates State, Compose Redraws

when (result) {
    is Resource.Success -> {
        pokemonList.value += result.data!!.results.map { /* map to UI model */ }
        isLoading.value = false
    }
    is Resource.Error -> {
        loadError.value = result.message!!
        isLoading.value = false
    }
    else -> {}
}
Enter fullscreen mode Exit fullscreen mode

Because pokemonList is a Compose mutableStateOf, the moment it changes, any composable reading it recomposes automatically. No manual UI refresh needed.


The Full Chain, Visualized

App starts → Hilt builds Retrofit + Repository (once)
    ↓
ViewModel created → Repository injected → init{} fires
    ↓
viewModelScope.launch → repository.getPokemonList()
    ↓
api.getPokemonList() → Retrofit builds the URL
    ↓
OkHttp attaches headers, sends the HTTP request
    ↓
Server responds with JSON
    ↓
Gson converts JSON → Kotlin data class
    ↓
Repository wraps it in Resource.Success / Resource.Error
    ↓
ViewModel updates mutableStateOf
    ↓
Compose recomposes → user sees the data
Enter fullscreen mode Exit fullscreen mode

Top comments (0)