Base URL, headers, JSON parsing, retry. Every mobile app needs them, and most apps build them twice: Retrofit/OkHttp on Android, URLSession on iOS. Ktor's multiplatform client puts all of that in commonMain and swaps the HTTP engine per target underneath.
#The old way
OrdersApi on Android was a Retrofit interface with Moshi converters. On iOS it was a URLSession wrapper hand-parsing JSON with JSONDecoder. A new endpoint, a new header, a new error code — each one meant editing both, in two languages, against two serialization libraries.
#The new way
@Serializable
data class Order(
val id: String,
val status: String,
val totalCents: Int
)
class OrdersApi(baseUrl: String) {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
defaultRequest {
url(baseUrl)
}
}
suspend fun fetchOrder(orderId: String): Order =
client.get("orders/$orderId").body()
suspend fun createOrder(order: Order): Order =
client.post("orders") {
contentType(ContentType.Application.Json)
setBody(order)
}.body()
}
commonMain depends on ktor-client-core, ktor-client-content-negotiation, and ktor-serialization-kotlinx-json. androidMain adds ktor-client-okhttp; iosMain adds ktor-client-darwin. HttpClient() picks up whichever engine is on the classpath for that target. No expect/actual needed for the client itself.
#Why it matters
- One
@Serializablemodel and one request/response shape instead of two, in sync by construction - Error mapping, auth headers, and retry policy live in one
HttpClient { }block, not duplicated - Swapping engines (OkHttp for CIO, Darwin for a custom NSURLSession config) is a dependency change, not a rewrite
#Gotcha
HttpClient() with no explicit engine argument only resolves at compile time if exactly one engine artifact sits on that source set's classpath. Forget ktor-client-darwin in iosMain and the module fails to compile on the Native targets. Worse: if another engine leaks in transitively, it silently picks the wrong one. No runtime warning points at the missing dependency. You just get a client that behaves differently than expected on that platform.