I state management. Cache hit latency drops because OkHttp's Cache interceptor is explicitly configured rather than relying on implicit browser-like caching behavior.
Core Solution
Implementing Retrofit for production requires four coordinated layers: dependency declaration, contract definition, client configuration, and repository execution. Each layer must enforce separation of concerns and coroutine safety.
Step 1: Dependency Declaration
Use version catalogs or platform dependencies to ensure compatibility. Retrofit 2.11.0+ requires OkHttp 4.12.0+ and Kotlin 1.9+.
// build.gradle.kts (app module)
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}
Step 2: Data Model & Contract Definition
Use Moshi's @JsonClass for compile-time adapter generation. Define the API interface with suspend functions to enable coroutine integration.
@JsonClass(generateAdapter = true)
data class UserResponse(
@Json(name = "id") val id: String,
@Json(name = "username") val username: String,
@Json(name = "email") val email: String
)
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: String): UserResponse
@POST("auth/refresh")
suspend fun refreshToken(@Body refreshRequest: RefreshRequest): TokenResponse
}
Step 3: Production-Ready Client Configuration
Retrofit delegates to OkHttp. Configure timeouts, connection pooling, caching, and interceptors explicitly. Never use the default OkHttpClient() in production.
object NetworkModule {
private const val BASE_URL = "https://api.example.com/"
private const val TIMEOUT_SECONDS = 15L
private const val CACHE_SIZE_MB = 50L
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: Interceptor,
cache: Cache
): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.cache(cache)
.connectionPool(ConnectionPool(maxIdleConnections = 5, keepAliveDuration = 5, TimeUnit.MINUTES))
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.build()
}
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
fun provideCache(context: Context): Cache {
val cacheDir = File(context.cacheDir, "http_cache")
return Cache(cacheDir, CACHE_SIZE_MB * 1024 * 1024)
}
}
Step 4: Repository Layer & Structured Error Handling
Wrap network calls in a Result or sealed class to separate success, error, and loading states. Use CoroutineScope bound to ViewModel or Lifecycle.
class UserRepository @Inject constructor(
private val apiService: ApiService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun fetchUser(userId: String): Result<UserResponse> {
return withContext(ioDispatcher) {
try {
val response = apiService.getUser(userId)
Result.success(response)
} catch (e: HttpException) {
val errorBody = e.response()?.errorBody()?.string()
Result.failure(NetworkException(e.code(), errorBody))
} catch (e: IOException) {
Result.failure(NetworkException(0, e.message))
} catch (e: Exception) {
Result.failure(NetworkException(-1, "Unexpected error"))
}
}
}
}
sealed class NetworkException(val code: Int, message: String?) : Exception(message)
Architecture Decisions & Rationale
- Why
suspend over Call<T>? suspend functions integrate with structured concurrency, automatically cancel on scope cancellation, and eliminate callback nesting. Call<T> is retained only for legacy RxJava or Java interoperability.
- Why Moshi over Gson? Moshi generates adapters at compile time, reducing runtime reflection overhead by ~40% and eliminating
JsonSyntaxException surprises. It also handles null safety and Kotlin data class defaults predictably.
- Why Repository pattern? Retrofit should never be exposed directly to UI layers. The repository enforces error mapping, caching strategy, and data transformation, keeping
ViewModel logic declarative and testable.
- Why explicit
ConnectionPool? Mobile networks fluctuate. A pooled connection with 5 max idle connections and 5-minute keep-alive balances TCP handshake overhead with memory usage. Default pooling (5 connections, 5 minutes) is actually sound, but explicit declaration prevents accidental overrides.
Pitfall Guide
1. Blocking the Main Thread with Synchronous Calls
Using .execute() or forgetting suspend blocks the UI thread, causing ANRs. Retrofit's suspend functions run on the dispatcher you specify. Always route through Dispatchers.IO in the repository.
2. Ignoring HTTP Error Deserialization
HttpException does not deserialize the error payload automatically. Accessing e.response()?.errorBody()?.string() is mandatory for structured error handling. Failing to do so forces UI layers to parse raw JSON strings, breaking type safety.
3. Misordered Interceptors
Interceptor execution order is strict. Authentication interceptors must run before logging interceptors to avoid leaking tokens in logs. Retry/circuit-breaker interceptors must wrap the entire chain. Incorrect ordering causes infinite retry loops or missing headers.
OkHttp respects Cache-Control headers. If the backend sends no-cache, OkHttp will validate with the server every time. Forcing cache without backend coordination causes stale data. Use @Headers("Cache-Control: max-age=60") on read-only endpoints only.
5. Leaking Coroutine Scopes
Launching network calls in viewModelScope is safe, but launching in GlobalScope or unbound CoroutineScope persists calls across configuration changes and activity destruction. Always tie scopes to lifecycle-aware components.
6. Assuming 4xx/5xx Throws IOException
Retrofit throws HttpException for HTTP errors, not IOException. IOException is reserved for network failures (DNS, timeout, socket reset). Catching only IOException leaves 401/403/500 unhandled, causing uncaught exceptions in coroutines.
7. Shipping Debug Logging in Release
HttpLoggingInterceptor.Level.BODY logs request/response payloads, including tokens and PII. Use BuildConfig.DEBUG guards or ProGuard/R8 rules to strip logging interceptors in release builds. Production apps should use Level.NONE or Level.BASIC.
Production Best Practices:
- Implement
OkAuthenticator for automatic token refresh on 401 responses.
- Use
@QueryMap and @HeaderMap for dynamic parameters instead of string concatenation.
- Profile with
StrictMode and NetworkStatsManager during QA to detect unbounded requests.
- Mock Retrofit interfaces in unit tests using
MockWebServer for deterministic network simulation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple CRUD REST API | Retrofit + Moshi + Coroutines | Declarative, minimal boilerplate, native coroutine support | Low |
| Real-time streaming / WebSocket | OkHttp WebSocket + Flow | Retrofit lacks native streaming; OkHttp provides bidirectional channels | Medium |
| Offline-first with heavy caching | Retrofit + OkHttp Cache + Room | Cache interceptor + local DB sync handles intermittent connectivity | Medium |
| GraphQL endpoints | Apollo Android Client | Retrofit cannot parse GraphQL responses efficiently; Apollo handles operations & caching | High |
| Legacy Java codebase | Retrofit + Gson + RxJava 3 | Maintains type safety while bridging to existing reactive patterns | Low |
Configuration Template
// NetworkModule.kt (Hilt-compatible)
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi {
return Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideAuthInterceptor(@ApplicationContext context: Context): Interceptor {
return Interceptor { chain ->
val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
val token = prefs.getString("access_token", null)
val request = chain.request().newBuilder()
.apply { if (token != null) addHeader("Authorization", "Bearer $token") }
.build()
chain.proceed(request)
}
}
@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: Interceptor,
cache: Cache
): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.cache(cache)
.connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.build()
}
@Provides
@Singleton
fun provideCache(@ApplicationContext context: Context): Cache {
return Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
Quick Start Guide
- Add Retrofit, OkHttp, Moshi, and Coroutines dependencies to
build.gradle.kts and sync the project.
- Create a
@JsonClass(generateAdapter = true) data model matching the backend JSON structure.
- Define an
interface with @GET/@POST annotations and suspend functions.
- Initialize
Retrofit with a configured OkHttpClient (timeouts, cache, interceptors) and call retrofit.create(ApiService::class.java).
- Execute calls inside a
ViewModel using viewModelScope.launch { repository.fetchData() } and handle Result states in the UI.