Building a Shared Networking Layer in KMM with Ktor
One Networking Layer, Both Platforms
The single highest-leverage module to share in a KMM app is networking. It's pure logic, has no UI dependency, and duplicating it across Android (Retrofit) and iOS (URLSession/Alamofire) is pure waste. Here's a minimal but production-realistic setup using Ktor Client.
1. Shared module dependency setup
// commonMain
implementation("io.ktor:ktor-client-core:2.3.11")
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
// androidMain
implementation("io.ktor:ktor-client-okhttp:2.3.11")
// iosMain
implementation("io.ktor:ktor-client-darwin:2.3.11")Ktor's engine is platform-specific (OkHttp on Android, Darwin/NSURLSession on iOS) but the API surface you write against is identical.
2. A shared client talking to a NestJS API
class ApiClient(private val baseUrl: String) {
private val client = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.INFO }
defaultRequest { header("Authorization", "Bearer ${TokenStore.token}") }
}
suspend fun getProfile(userId: String): ProfileDto =
client.get("$baseUrl/users/$userId").body()
suspend fun updateProfile(dto: ProfileDto): ProfileDto =
client.put("$baseUrl/users/${dto.id}") {
contentType(ContentType.Application.Json)
setBody(dto)
}.body()
}If your NestJS backend already returns consistent DTOs (which it should, especially with class-validator decorators mirrored on the client), the @Serializable Kotlin data classes map almost one-to-one:
@Serializable
data class ProfileDto(
val id: String,
val name: String,
val email: String
)3. Handling errors consistently across platforms
sealed class ApiResult {
data class Success(val data: T) : ApiResult()
data class Error(val code: Int, val message: String) : ApiResult()
}
suspend fun safeApiCall(block: suspend () -> T): ApiResult = try {
ApiResult.Success(block())
} catch (e: ClientRequestException) {
ApiResult.Error(e.response.status.value, e.message)
} catch (e: Exception) {
ApiResult.Error(-1, e.message ?: "Unknown error")
}This gives both Android and iOS the exact same error-handling contract — no more "the iOS team handles 401s differently than Android" bugs.
Why this matters more than sharing UI
Networking, serialization, retry logic, auth token refresh, and caching decisions are where subtle platform-specific bugs actually live. Sharing this layer through KMM — while keeping native Compose/SwiftUI screens — gets you most of the code-reuse benefit with none of the "does this feel native?" tradeoff that comes with sharing UI too.