๐Ÿ”Œ APIs, Databases, and Backend Integration

REST API Integration with Kotlin and Coroutines

Calling an endpoint is easy. Integrating an API well means clean models, predictable loading and error states, parallel calls where useful, sensible retries and timeouts, and tests. This walkthrough builds a complete feature end to end.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The end-to-end flow
  2. 1. The API contract and DTOs
  3. 2. Domain models and mapping
  4. 3. Repository: parallel, with partial failure
  5. 4. ViewModel state
  6. 5. Testing the repository
  7. Variations
  8. Common mistakes
  9. Frequently asked questions
  10. Conclusion

The feature: an order-tracking screen that loads the order, its shipment status and recommended products, three endpoints with different reliability. The goal: fast loading, graceful partial failure, and code that's easy to test.

The end-to-end flow

Parallel API calls with partial failure The ViewModel asks the repository for order details. The repository calls order and shipment endpoints in parallel with async and the recommendations endpoint in a supervised way. Order and shipment are required; recommendations failure is tolerated. Results map to domain models and then to UI state. ViewModel OrderRepositorycoroutineScope GET /orders/{id}: required GET /shipments/{id}: required GET /recommendations: optional
Required calls fail together. Optional calls fail alone, and the screen still works without them.

1. The API contract and DTOs

OrderApi.ktKotlin
interface OrderApi {
    @GET("orders/{id}") suspend fun order(@Path("id") id: String): OrderDto
    @GET("shipments/{orderId}") suspend fun shipment(@Path("orderId") id: String): ShipmentDto
    @GET("recommendations") suspend fun recommendations(@Query("orderId") id: String): List<ProductDto>
}

@Serializable data class OrderDto(val id: String, val total_paise: Long, val placed_at: String)
@Serializable data class ShipmentDto(val status: String, val eta: String? = null)
@Serializable data class ProductDto(val id: String, val name: String)

2. Domain models and mapping

Models.ktKotlin
enum class ShipmentStatus { PACKED, SHIPPED, OUT_FOR_DELIVERY, DELIVERED, UNKNOWN }

data class OrderDetails(
    val orderId: String,
    val totalPaise: Long,
    val placedAt: Instant,
    val status: ShipmentStatus,
    val eta: LocalDate?,
    val recommendations: List<Product>,
)

fun ShipmentDto.statusEnum() = runCatching { ShipmentStatus.valueOf(status) }.getOrDefault(ShipmentStatus.UNKNOWN)

Unknown server values map to UNKNOWN instead of crashing, so a new server status doesn't break old app versions.

3. Repository: parallel, with partial failure

OrderRepository.ktKotlin
class OrderRepository(private val api: OrderApi) {

    suspend fun details(orderId: String): OrderDetails = coroutineScope {
        val order = async { retryIo { api.order(orderId) } }
        val shipment = async { retryIo { api.shipment(orderId) } }
        // Optional: failure shouldn't cancel siblings or fail the whole call
        val recs = async {
            runCatching { withTimeout(3_000) { api.recommendations(orderId) } }
                .getOrDefault(emptyList())
        }

        val o = order.await()
        val s = shipment.await()
        OrderDetails(
            orderId = o.id,
            totalPaise = o.total_paise,
            placedAt = Instant.parse(o.placed_at),
            status = s.statusEnum(),
            eta = s.eta?.let(LocalDate::parse),
            recommendations = recs.await().map { Product(it.id, it.name) },
        )
    }
}

/** Retries transient I/O failures with exponential backoff; rethrows the last error. */
suspend fun <T> retryIo(times: Int = 3, initialDelay: Long = 400, block: suspend () -> T): T {
    var delayMs = initialDelay
    repeat(times - 1) {
        try { return block() } catch (e: IOException) { delay(delayMs); delayMs *= 2 }
    }
    return block()
}
Why runCatching is safe here

runCatching also catches CancellationException. Here it wraps a block with its own timeout, and if the parent is cancelled, the following await() calls still observe cancellation. In general, prefer catching specific exceptions.

4. ViewModel state

OrderViewModel.ktKotlin
sealed interface OrderUiState {
    data object Loading : OrderUiState
    data class Content(val details: OrderDetails) : OrderUiState
    data class Error(val message: String) : OrderUiState
}

class OrderViewModel(private val repo: OrderRepository, savedState: SavedStateHandle) : ViewModel() {
    private val orderId: String = checkNotNull(savedState["orderId"])
    private val _state = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
    val state = _state.asStateFlow()

    init { load() }

    fun load() = viewModelScope.launch {
        _state.value = OrderUiState.Loading
        _state.value = try {
            OrderUiState.Content(repo.details(orderId))
        } catch (e: IOException) {
            OrderUiState.Error("You're offline. Pull to retry.")
        } catch (e: HttpException) {
            OrderUiState.Error(if (e.code() == 404) "Order not found." else "Something went wrong (${e.code()}).")
        }
    }
}

5. Testing the repository

OrderRepositoryTest.ktKotlin
@Test
fun `recommendations failure does not fail details`() = runTest {
    val api = object : OrderApi {
        override suspend fun order(id: String) = OrderDto(id, 129_900, "2026-10-01T10:00:00Z")
        override suspend fun shipment(id: String) = ShipmentDto("SHIPPED", "2026-10-03")
        override suspend fun recommendations(id: String): List<ProductDto> = throw IOException("down")
    }
    val details = OrderRepository(api).details("A1")
    assertEquals(ShipmentStatus.SHIPPED, details.status)
    assertTrue(details.recommendations.isEmpty())
}

Variations

Dependent calls

Sequential: get the user, then fetch orders by the user's account ID. No async needed.

Polling a status

A flow { while (true) { emit(api.status()); delay(10_000) } } collected only while the screen is visible.

Offline support

Write results to Room and observe from there. See Offline-First.

Common mistakes

  • Sequential calls for independent data, which doubles or triples loading time.
  • Retrying non-idempotent requests, such as creating a payment, without idempotency keys.
  • Retrying 4xx errors. Only transient failures (I/O, 5xx, 429 with backoff) deserve retries.
  • Parsing dates and enums without fallbacks.

Frequently asked questions

coroutineScope or supervisorScope?

coroutineScope fails fast when any child fails, which is right for required data. supervisorScope lets children fail independently. Above, the optional call handles its own failure instead.

Where do timeouts belong?

Set global timeouts on OkHttp, and use withTimeout for specific, latency-sensitive optional calls.

Should I return Result from repositories?

Either convention works. Pick one per codebase. This example throws and maps in the ViewModel.

Conclusion

Good API integration combines clean DTO-to-domain mapping, parallel calls for independent data, isolated optional failures, retries only for transient errors, and explicit UI states, all testable with fakes.

Next step: compare HTTP clients in Retrofit vs Ktor Client.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp