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
}
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:
- The Interface — what requests can be made
- The Builder — where to send them and how to parse responses
- 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)
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())
}
}
@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)
}
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
}
}
}
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)
}
}
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
Calling getPokemonList(20, 0) produces:
https://pokeapi.co/api/v2/pokemon?limit=20&offset=0
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/" }
]
}
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>,
)
@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)
}
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 -> {}
}
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
What to Actually Memorize vs. What to Look Up
As a junior dev, you don't need to memorize OkHttp client configuration (DNS overrides, cipher suites, connection specs) — that's boilerplate you'll copy from past projects or generate with AI/docs.
What you do need to know cold, because it comes up in interviews and code reviews:
- How to write a Retrofit interface from scratch (
@GET,@POST,@Query,@Path,@Body) - The basic
Retrofit.Builder()setup - Why
suspendmatters for network calls - The full request lifecycle, end to end, in your own words
- Why the Repository pattern exists
Interview Questions on Retrofit
Easy
- What is Retrofit? A type-safe HTTP client that turns annotated interface methods into network calls.
-
Difference between
@Queryand@Path?@Pathsubstitutes a{placeholder}inside the URL;@Queryappends?key=valueparameters. -
Why use
suspend funfor network calls? It moves the blocking work off the main thread via coroutines, avoiding UI freezes, without needing callbacks orCall<T>.
Medium
- How does Retrofit relate to OkHttp? Retrofit is a layer on top of OkHttp. Retrofit handles interfaces, annotations, and response conversion; OkHttp handles the actual socket connection, headers, and request/response transport.
- What is an Interceptor used for? Code that runs on every outgoing request or incoming response — commonly used for adding auth headers, logging, or retry logic.
-
Why wrap responses in a sealed class like
Resource? It forces explicit handling of success, error, and loading states instead of relying on nullable types or exceptions leaking into the UI layer. -
What does
@SerializedNamedo, and when do you need it? Maps a JSON key to a Kotlin field with a different name — common when the API usessnake_caseand your code usescamelCase.
Harder
-
Why mark the Retrofit instance as
@Singletonin Hilt? Building Retrofit is expensive (thread pools, connection pools). A singleton ensures it's constructed once and reused, instead of rebuilt every time a screen or ViewModel is created. -
How would you attach an auth token to every outgoing request? Add an OkHttp
Interceptorthat reads the token and appends anAuthorizationheader beforechain.proceed(request). - How would you test a Repository that depends on Retrofit? Mock the API interface (e.g., with MockK or a fake implementation) and inject it into the Repository, so tests don't hit the real network.
Closing Thoughts
Retrofit looks intimidating because tutorials usually only show the builder code in isolation. Once you trace a single request from the UI all the way to the server and back, the "magic" disappears — it's just a clean chain of responsibility: UI asks ViewModel, ViewModel asks Repository, Repository asks Retrofit, Retrofit asks the network.
Understand that chain, and you'll be able to explain — and debug — any Retrofit-based Android app, not just the one you built it in.

























