Networking with Retrofit & Ktor Client
Almost every app talks to a server: train status, cricket scores, UPI payment history, the product list of an online shop. The server usually offers a REST API: web addresses (URLs) that return data as JSON text. Your Kotlin code sends an HTTP request, waits for the reply without freezing the screen, turns the JSON into Kotlin objects, and handles the many ways a network call can fail.
In the Android world two clients dominate. Retrofit, from Square, has been the standard on Android for years. Ktor client, from JetBrains, is pure Kotlin and also runs in Kotlin Multiplatform projects. Both use suspend functions and the same JSON library, kotlinx.serialization, so this lesson starts there. The JSON examples were run with kotlinx.serialization 1.12; the network code needs a real server, so its results are described.
JSON with kotlinx.serialization
Mark a data class @Serializable, and the serialization compiler plugin generates the code that reads and writes it. Add the plugin and the library to your build:
// build.gradle.kts
plugins {
kotlin("plugin.serialization") version "2.4.21"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0")
}Then decode a reply. Real APIs send fields you do not need, use snake_case names, and leave fields out, so configure a Json instance for that:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class TrainDto(
val number: String,
val name: String,
@SerialName("delay_minutes") val delayMinutes: Int = 0,
val platform: Int? = null,
)
val json = Json { ignoreUnknownKeys = true }
fun main() {
val body = """
[
{"number": "12015", "name": "Ajmer Shatabdi", "delay_minutes": 12, "platform": 5, "coach_count": 18},
{"number": "14315", "name": "Intercity Express"}
]
""".trimIndent()
val trains = json.decodeFromString<List<TrainDto>>(body)
for (t in trains) {
val where = t.platform?.let { "platform $it" } ?: "platform not announced"
println("${t.number} ${t.name}: ${t.delayMinutes} min late, $where")
}
}12015 Ajmer Shatabdi: 12 min late, platform 5 14315 Intercity Express: 0 min late, platform not announced
ignoreUnknownKeys = trueskipscoach_count, which the class does not have. Without it, decoding fails, and your app breaks the day the server adds a field.@SerialNamemaps the JSON namedelay_minutesto the Kotlin namedelayMinutes.- A default value (
= 0) or a nullable type with a default (= null) makes a field optional.
Encoding works the other way, and a missing required field is reported with an exception:
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
@Serializable
data class BookingRequest(val trainNumber: String, val passengers: List<String>, val berth: String = "ANY")
@Serializable
data class BookingReply(val pnr: String, val status: String)
fun main() {
val request = BookingRequest("12015", listOf("Ananya", "Meera"))
println(Json.encodeToString(request))
val reply = Json.decodeFromString<BookingReply>("""{"pnr": "4521876301", "status": "CNF"}""")
println(reply)
try {
Json.decodeFromString<BookingReply>("""{"pnr": "4521876302"}""")
} catch (e: SerializationException) {
println("Bad reply: ${e::class.simpleName}")
}
}{"trainNumber":"12015","passengers":["Ananya","Meera"]}
BookingReply(pnr=4521876301, status=CNF)
Bad reply: MissingFieldExceptionNotice that berth is missing from the output: by default, properties that still have their default value are not encoded. Set encodeDefaults = true in the Json { } builder if your server needs them. MissingFieldException is a subclass of SerializationException.
Retrofit, step by step
Here is how to call a train-status API from an Android app with Retrofit:
- Add the dependencies
com.squareup.retrofit2:retrofitandcom.squareup.retrofit2:converter-kotlinx-serialization(version 3.0.0 at the time of writing), plus the serialization plugin and library from above. - Allow internet access by adding
<uses-permission android:name="android.permission.INTERNET" />toAndroidManifest.xml. Without it, every call fails with a security error. - Describe the API as a Kotlin interface. Each function is a
suspend funwith an annotation for the HTTP method and path. - Build one
Retrofitobject with the base URL and the JSON converter, and let it create the interface implementation. - Call the API from a repository, inside a coroutine, and convert failures into values your UI can show.
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface TrainApi {
@GET("trains/{number}/status")
suspend fun status(@Path("number") number: String): TrainDto
@GET("trains/search")
suspend fun search(@Query("from") from: String, @Query("to") to: String): List<TrainDto>
}
fun createTrainApi(): TrainApi {
val json = Json { ignoreUnknownKeys = true }
return Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(TrainApi::class.java)
}Calling api.search("NDLS", "JP") sends GET https://api.example.com/v1/trains/search?from=NDLS&to=JP and returns a List<TrainDto>. @Path fills a {placeholder} in the path and @Query adds ?name=value. The base URL must end with /, or Retrofit throws an exception when it is built. Retrofit's suspend functions do the network work off the main thread themselves, so you can call them directly from viewModelScope.launch.
Handling failures
A network call can fail in two different ways, and the user should see a different message for each:
- No answer at all (no internet, timeout, DNS failure): an
IOException. - The server answered with an error code such as 404 (not found) or 500 (server error): Retrofit throws
HttpExceptionwhen the function returns a body type directly.
The repository is the place to translate these into a sealed result, as you learned in Domain Modelling:
import java.io.IOException
import retrofit2.HttpException
sealed interface LoadResult<out T> {
data class Success<T>(val data: T) : LoadResult<T>
data class Failure(val message: String) : LoadResult<Nothing>
}
class TrainRepository(private val api: TrainApi) {
suspend fun status(number: String): LoadResult<TrainDto> =
try {
LoadResult.Success(api.status(number))
} catch (e: HttpException) {
val message = if (e.code() == 404) "No train with number $number" else "Server error ${e.code()}"
LoadResult.Failure(message)
} catch (e: IOException) {
LoadResult.Failure("Check your internet connection and try again")
}
}The ViewModel never sees HttpException; it receives a Success or a Failure with a ready-made message. Catching specific exception types also keeps cancellation working, as explained in Coroutines in Depth.
The Ktor client
Ktor's HttpClient is configured with an engine (the code that actually opens connections, such as OkHttp on Android or CIO, which is pure Kotlin) and plugins installed with install(...). The ContentNegotiation plugin with json() handles kotlinx.serialization for you. The artifacts are io.ktor:ktor-client-core, an engine such as ktor-client-okhttp, ktor-client-content-negotiation and ktor-serialization-kotlinx-json (version 3.6.1 at the time of writing).
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class KtorTrainApi {
private val client = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 10_000
}
}
suspend fun search(from: String, to: String): List<TrainDto> =
client.get("https://api.example.com/v1/trains/search") {
parameter("from", from)
parameter("to", to)
}.body()
}expectSuccess = true makes 4xx and 5xx replies throw ClientRequestException and ServerResponseException (both subclasses of ResponseException), so the repository pattern above works the same way. Create one HttpClient and reuse it: building a client is expensive.
| Retrofit | Ktor client | |
|---|---|---|
| Style | annotated interface | function calls in Kotlin code |
| Platforms | JVM and Android | Android, iOS, desktop, web, server (Multiplatform) |
| Built on | OkHttp | a choice of engines |
| Common in | existing Android apps | new Kotlin Multiplatform and Kotlin-first projects |
DTOs and domain models
TrainDto mirrors the server's JSON. Keep it at the edge of your app and convert it into your own domain model in the repository, as the exercise shows. Then a renamed server field changes one mapping function, not every screen.
Common mistakes
- Forgetting the INTERNET permission. Every request fails, often with a confusing message.
- No
ignoreUnknownKeys. The app crashes as soon as the server adds a field. - Making every DTO field non-null. If the server sometimes omits a field, give it a default or make it nullable.
- Catching
Exceptionaround network calls. This also swallowsCancellationException; catchIOExceptionand HTTP exceptions instead. - Creating a new Retrofit or
HttpClientfor each request. Build one and share it, usually through dependency injection.
Key points
@Serializabledata classes withJson { ignoreUnknownKeys = true }turn JSON into Kotlin objects safely.- Retrofit describes an API as an interface of
suspendfunctions with@GET,@Pathand@Query. - Ktor client uses an engine plus plugins such as
ContentNegotiation, and works in Multiplatform code. - Network failures are
IOException(no answer) or HTTP errors (bad status code); map them to a sealed result in the repository. - Keep DTOs at the edge and convert them to domain models.
Exercise
A cricket API returns this JSON: [{"team": "India", "runs": 287, "wickets": 6, "balls_bowled": 290, "venue": "Wankhede"}, {"team": "Australia", "runs": 152, "wickets": 3, "balls_bowled": 160, "target": 288}]. Write a @Serializable DTO, decode the list, and map each item to a domain class Score(team, summary) whose summary reads like 287/6 in 48.2 overs, adding , needs N more when there is a target. Print one line per team.
Show solution
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class ScoreDto(
val team: String,
val runs: Int,
val wickets: Int,
@SerialName("balls_bowled") val balls: Int,
val target: Int? = null,
)
data class Score(val team: String, val summary: String)
fun ScoreDto.toDomain(): Score {
val overs = "${balls / 6}.${balls % 6}"
val chase = target?.let { ", needs ${it - runs} more" } ?: ""
return Score(team, "$runs/$wickets in $overs overs$chase")
}
private val json = Json { ignoreUnknownKeys = true }
fun main() {
val body = """
[
{"team": "India", "runs": 287, "wickets": 6, "balls_bowled": 290, "venue": "Wankhede"},
{"team": "Australia", "runs": 152, "wickets": 3, "balls_bowled": 160, "target": 288}
]
""".trimIndent()
json.decodeFromString<List<ScoreDto>>(body)
.map { it.toDomain() }
.forEach { println("${it.team}: ${it.summary}") }
}India: 287/6 in 48.2 overs Australia: 152/3 in 26.4 overs, needs 136 more
venue is ignored, target is optional, and cricket overs are whole overs plus leftover balls, so 290 balls is 48 overs and 2 balls. The mapping function is the only code that knows the JSON shape.
Quick Quiz
1. What does Json { ignoreUnknownKeys = true } protect you from?
Unknown keys are skipped instead of causing an exception.
2. In Retrofit, what does @Query("from") do?
Query parameters are appended after the question mark.
3. A call fails because the phone has no internet. Which exception type do you usually get?
No reply at all is an input/output failure, not an HTTP error code.
4. Why convert DTOs into domain models in the repository?
The rest of the app depends on your own stable types.
Learn Kotlin with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
