Almost every app talks to a backend. Retrofit turns an annotated Kotlin interface into a type-safe HTTP client, integrates with coroutines, and — with OkHttp underneath — handles serialization, errors, and logging.
Retrofit lets you describe your REST API as a Kotlin interface: each method is annotated with the HTTP verb and path, parameters map to path/query/body, and Retrofit generates the networking code. Making methods suspend integrates it directly with coroutines, so a network call is just an await-like suspend call. This turns HTTP into clean, typed function calls.
// describe the API as an interface — Retrofit generates the implementation
interface UserApi {
@GET("users")
suspend fun getUsers(): List<UserDto> // suspend -> coroutine-friendly
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): UserDto
@POST("users")
suspend fun createUser(@Body user: UserDto): UserDto
@GET("search")
suspend fun search(@Query("q") query: String): List<UserDto>
}
// a network call is now just: val users = api.getUsers() — typed + suspending.You create a Retrofit instance with the base URL and a converter that (de)serializes JSON to your data classes — Moshi, Kotlinx Serialization, or Gson. Underneath, Retrofit uses OkHttp, the HTTP engine you configure for timeouts, interceptors, and caching. In a real app this construction lives in a Hilt module (previous lesson), created once and injected.
// OkHttp: the HTTP engine — timeouts, interceptors, caching
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor()) // log requests in debug
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create()) // JSON <-> data classes
.build()
val api: UserApi = retrofit.create(UserApi::class.java)
// in a real app this lives in a Hilt @Provides (DI lesson) — built once, injected.Because the API methods are suspend functions, you call them from a coroutine (viewModelScope) and handle failure with try/catch — network errors, timeouts, and non-2xx responses. Wrapping the result in your own Result type (or a sealed state) lets the ViewModel translate outcomes into UI state cleanly. Robust error handling is what separates a demo from a shippable app.
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state = _state.asStateFlow()
fun load() {
viewModelScope.launch {
_state.value = try {
UiState.Success(repository.getUsers())
} catch (e: IOException) {
UiState.Error("No connection") // network down / timeout
} catch (e: HttpException) {
UiState.Error("Server error ${e.code()}") // non-2xx response
}
}
}
}
// always handle IOException (offline) + HttpException (4xx/5xx). Map to UI state.Networking rarely stands alone — it feeds the repository’s offline-first flow: fetch from Retrofit, write to Room, and let the UI observe the database. The network call updates the source of truth rather than driving the UI directly, so the app shows cached data immediately and refreshes seamlessly. This ties together every layer of the course into one coherent data flow.
class DefaultUserRepository @Inject constructor(
private val api: UserApi, // Retrofit
private val dao: UserDao // Room
) : UserRepository {
// UI reads from the DB (instant, offline-capable)
override fun observeUsers(): Flow<List<User>> =
dao.observeAll().map { list -> list.map { it.toDomain() } }
// refresh: network -> DB. The Flow above re-emits -> UI updates. Never
// pushes directly to the UI.
override suspend fun refresh() {
val fresh = api.getUsers() // network
dao.insertAll(fresh.map { it.toEntity() }) // update source of truth
}
}
// Retrofit + Room + repository + ViewModel + Compose = the full stack.