Making requests
Ordering in a restaurant, you don’t walk into the kitchen; you tell the waiter “the soup, please” and get a bowl back. Retrofit is the waiter. You write an interface that lists the requests your app makes, as ordinary Kotlin functions, and Retrofit does the HTTP: building the URL, sending the request, and turning the JSON response into Kotlin objects.
Retrofit generates an implementation of an annotated interface at runtime; suspend functions are supported natively and are main-safe (OkHttp does the I/O on its own threads). Ktor Client is an explicit DSL over a pluggable engine (OkHttp on Android, Darwin on iOS), which makes it the default for KMP shared code. Both pair best with kotlinx.serialization: compile-time generated serializers, no reflection, R8-friendly, and explicit handling of unknown keys and defaults. Keep one client instance per app (connection pool, cache, HTTP/2 multiplexing) and inject it.
@Serializable
data class ProductDto(
val id: Long,
val name: String,
@SerialName("price_cents") val priceCents: Long,
val imageUrl: String? = null, // optional in the JSON
)
interface ShopApi {
@GET("products")
suspend fun products(@Query("page") page: Int): List<ProductDto>
@GET("products/{id}")
suspend fun product(@Path("id") id: Long): ProductDto
@POST("cart/items")
suspend fun addToCart(@Body item: CartItemDto): CartDto
}
val json = Json { ignoreUnknownKeys = true } // new server fields don't break old apps
val api: ShopApi = Retrofit.Builder()
.baseUrl("https://api.shop.example.com/") // must end with "/"
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ShopApi::class.java)val client = HttpClient(OkHttp) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) { requestTimeoutMillis = 15_000 }
defaultRequest { url("https://api.shop.example.com/") }
expectSuccess = true // throw on 4xx/5xx instead of returning them
}
class ShopService(private val client: HttpClient) {
suspend fun products(page: Int): List<ProductDto> =
client.get("products") { parameter("page", page) }.body()
suspend fun product(id: Long): ProductDto =
client.get("products/$id").body()
}@Serializabledata classes describe the JSON.@SerialNamemaps a JSON name likeprice_centsto a Kotlin name; a default value (= null) makes the field optional.ignoreUnknownKeys = truematters: without it, the day the server adds a field, every old version of your app fails to parse the response.suspendfunctions don’t block the main thread; Retrofit and Ktor do the waiting on background threads. Call them from a ViewModel withviewModelScope.launch.- One client for the whole app. Creating a new one per request throws away connection reuse and caching.
Tap the line with the bug.
@Composable
fun ProductScreen(id: Long, api: ShopApi) {
var product by remember { mutableStateOf<ProductDto?>(null) }
val scope = rememberCoroutineScope()
scope.launch { product = api.product(id) }
product?.let { Text(it.name) } ?: CircularProgressIndicator()
}Hint: When does this line run? What happens right after product is set?
The composable body runs on every recomposition, so every recomposition launches a new request. When the response arrives, product changes, which recomposes the screen, which launches another request: an endless loop of network calls. Even without the loop, the request is cancelled when the screen leaves composition and started again after rotation, and there’s no error handling at all: one failed request crashes the app with an uncaught exception.
Composables show state; they don’t fetch it. Load in a ViewModel, which survives rotation and owns the coroutine:
class ProductViewModel(
savedStateHandle: SavedStateHandle,
private val repository: ProductRepository,
) : ViewModel() {
private val id: Long = savedStateHandle.toRoute<ProductRoute>().id
private val _state = MutableStateFlow<ProductUiState>(ProductUiState.Loading)
val state: StateFlow<ProductUiState> = _state
init { load() }
fun load() = viewModelScope.launch {
_state.value = ProductUiState.Loading
_state.value = repository.product(id).fold(
onSuccess = { ProductUiState.Loaded(it) },
onFailure = { ProductUiState.Error(it.toUserMessage()) },
)
}
}(If a request must start from a composable, LaunchedEffect(id) { … } runs once per key, not per recomposition. But the ViewModel is the right home.)
Predict the output
The app's ProductDto has val name: String. The server starts sending one product whose name is null in the JSON. With the default Json settings, what happens when the list loads?
name is a non-nullable String, so null doesn't fit, and kotlinx.serialization throws for the whole list: one bad item empties the screen. Decide what the app should do: make it String? and show a placeholder, give it a default with coerceInputValues = true (null becomes the default for non-null fields with defaults), or validate on the server. Either way, catch the exception and show an error instead of crashing.
Retrofit or Ktor
- Retrofit: declarative, mature, huge ecosystem (call adapters, mocks), Android/JVM only. Good default for Android-only apps.
- Ktor Client: imperative DSL, multiplatform (shared networking for Android and iOS in KMP), plugins for auth, retries, logging, content negotiation. Slightly more code per endpoint.
- Either way: DTOs stay in the data layer and are mapped to domain models, so a backend rename touches one mapper; generate DTOs from an OpenAPI spec for large APIs; keep R8 rules for serialization (kotlinx.serialization ships them).
- Retrofit: annotated interface,
suspendfunctions, main-safe. Ktor: DSL over OkHttp/Darwin engines, KMP-friendly. - kotlinx.serialization with
ignoreUnknownKeys = true; nullable or defaulted fields for optional data;@SerialNamefor wire names. - One shared client (connection pool, HTTP/2, cache), injected. Never fetch from a composable body; ViewModel +
viewModelScope. - DTOs mapped to domain models at the data layer boundary.
Check this part
Why set ignoreUnknownKeys = true?
Where should a screen's network request be started?
Why choose Ktor Client over Retrofit?