AA3.1 Repositorio, errores y mapeo¶
Qué se ha ejecutado y qué no
Los ejemplos con salida son Kotlin puro con una API y una base de datos falsas (clases escritas aquí para simular la red y Room), y se han ejecutado de verdad al construir la web. Retrofit, Room y WorkManager reales no se pueden ejecutar sin Android: sus fragmentos están marcados «sin ejecutar».
Las capas¶
Una app que mezcla en el mismo sitio dibujar la pantalla, llamar a internet y leer del disco se vuelve imposible de cambiar y de probar. La organización habitual separa tres capas:
| Capa | Qué contiene | Quién la usa |
|---|---|---|
| Interfaz | Composables y ViewModel |
El usuario |
| Datos | Repositorios y fuentes de datos (red, base de datos local, preferencias) | El ViewModel |
| Dominio (opcional) | Reglas de negocio y casos de uso, cuando son complejas | El ViewModel |
El repositorio es el único que sabe de dónde salen los datos. El ViewModel le pregunta («dame los productos») sin saber si vienen de internet, de la caché o de un archivo. Eso permite cambiar de Retrofit a otra librería, o añadir una caché, sin tocar la pantalla, y sustituir el repositorio por uno falso en las pruebas.
Tres modelos para el mismo dato¶
Un producto no tiene la misma forma en todas partes:
| Modelo | Dónde | Cómo es |
|---|---|---|
| DTO (data transfer object) | La red | Refleja el JSON tal cual, con nombres feos y casi todo opcional (precio_cent: Int?) |
| Entidad | La base de datos (Room) | Refleja la tabla |
| Modelo de dominio | El resto de la app | Limpio, con los tipos que te convienen y sin nulos imposibles |
Se convierten con funciones de mapeo (normalmente funciones de extensión). La gran ventaja: si el servidor cambia un nombre de campo, solo se toca el mapeo.
data class ProductoDto(val id: Int?, val nombre: String?, val precio_cent: Int?, val stock: Int?) // tal como llega de la red
data class Producto(val id: Int, val nombre: String, val precio: Double, val disponible: Boolean) // lo que usa la app
fun ProductoDto.toDominio(): Producto? {
// Un DTO incompleto no se convierte: se devuelve null y quien llama decide qué hacer
val id = id ?: return null
val nombre = nombre?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val centimos = precio_cent ?: return null
return Producto(id, nombre, centimos / 100.0, (stock ?: 0) > 0)
}
fun main() {
val dtos = listOf(
ProductoDto(1, "Cuaderno", 350, 10),
ProductoDto(2, null, 100, 3), // sin nombre
ProductoDto(3, " Lápiz ", 90, 0), // con espacios y sin stock
ProductoDto(4, "Goma", null, 5) // sin precio
)
val productos = dtos.mapNotNull { it.toDominio() }
productos.forEach(::println)
println("${dtos.size - productos.size} descartados por estar incompletos")
}
Salida:
Producto(id=1, nombre=Cuaderno, precio=3.5, disponible=true)
Producto(id=3, nombre=Lápiz, precio=0.9, disponible=false)
2 descartados por estar incompletos
El mapeo hace tres trabajos a la vez: valida (descarta lo incompleto con mapNotNull), limpia (trim) y traduce (céntimos a euros, stock a un Boolean). A partir de aquí, el resto de la app trabaja con Producto, que nunca tiene un nombre nulo.
Errores: de excepciones a mensajes¶
Una petición falla de muchas maneras: sin red, el servidor devuelve un 500, el recurso no existe... La pantalla no debe ver una IOException: debe recibir algo que pueda explicar al usuario. Se traduce en el repositorio:
import java.io.IOException
class HttpFalso(val codigo: Int) : Exception("HTTP $codigo") // hace el papel de la excepción HTTP de Retrofit
sealed interface ErrorApp {
data object SinConexion : ErrorApp
data object NoEncontrado : ErrorApp
data class Servidor(val codigo: Int) : ErrorApp
data class Desconocido(val causa: Throwable) : ErrorApp
}
fun Throwable.aErrorApp(): ErrorApp = when (this) {
is IOException -> ErrorApp.SinConexion
is HttpFalso -> if (codigo == 404) ErrorApp.NoEncontrado else ErrorApp.Servidor(codigo)
else -> ErrorApp.Desconocido(this)
}
fun ErrorApp.mensaje(): String = when (this) {
ErrorApp.SinConexion -> "No hay conexión. Revisa tu red."
ErrorApp.NoEncontrado -> "No se encontró lo que buscas."
is ErrorApp.Servidor -> "El servidor tuvo un problema (código $codigo). Inténtalo más tarde."
is ErrorApp.Desconocido -> "Ocurrió algo inesperado."
}
fun main() {
val fallos = listOf<Throwable>(IOException("timeout"), HttpFalso(404), HttpFalso(503), IllegalStateException("bug"))
fallos.forEach { println("${it::class.simpleName}: ${it.aErrorApp().mensaje()}") }
}
Salida:
IOException: No hay conexión. Revisa tu red.
HttpFalso: No se encontró lo que buscas.
HttpFalso: El servidor tuvo un problema (código 503). Inténtalo más tarde.
IllegalStateException: Ocurrió algo inesperado.
Con una jerarquía sealed y when sin else, si mañana añades un tipo de error, el compilador te recuerda todos los sitios donde falta su mensaje.
runCatching y la cancelación¶
runCatching { ... } convierte una excepción en un Result y es muy cómodo, pero tiene una trampa que ya conoces de la AA2: captura también la CancellationException, así que una corrutina cancelada sigue ejecutándose.
suspend fun pedir(): String { delay(1000); return "datos" }
sealed interface Resultado<out T> {
data class Ok<T>(val valor: T) : Resultado<T>
data class Fallo(val error: Throwable) : Resultado<Nothing>
}
// La versión correcta: relanza la cancelación
suspend fun <T> intentar(bloque: suspend () -> T): Resultado<T> =
try { Resultado.Ok(bloque()) }
catch (e: CancellationException) { throw e }
catch (e: Exception) { Resultado.Fallo(e) }
fun main() = runBlocking {
val malo = launch {
val r = runCatching { pedir() }
println("runCatching: fallo=${r.isFailure} y yo sigo ejecutando (¿activo? $isActive)")
}
val bueno = launch {
val r = intentar { pedir() }
println("esto no se imprime: $r")
}
delay(100)
malo.cancelAndJoin()
bueno.cancelAndJoin()
println("intentar respetó la cancelación: ${bueno.isCancelled}")
}
Salida:
runCatching: fallo=true y yo sigo ejecutando (¿activo? false)
intentar respetó la cancelación: true
runCatching cogió la cancelación, siguió con la línea siguiente y siguió ejecutando una corrutina ya cancelada. intentar la dejó pasar y la corrutina terminó como debía. Si usas runCatching con funciones suspend, asegúrate de relanzar CancellationException (o usa un helper como este).
El repositorio, completo¶
Junta todo: el repositorio recibe una interfaz (ProductosApi), pide los datos, mapea, traduce los errores y devuelve un Flow con las tres fases que necesita la pantalla.
// (necesita import java.io.IOException, como en el ejemplo anterior)
sealed interface Carga<out T> {
data object Cargando : Carga<Nothing>
data class Ok<T>(val datos: T) : Carga<T>
data class Fallo(val error: ErrorApp) : Carga<Nothing>
}
interface ProductosApi { suspend fun listar(): List<ProductoDto> }
class ProductosRepositorio(private val api: ProductosApi) {
fun productos(): Flow<Carga<List<Producto>>> = flow {
emit(Carga.Cargando)
try {
val lista = api.listar().mapNotNull { it.toDominio() }
emit(Carga.Ok(lista))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
emit(Carga.Fallo(e.aErrorApp()))
}
}
}
// Dos APIs falsas para probarlo
class ApiBuena : ProductosApi {
override suspend fun listar() = listOf(ProductoDto(1, "Cuaderno", 350, 10), ProductoDto(2, null, 100, 3))
}
class ApiSinRed : ProductosApi {
override suspend fun listar(): List<ProductoDto> = throw IOException("sin red")
}
class ApiRota : ProductosApi {
override suspend fun listar(): List<ProductoDto> = throw HttpFalso(500)
}
fun main() = runBlocking {
for ((nombre, api) in listOf("buena" to ApiBuena(), "sin red" to ApiSinRed(), "rota" to ApiRota())) {
println("— API $nombre")
ProductosRepositorio(api).productos().collect { println(" $it") }
}
}
Salida:
— API buena
Cargando
Ok(datos=[Producto(id=1, nombre=Cuaderno, precio=3.5, disponible=true)])
— API sin red
Cargando
Fallo(error=SinConexion)
— API rota
Cargando
Fallo(error=Servidor(codigo=500))
Fíjate en lo que no hace falta: ni Retrofit, ni emulador, ni una red real. Como el repositorio depende de una interfaz, en las pruebas le pasas una API falsa que falla cuando tú quieres. Esa es la razón de ser de las interfaces en esta capa.
Con Retrofit de verdad (sin ejecutar)¶
// Sin ejecutar: necesita Android y la dependencia de Retrofit
interface ProductosService {
@GET("productos")
suspend fun listar(): List<ProductoDto>
@GET("productos/{id}")
suspend fun detalle(@Path("id") id: Int): ProductoDto
}
class ProductosApiRetrofit(private val servicio: ProductosService) : ProductosApi {
override suspend fun listar() = servicio.listar()
}
ProductosApiRetrofit es la única clase que conoce Retrofit. El resto de la app solo ve ProductosApi. Las excepciones reales que lanza Retrofit son IOException (sin red) y HttpException (el servidor respondió con un error): el aErrorApp() de arriba se adapta a ellas con un caso más en el when.
Errores frecuentes¶
| Error | Cómo evitarlo |
|---|---|
| Usar el DTO en toda la app | Mapear a un modelo de dominio en el repositorio |
| Dejar pasar excepciones crudas hasta la pantalla | Traducirlas a un tipo propio (ErrorApp) |
runCatching con suspend sin pensar en la cancelación |
Relanzar CancellationException |
Mapear sin validar (dto.nombre!!) |
Devolver null o un error y descartar lo inválido |
El repositorio crea su propia API (Retrofit.Builder() dentro) |
Recibirla por el constructor, como interfaz |
Mostrar e.message al usuario |
Un mensaje pensado para personas, no el del sistema |
Para practicar¶
Los ejercicios AA3.1 a AA3.3 de AA3 · Ejercicios practican el mapeo, intentar y la traducción de errores.