EthicsComputerCourses
Join training +91 90532 10052

Networking with Retrofit & Ktor Client

Kotlin · Lesson 39 of 41 Advanced 8 min read Updated

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:

Kotlin
// 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:

Kotlin
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")
    }
}
Output
12015 Ajmer Shatabdi: 12 min late, platform 5
14315 Intercity Express: 0 min late, platform not announced
  • ignoreUnknownKeys = true skips coach_count, which the class does not have. Without it, decoding fails, and your app breaks the day the server adds a field.
  • @SerialName maps the JSON name delay_minutes to the Kotlin name delayMinutes.
  • 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:

Kotlin
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}")
    }
}
Output
{"trainNumber":"12015","passengers":["Ananya","Meera"]}
BookingReply(pnr=4521876301, status=CNF)
Bad reply: MissingFieldException

Notice 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:

  1. Add the dependencies com.squareup.retrofit2:retrofit and com.squareup.retrofit2:converter-kotlinx-serialization (version 3.0.0 at the time of writing), plus the serialization plugin and library from above.
  2. Allow internet access by adding <uses-permission android:name="android.permission.INTERNET" /> to AndroidManifest.xml. Without it, every call fails with a security error.
  3. Describe the API as a Kotlin interface. Each function is a suspend fun with an annotation for the HTTP method and path.
  4. Build one Retrofit object with the base URL and the JSON converter, and let it create the interface implementation.
  5. Call the API from a repository, inside a coroutine, and convert failures into values your UI can show.
Kotlin
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 HttpException when 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:

Kotlin
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).

Kotlin
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.

RetrofitKtor client
Styleannotated interfacefunction calls in Kotlin code
PlatformsJVM and AndroidAndroid, iOS, desktop, web, server (Multiplatform)
Built onOkHttpa choice of engines
Common inexisting Android appsnew 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 Exception around network calls. This also swallows CancellationException; catch IOException and HTTP exceptions instead.
  • Creating a new Retrofit or HttpClient for each request. Build one and share it, usually through dependency injection.

Key points

  • @Serializable data classes with Json { ignoreUnknownKeys = true } turn JSON into Kotlin objects safely.
  • Retrofit describes an API as an interface of suspend functions with @GET, @Path and @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
Kotlin
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}") }
}
Output
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?

2. In Retrofit, what does @Query("from") do?

3. A call fails because the phone has no internet. Which exception type do you usually get?

4. Why convert DTOs into domain models in the repository?

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