Consumo de APIs REST en Android con Retrofit y serialización en Kotlin

Capítulo 8

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

¿Qué es Retrofit y cómo encaja en una app Android?

Retrofit es una librería para consumir servicios web (normalmente APIs REST) desde Android. Su objetivo es que declares los endpoints como funciones Kotlin y obtengas respuestas tipadas (modelos) sin escribir manualmente el código de bajo nivel de HTTP. Retrofit se apoya en un cliente HTTP (OkHttp) y en un convertidor de serialización (por ejemplo, Kotlinx Serialization) para transformar JSON a objetos Kotlin y viceversa.

En este capítulo conectaremos una app a un servicio web, definiendo modelos de respuesta (DTO), endpoints, manejo de errores HTTP, timeouts y estados de carga. Además, haremos una práctica completa: llamar a un endpoint, mapear DTO a modelo de dominio y mostrar resultados en una lista.

Dependencias necesarias (Retrofit + OkHttp + Kotlinx Serialization)

Usaremos Kotlinx Serialization como convertidor JSON. Asegúrate de tener aplicado el plugin de serialization y las dependencias de Retrofit/OkHttp. Ejemplo de configuración (puede variar según tu versión de Gradle):

// build.gradle (Module) - ejemplo orientativo
plugins {
  id("org.jetbrains.kotlin.plugin.serialization")
}

dependencies {
  implementation("com.squareup.retrofit2:retrofit: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-serialization-json:1.6.3")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
}

Nota: el convertidor retrofit2-kotlinx-serialization-converter facilita integrar Kotlinx Serialization con Retrofit.

Modelos: DTO (red) vs modelo de dominio (app)

Una práctica recomendada es separar los modelos que vienen de la red (DTO) de los modelos que usa tu app (dominio). El DTO refleja el JSON tal cual llega; el dominio representa lo que tu UI y lógica necesitan, con nombres y tipos más estables.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Ejemplo de JSON

Imaginemos un endpoint que devuelve una lista de repositorios:

[{"id": 1, "name": "kotlin", "description": "Lang", "stars": 1234}]

DTO con Kotlinx Serialization

Usa @Serializable y, si el nombre del campo en JSON no coincide, @SerialName. Declara campos opcionales si pueden venir nulos o ausentes.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class RepoDto(
  val id: Long,
  val name: String,
  val description: String? = null,
  @SerialName("stars") val stargazersCount: Int? = null
)

Modelo de dominio

data class Repo(
  val id: Long,
  val title: String,
  val subtitle: String,
  val stars: Int
)

Mapper DTO -> Dominio

El mapper decide valores por defecto y normaliza datos (por ejemplo, si faltan campos).

fun RepoDto.toDomain(): Repo = Repo(
  id = id,
  title = name,
  subtitle = description ?: "Sin descripción",
  stars = stargazersCount ?: 0
)

Definir endpoints con Retrofit

Retrofit define una interfaz con anotaciones HTTP. Usaremos funciones suspend para integrarlo con corrutinas.

import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path

interface RepoApi {
  @GET("users/{user}/repos")
  suspend fun getRepos(
    @Path("user") user: String
  ): Response<List<RepoDto>>
}

Usar Response<T> te permite inspeccionar código HTTP, headers y cuerpo, y manejar errores de forma explícita.

Configurar Retrofit: baseUrl, serialización, logging y timeouts

Crearemos un proveedor de Retrofit con OkHttp configurado. Incluiremos timeouts y un interceptor de logging (útil en desarrollo).

import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit

object NetworkModule {
  private val json = Json {
    ignoreUnknownKeys = true
    isLenient = true
    explicitNulls = false
  }

  private val logging = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
  }

  private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(15, TimeUnit.SECONDS)
    .writeTimeout(15, TimeUnit.SECONDS)
    .callTimeout(20, TimeUnit.SECONDS)
    .addInterceptor(logging)
    .build()

  private val retrofit: Retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .client(okHttpClient)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

  val repoApi: RepoApi = retrofit.create(RepoApi::class.java)
}
  • ignoreUnknownKeys: evita fallos si el backend añade campos nuevos.
  • explicitNulls = false: ayuda con JSON donde campos nulos se omiten o se tratan de forma flexible.
  • Timeouts: protegen de esperas infinitas y te permiten mostrar errores y reintentos.

Manejo de errores HTTP y de red

En consumo de APIs hay dos grandes familias de errores: (1) HTTP (el servidor respondió con un código 4xx/5xx) y (2) errores de red/IO (no hubo respuesta: sin internet, DNS, timeout, etc.).

Modelo de resultado para la capa de datos

Un patrón simple es encapsular el resultado en un tipo sellado.

sealed class ApiResult<out T> {
  data class Success<T>(val data: T) : ApiResult<T>()
  data class HttpError(val code: Int, val message: String?) : ApiResult<Nothing>()
  data class NetworkError(val message: String?) : ApiResult<Nothing>()
  data class UnknownError(val message: String?) : ApiResult<Nothing>()
}

Función helper para ejecutar llamadas

Centraliza el manejo de Response y excepciones.

import retrofit2.Response
import java.io.IOException

suspend fun <T> safeApiCall(call: suspend () -> Response<T>): ApiResult<T> {
  return try {
    val response = call()
    if (response.isSuccessful) {
      val body = response.body()
      if (body != null) ApiResult.Success(body)
      else ApiResult.UnknownError("Respuesta vacía")
    } else {
      ApiResult.HttpError(
        code = response.code(),
        message = response.errorBody()?.string()
      )
    }
  } catch (e: IOException) {
    ApiResult.NetworkError(e.message)
  } catch (e: Exception) {
    ApiResult.UnknownError(e.message)
  }
}

Si tu API devuelve un JSON de error estructurado, puedes parsearlo desde errorBody() con Kotlinx Serialization, pero recuerda que errorBody() es un stream de una sola lectura.

Estados de carga (Loading, Success, Error) para la UI

Para que la UI pueda reaccionar, normalmente se expone un estado que represente: cargando, datos listos o error. Un ejemplo típico:

sealed class UiState<out T> {
  data object Idle : UiState<Nothing>()
  data object Loading : UiState<Nothing>()
  data class Success<T>(val data: T) : UiState<T>()
  data class Error(val message: String) : UiState<Nothing>()
}

La capa de presentación (por ejemplo, un ViewModel) transforma ApiResult a UiState y la UI renderiza en función del estado.

Práctica completa: consumir endpoint, mapear DTO a dominio y mostrar en lista

Paso 1: Definir el endpoint

Ya lo tenemos en RepoApi.getRepos(user).

Paso 2: Crear un repositorio (capa de datos)

El repositorio llama a la API, maneja errores y mapea DTO a dominio.

class RepoRepository(
  private val api: RepoApi
) {
  suspend fun fetchRepos(user: String): ApiResult<List<Repo>> {
    return when (val result = safeApiCall { api.getRepos(user) }) {
      is ApiResult.Success -> {
        val domain = result.data.map { it.toDomain() }
        ApiResult.Success(domain)
      }
      is ApiResult.HttpError -> result
      is ApiResult.NetworkError -> result
      is ApiResult.UnknownError -> result
    }
  }
}

Paso 3: ViewModel con estado de carga

Este ejemplo asume que ya trabajas con ViewModel y estado (visto en capítulos previos). Aquí nos centramos en cómo conectar la llamada y exponer el estado.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class RepoListViewModel(
  private val repository: RepoRepository
) : ViewModel() {

  private val _state = MutableStateFlow<UiState<List<Repo>>>(UiState.Idle)
  val state: StateFlow<UiState<List<Repo>>> = _state

  fun load(user: String) {
    _state.value = UiState.Loading
    viewModelScope.launch {
      when (val result = repository.fetchRepos(user)) {
        is ApiResult.Success -> _state.value = UiState.Success(result.data)
        is ApiResult.HttpError -> _state.value = UiState.Error("HTTP ${result.code}: ${result.message ?: "Error"}")
        is ApiResult.NetworkError -> _state.value = UiState.Error("Error de red: ${result.message ?: "Verifica tu conexión"}")
        is ApiResult.UnknownError -> _state.value = UiState.Error("Error inesperado: ${result.message ?: "Intenta de nuevo"}")
      }
    }
  }
}

Paso 4: Mostrar resultados en una lista (Jetpack Compose)

Renderiza según el estado: indicador de carga, lista o mensaje de error.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun RepoListScreen(
  state: UiState<List<Repo>>,
  onRetry: () -> Unit
) {
  when (state) {
    UiState.Idle -> {
      Text("Ingresa un usuario y busca repos")
    }
    UiState.Loading -> {
      Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
        CircularProgressIndicator()
      }
    }
    is UiState.Error -> {
      Column(Modifier.padding(16.dp)) {
        Text(state.message, color = MaterialTheme.colorScheme.error)
        Spacer(Modifier.height(12.dp))
        Button(onClick = onRetry) { Text("Reintentar") }
      }
    }
    is UiState.Success -> {
      LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        items(state.data) { repo ->
          ElevatedCard {
            Column(Modifier.padding(16.dp)) {
              Text(repo.title, style = MaterialTheme.typography.titleMedium)
              Spacer(Modifier.height(4.dp))
              Text(repo.subtitle, style = MaterialTheme.typography.bodyMedium)
              Spacer(Modifier.height(8.dp))
              Text("★ ${repo.stars}", style = MaterialTheme.typography.labelLarge)
            }
          }
        }
      }
    }
  }
}

Para completar el flujo, tu pantalla contenedora puede llamar a viewModel.load(user) (por ejemplo, al pulsar un botón de búsqueda) y pasar state a RepoListScreen.

Timeouts, reintentos y cancelación

Timeouts

Ya configuramos connectTimeout, readTimeout, writeTimeout y callTimeout. En móviles, un timeout demasiado corto puede generar falsos errores en redes lentas; uno demasiado largo empeora la experiencia. Ajusta según tu caso de uso (por ejemplo, 10–20s suele ser razonable).

Reintentos

Evita reintentar automáticamente en todos los casos. Reintenta de forma controlada en errores transitorios (timeouts, 502/503) y ofrece un botón de “Reintentar” al usuario. Si implementas reintentos automáticos, limita el número y usa backoff.

Cancelación

Cuando usas corrutinas con Retrofit (suspend), si el scope se cancela (por ejemplo, al salir de la pantalla), la llamada se cancela y OkHttp libera recursos. Esto evita trabajo innecesario y estados inconsistentes.

Problemas comunes y cómo tratarlos

1) JSON incompleto o con campos faltantes

Es frecuente que el backend omita campos. Si tu DTO declara un campo no nulo sin valor por defecto y el JSON no lo trae, la deserialización fallará.

  • Solución: declara campos opcionales (String?, Int?) o provee valores por defecto en el DTO.
  • Activa ignoreUnknownKeys = true para tolerar campos extra (no faltantes), y usa defaults para faltantes.
@Serializable
data class UserDto(
  val id: Long,
  val username: String = "",
  val avatarUrl: String? = null
)

2) Campos nulos donde no esperabas nulos

Algunas APIs devuelven null en campos como description. Si tu dominio no quiere nulos, resuélvelo en el mapper.

  • Solución: en toDomain(), reemplaza null por un texto o valor por defecto.
  • Si el campo es crítico (por ejemplo, un id), considera validar y devolver un error de datos.
fun RepoDto.toDomain(): Repo {
  require(name.isNotBlank()) { "Nombre inválido" }
  return Repo(
    id = id,
    title = name,
    subtitle = description ?: "Sin descripción",
    stars = stargazersCount ?: 0
  )
}

3) Errores de red (sin internet, DNS, timeout)

Estos errores suelen llegar como IOException. No hay código HTTP porque no hubo respuesta.

  • Solución: captura IOException y muestra un mensaje accionable: “Verifica tu conexión” o “Intenta más tarde”.
  • Para timeouts, OkHttp lanza excepciones específicas (subclases de IOException), pero puedes tratarlas de forma general o diferenciarlas si necesitas mensajes distintos.
catch (e: IOException) {
  // Sin conexión, timeout, etc.
  ApiResult.NetworkError(e.message)
}

4) Errores HTTP (401, 403, 404, 422, 500)

El servidor respondió, pero con error. Aquí sí hay código HTTP.

  • Solución: usa response.code() para decidir el mensaje o acción.
  • Ejemplo: 401 puede llevar a refrescar sesión/token; 404 puede mostrar “No encontrado”; 500 “Problema del servidor”.
if (!response.isSuccessful) {
  return ApiResult.HttpError(response.code(), response.errorBody()?.string())
}

5) Respuesta vacía o inesperada

A veces el endpoint responde 204 (No Content) o un 200 con body vacío. Si tu app espera datos, debes contemplarlo.

  • Solución: valida body y devuelve un error controlado o un estado vacío.
  • En listas, puedes mapear a lista vacía y mostrar un estado “Sin resultados”.
val body = response.body() ?: emptyList()

6) Serialización falla por tipos incompatibles

Ejemplo típico: el backend envía un número como string o viceversa. Kotlinx Serialization es estricta por defecto.

  • Solución: ajusta el DTO al tipo real del JSON o crea un adaptador/transformación (por ejemplo, recibir como String? y convertir en el mapper).
  • Si el backend es inconsistente, considera un DTO más tolerante y normaliza en dominio.

Checklist rápido para una integración estable

ÁreaQué revisar
DTOCampos opcionales y defaults para faltantes; @SerialName cuando aplique
Json configignoreUnknownKeys activo; explicitNulls según tu API
ErroresDiferenciar HTTP vs red; mensajes claros; parseo de errorBody si es necesario
TimeoutsValores razonables; evitar esperas largas sin feedback
UIEstados: Loading/Success/Error; botón de reintento; estado vacío
MapeoNormalizar nulos; validar campos críticos; dominio sin detalles de red

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la razón principal para separar los modelos DTO (red) de los modelos de dominio (app) al consumir una API con Retrofit?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Separar DTO y dominio permite que el DTO represente el JSON de la red, mientras el modelo de dominio se mantiene estable para la app. Un mapper puede normalizar nulos y definir valores por defecto sin contaminar la UI con detalles del backend.

Siguiente capítulo

Concurrencia y tareas en segundo plano con Coroutines y Flow

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
57%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.