Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 029 / 100

Retrofit tutorial: a Android & Kotlin guide for developers

Call REST APIs from Android.

Expona Admin8 min read

Introduction

When you're building Android apps that talk to backend services, you quickly realize that manually managing HTTP connections is tedious and error-prone. A Retrofit tutorial: a Android & Kotlin guide for developers cuts through that complexity by giving you a declarative, type-safe way to define and consume REST APIs. Over the next few sections, we'll walk through how to set up Retrofit in a production Android codebase, starting from the interface definition through to proper error handling in your repositories.

Define an interface — Suspend functions for endpoints

The heart of Retrofit is its interface-based approach. Rather than writing boilerplate code to construct requests, Retrofit generates the HTTP client implementation at runtime using annotations. In Kotlin, we pair this with suspend functions so that our network calls integrate seamlessly with coroutines—the modern way to handle concurrency on Android.

Consider a real-world scenario: you're building an e-commerce app and need to fetch a product by ID from your backend. Here's how you'd define the endpoint:

kotlin
import retrofit2.http.GET
import retrofit2.http.Path

interface Api {
    @GET("products/{id}")
    suspend fun product(@Path("id") id: String): ProductDto
}

data class ProductDto(
    val id: String,
    val name: String,
    val price: Double,
    val description: String
)

The @GET annotation tells Retrofit this is an HTTP GET request. The {id} placeholder in the path gets replaced by the @Path("id") parameter at runtime. The suspend keyword means this function is a coroutine and won't block the calling thread—critical for keeping your Android UI responsive.

When you need to POST data or pass query parameters, extend this pattern:

kotlin
interface Api {
    @GET("products/{id}")
    suspend fun product(@Path("id") id: String): ProductDto

    @POST("orders")
    suspend fun createOrder(@Body request: CreateOrderRequest): OrderDto

    @GET("products")
    suspend fun listProducts(
        @Query("page") page: Int,
        @Query("limit") limit: Int
    ): List
}

Each method declaration becomes a contract that Retrofit validates at compile time when using Kotlin's type system correctly. This prevents entire classes of runtime errors before they reach production.

Build the client — Base URL and a JSON converter

Once you've defined your API interface, the next step is building a Retrofit instance. This is typically done once and reused throughout your app—treat it as a singleton. You'll need to specify a base URL and configure a converter factory to handle JSON serialization and deserialization.

In a typical Android project, we set this up in a dependency injection container or a service locator:

kotlin
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType

object ApiClient {
    private const val BASE_URL = "https://api.example.com/"

    fun create(): Api {
        val json = Json { ignoreUnknownKeys = true }
        
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()

        return retrofit.create(Api::class.java)
    }
}

The baseUrl is combined with the path in your endpoint annotations to form the complete URL. If your base URL is https://api.example.com/ and you call product("123"), Retrofit automatically constructs https://api.example.com/products/123.

In practice, you might also want to add an OkHttp interceptor for logging, authentication tokens, or custom headers. Here's a more realistic setup:

kotlin
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit

object ApiClient {
    private const val BASE_URL = "https://api.example.com/"

    fun create(authToken: String): Api {
        val logging = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }

        val httpClient = OkHttpClient.Builder()
            .addInterceptor(logging)
            .addInterceptor { chain ->
                val request = chain.request().newBuilder()
                    .addHeader("Authorization", "Bearer $authToken")
                    .build()
                chain.proceed(request)
            }
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()

        val json = Json { ignoreUnknownKeys = true }

        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(httpClient)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()

        return retrofit.create(Api::class.java)
    }
}

Notice how we're adding an interceptor to inject the authentication token into every request. This is a common pattern in production apps where you need to send credentials or tracking headers with each call.

Kotlinx serialization — Fast, Kotlin-native JSON parsing

Older Android projects often used Gson or Moshi for JSON conversion, but Kotlinx serialization is the modern choice for Kotlin codebases. It's faster, fully integrated with Kotlin's multiplatform ecosystem, and generates no reflection overhead at runtime—important on Android where startup time and memory footprint matter.

To use Kotlinx serialization with Retrofit, ensure your DTOs are marked with the @Serializable annotation:

kotlin
import kotlinx.serialization.Serializable

@Serializable
data class ProductDto(
    val id: String,
    val name: String,
    val price: Double,
    val description: String,
    val inStock: Boolean = true  // Default value for missing fields
)

@Serializable
data class CreateOrderRequest(
    val productId: String,
    val quantity: Int,
    val shippingAddress: String
)

@Serializable
data class OrderDto(
    val orderId: String,
    val status: String,
    val estimatedDelivery: String
)

The @Serializable annotation tells the Kotlinx serialization compiler plugin to generate serialization code at compile time. The ignoreUnknownKeys = true` configuration we set in the Json builder ensures that if the backend adds new fields in the future, your app won't crash—it'll just ignore them.

This approach also plays nicely with sealed classes if you need to handle polymorphic responses:

kotlin
import kotlinx.serialization.Serializable

@Serializable
sealed class ApiResponse {
    @Serializable
    data class Success(val data: ProductDto) : ApiResponse()
    
    @Serializable
    data class Error(val message: String) : ApiResponse()
}

Handle errors — Catch HttpException and IOException in repositories

Network calls can fail for many reasons: the server might return an error status code, the network might be unavailable, or JSON parsing might fail. In a well-structured Android app, your repository layer is responsible for catching these exceptions and converting them into domain-level results or sealed classes that your UI layer can handle safely.

Here's a production-ready repository pattern:

kotlin
import retrofit2.HttpException
import java.io.IOException

sealed class Result {
    data class Success(val data: T) : Result()
    data class Error(val exception: Exception) : Result()
    object Loading : Result()
}

class ProductRepository(private val api: Api) {
    suspend fun getProduct(id: String): Result {
        return try {
            val product = api.product(id)
            Result.Success(product)
        } catch (e: HttpException) {
            // Server returned an error status code (4xx, 5xx)
            Result.Error(Exception("HTTP error: ${e.code()} ${e.message()}"))
        } catch (e: IOException) {
            // Network error (no internet, timeout, DNS failure, etc.)
            Result.Error(Exception("Network error: ${e.message}"))
        } catch (e: Exception) {
            // Other exceptions (JSON parsing, etc.)
            Result.Error(Exception("Unexpected error: ${e.message}"))
        }
    }

    suspend fun createOrder(request: CreateOrderRequest): Result {
        return try {
            val order = api.createOrder(request)
            Result.Success(order)
        } catch (e: HttpException) {
            Result.Error(Exception("Failed to create order: ${e.code()}"))
        } catch (e: IOException) {
            Result.Error(Exception("Network unavailable"))
        } catch (e: Exception) {
            Result.Error(Exception("Order creation failed: ${e.message}"))
        }
    }
}

By wrapping your network calls in a repository and converting exceptions into a sealed Result type, you make your code testable and your UI layer easier to reason about. The UI then consumes this Result and reacts accordingly:

kotlin
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(ProductUiState.Loading)
    val uiState: StateFlow = _uiState.asStateFlow()

    fun loadProduct(id: String) {
        viewModelScope.launch {
            val result = repository.getProduct(id)
            _uiState.value = when (result) {
                is Result.Success -> ProductUiState.Loaded(result.data)
                is Result.Error -> ProductUiState.Error(result.exception.message ?: "Unknown error")
                is Result.Loading -> ProductUiState.Loading
            }
        }
    }
}

sealed class ProductUiState {
    object Loading : ProductUiState()
    data class Loaded(val product: ProductDto) : ProductUiState()
    data class Error(val message: String) : ProductUiState()
}

Common mistakes

  • Forgetting suspend functions. If you omit the suspend keyword from your Retrofit interface methods, you'll get a Callback-based API instead of a coroutine-friendly one. Always use suspend functions in modern Android code.
  • Hardcoding API credentials. Never commit API keys or authentication tokens to your repository. Use BuildConfig fields or a secure configuration server, and rotate tokens regularly.
  • Ignoring timeouts. The default OkHttp timeouts are often too lenient for mobile networks. Set explicit connection and read timeouts (typically 15-30 seconds) depending on your use case.
  • Not handling 401 Unauthorized. If your API returns 401 when a token expires, you need an interceptor or repository logic to refresh the token and retry the request—or log the user out gracefully.
  • Reusing Retrofit instances carelessly. Create Retrofit once and reuse it. Creating a new instance for each request wastes resources and defeats connection pooling.
  • Catching generic Exception. While we show it above for completeness, over-catching can hide bugs. Be specific: catch HttpException and IOException separately, and let other exceptions propagate or be logged for debugging.

Key takeaways

  • Define your API endpoints as an interface with annotations—Retrofit generates the HTTP client at runtime, keeping your code type-safe and declarative.
  • Use suspend functions for all network endpoints so they integrate with Kotlin coroutines and keep your UI thread responsive.
  • Build your Retrofit client once, configure it with a base URL and Kotlinx serialization converter, and inject it as a singleton throughout your app.
  • Kotlinx serialization is the Kotlin-native choice for JSON parsing—it's faster than Gson and integrates well with modern Kotlin tooling.
  • Wrap Retrofit calls in a repository layer that catches HttpException and IOException separately, converting them to domain-level Result types for your UI to consume safely.
  • Always set explicit timeouts, include authentication headers via interceptors, and handle error responses (especially 401 Unauthorized) gracefully.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles