wandres.dev
CAPA DE DATOS · repositorios y fuentes

Fuente remota: DTOs, mapeo y la red que no debe subir

El servicio de red es el componente más inestable de una aplicación porque su forma la decide otro equipo y cambia sin previo aviso. Esta lección trata el acceso remoto como lo que es, un detalle confinado de la implementación: por qué el objeto de transferencia debe ser un tipo aparte del modelo de dominio y por qué casi todos sus campos deberían declararse opcionales, cómo el mapeo en la frontera es el lugar donde la desconfianza se convierte en garantías, y qué significa exactamente que ningún tipo, error, código de estado ni excepción de red pueda aparecer por encima del repositorio.

⏱ 19 min

De todas las dependencias que tiene una aplicación, la red es la única cuya forma decide alguien que no está en la sala. El esquema de la base de datos lo controla el equipo; la biblioteca de interfaz se actualiza cuando conviene; el contrato del servidor, en cambio, puede añadir un campo el martes, hacer opcional otro el jueves y devolver un código inesperado el viernes por la noche, sin que nadie haya tocado una línea del proyecto. Esa asimetría de control es la que justifica todo lo que sigue. Tratar la respuesta de un servidor como si fuera un valor de confianza equivale a permitir que un sistema ajeno defina los tipos internos de la aplicación, y por tanto a aceptar que un cambio remoto pueda propagarse hasta un reducer. La disciplina que evita eso cabe en una frase: el mundo exterior entra por un tipo propio, desconfiado y desechable, y solo se convierte en dominio después de pasar por una función que decide si merece serlo.

🎯 Al terminar esta lección sabrás
  • Declarar el servicio remoto como un detalle interno que ningún consumidor del repositorio puede nombrar.
  • Modelar objetos de transferencia tolerantes al cambio y separados del modelo de dominio.
  • Escribir mapeadores que validen en la frontera y produzcan modelos sin estados imposibles.
  • Impedir que tipos, excepciones y códigos de estado de red asciendan por encima de la capa de datos.

El servicio remoto es un detalle confinado

Una interfaz de acceso remoto describe un protocolo, no un dominio. Sus métodos hablan de rutas, parámetros de consulta y cuerpos; sus tipos de retorno son objetos de transferencia. Nada de eso debe salir del módulo de datos, y por eso la interfaz se declara con visibilidad restringida y se inyecta únicamente en la implementación del repositorio.

internal interface CatalogoApi {

    @GET("v2/catalogo/{categoria}")
    suspend fun productos(
        @Path("categoria") categoria: String,
        @Query("desde") cursor: String? = null,
    ): List<ProductoDto>
}

Dos detalles de esa firma tienen consecuencias arquitectónicas. El primero es que devuelve directamente la lista y no un envoltorio de respuesta con código de estado y cabeceras: quien la llama no quiere inspeccionar un estado HTTP, quiere datos o un fallo, y el fallo llegará como excepción que la implementación capturará y traducirá. Exponer el envoltorio invita a que alguien, aguas arriba, escriba una condición sobre un número de tres cifras, y ese número es infraestructura pura. El segundo es el cursor de paginación: es un parámetro del protocolo, no del negocio, y por eso aparece aquí y no en la interfaz del repositorio, que se limita a hablar de refrescar una categoría.

La paginación merece una nota aparte porque es el lugar donde más se filtra el protocolo hacia arriba. Un repositorio que expone cursores, números de página o tamaños de lote está publicando la forma en que el servidor decide trocear sus respuestas, y basta con que el servidor pase de cursores a desplazamientos para que ese cambio llegue a la pantalla. La formulación correcta habla de lo que el dominio necesita, cargar más elementos de una lista, y deja que el repositorio guarde internamente por dónde iba. Si la biblioteca de paginación impone su propio tipo en la frontera, ese tipo pertenece a la capa de presentación y no al dominio, y conviene tenerlo claro antes de repartirlo por toda la aplicación.

El planteamiento no cambia con Ktor en lugar de Retrofit. La forma de declarar la petición difiere, pero la regla es idéntica: la clase que construye peticiones es interna, se inyecta en el repositorio y no la conoce nadie más. Cambiar de cliente HTTP debería ser una tarea contenida en un módulo; si obliga a tocar el ViewModel, la frontera estaba rota antes de empezar la migración.

💡
El cliente HTTP no se construye dentro del repositorio

Tiempos de espera, reintentos, cabeceras de autenticación, registro y serialización se configuran una sola vez donde se construye el cliente, no dentro de cada repositorio. Un repositorio que instancia su propio cliente duplica política transversal, dificulta las pruebas y garantiza que dos repositorios acaben con tiempos de espera distintos por descuido. La construcción pertenece al grafo de inyección de dependencias; el repositorio solo recibe una interfaz ya configurada.

El objeto de transferencia miente, y por eso es un tipo aparte

Un objeto de transferencia representa lo que el servidor dijo, no lo que la aplicación necesita. Esa distinción justifica que sea un tipo propio y explica su forma, que a primera vista parece una regresión: casi todos sus campos deberían ser opcionales.

@Serializable
internal data class ProductoDto(
    val id: String? = null,
    val nombre: String? = null,
    @SerialName("precio_centimos") val precioCentimos: Long? = null,
    val categoria: String? = null,
)

La reacción instintiva es que eso arruina la seguridad frente a valores nulos que tanto costó ganar. Ocurre lo contrario. Declarar un campo como obligatorio en el objeto de transferencia no lo hace obligatorio en el servidor: solo hace que la deserialización lance una excepción cuando falte, en un punto del código que nadie eligió y con un mensaje que habla de serialización en vez de negocio. Declararlo opcional traslada la decisión al mapeador, que es el lugar donde se puede decidir con criterio si un producto sin nombre debe descartarse, sustituirse por un valor por defecto o provocar el fallo de toda la operación. La opcionalidad del objeto de transferencia no es debilidad: es honestidad sobre lo que se sabe realmente, y la garantía se recupera unos milímetros más abajo, ya con contexto para decidir.

El mismo razonamiento explica por qué el objeto de transferencia debe ignorar los campos desconocidos en lugar de fallar ante ellos. Un servidor que añade una propiedad nueva no está rompiendo nada; una aplicación que se cae por ese motivo se está rompiendo a sí misma, y además en todas las versiones ya instaladas, que es el peor momento posible para descubrirlo.

flowchart LR
S[Servidor] -->|JSON| D[DTO opcional y tolerante]
D --> M[Mapeador que valida]
M -->|valido| E[Entidad local]
M -->|invalido| X[Descarte o fallo explicito]
E --> DO[Modelo de dominio]
style M fill:#f9e2af,color:#11111b
style DO fill:#a6e3a1,color:#11111b

Traducir es validar

El mapeador es la aduana. Su trabajo no consiste en copiar campos, sino en decidir qué respuestas merecen convertirse en dominio y qué hacer con las que no. Escribirlo como una función que devuelve un valor opcional, o un Result, obliga a esa decisión en vez de esconderla.

internal fun ProductoDto.aDominioONulo(): Producto? {
    val id = id?.takeIf { it.isNotBlank() } ?: return null
    val nombre = nombre?.takeIf { it.isNotBlank() } ?: return null
    val precio = precioCentimos?.takeIf { it >= 0 } ?: return null
    return Producto(
        id = ProductoId(id),
        nombre = nombre,
        precio = Dinero(precio),
        categoria = CategoriaId(categoria ?: "sin-categoria"),
    )
}

Fíjate en que cada rama de descarte encierra una decisión de producto, no una decisión técnica. Un producto sin identificador es inservible y se descarta. Un precio negativo indica un fallo de origen y también se descarta. Una categoría ausente, en cambio, es tolerable con un valor de reserva porque no impide mostrar el producto. Esas tres respuestas distintas a tres ausencias distintas son justo lo que un fallo de deserialización habría colapsado en un único error genérico.

🛃

Descartar el elemento

Adecuado en listas: un elemento corrupto no debería impedir mostrar los cien restantes. Conviene registrar cuántos se descartaron.

🛑

Fallar la operación

Adecuado cuando el dato es único e imprescindible, como el detalle que la pantalla iba a mostrar. Falsear un valor sería peor que fallar.

🩹

Valor de reserva

Legítimo solo cuando la ausencia tiene un significado razonable en el negocio y no cuando sirve para callar un problema.

📈

Registrar la anomalia

Toda traducción fallida es información sobre el contrato. Sin métrica, un servidor que empieza a mandar basura pasa inadvertido durante meses.

Hay un matiz que conviene fijar sobre el destino de la traducción. Cuando existe base de datos local, el objeto de transferencia suele convertirse primero en entidad y solo después en modelo de dominio, porque quien va a guardar los datos es la fuente local y quien va a mostrarlos los leerá de ella. Escribir directamente del objeto de transferencia al dominio y guardar aparte produce dos rutas de conversión que acaban divergiendo en cuanto una de ellas se ajusta y la otra no. Una sola cadena, del objeto remoto a la entidad y de la entidad al dominio, mantiene una única definición de qué significa cada campo.

Y conviene registrar de forma agregada, no por elemento. Un descarte suelto no dice nada; que el diez por ciento de los productos de una categoría se estén descartando desde el martes dice que alguien cambió algo en el servidor y que hay usuarios viendo un catálogo incompleto sin que ninguna alerta se haya disparado. Ese contador es la única manera realista de enterarse de un incumplimiento silencioso del contrato antes de que lo reporte un cliente.

La colocación del mapeador también importa. Vive en el módulo de datos, es una función pura y no depende de nada más que del objeto de transferencia y del modelo, lo que permite probarlo con respuestas reales guardadas como texto, incluidas las malformadas que provocaron un incidente. Ese conjunto de casos acaba siendo la documentación más fiable que existe del contrato con el servidor, porque describe lo que el servidor hizo de verdad y no lo que la documentación decía que haría.

Que nada de red suba

La regla final es la más fácil de enunciar y la más fácil de incumplir: por encima del repositorio no puede aparecer ni un tipo, ni una excepción, ni un código de la capa de red. Las excepciones son el vehículo habitual de la filtración, porque viajan sin declararse y atraviesan capas sin que nadie las mencione en una firma.

override suspend fun refrescar(categoria: CategoriaId): Result<Unit> = try {
    val dtos = api.productos(categoria.valor)
    dao.reemplazarCategoria(categoria.valor, dtos.mapNotNull { it.aEntidadONulo() })
    Result.success(Unit)
} catch (e: IOException) {
    Result.failure(FalloDeDatos.SinConexion)
} catch (e: HttpException) {
    Result.failure(if (e.code() == 401) FalloDeDatos.SesionCaducada else FalloDeDatos.Servidor)
}

Ese bloque es la aduana en su forma más literal. Dentro se nombran tipos de la librería HTTP y números de estado; fuera solo salen valores de un vocabulario propio que una pantalla puede entender. La traducción del código 401 a una sesión caducada es la que ilustra mejor el punto: es una decisión que exige conocer el protocolo, y por eso pertenece aquí, y su resultado es un concepto de negocio que cualquier capa puede consumir sin saber que existe HTTP.

⚠️
La cancelacion no es un fallo

Al capturar excepciones alrededor de una llamada suspendida hay una trampa clásica: capturar la excepción de cancelación y convertirla en un error de datos. Eso rompe la concurrencia estructurada, porque la corrutina cancelada deja de propagar su cancelación y el sistema cree que sigue viva. La captura debe ser selectiva por tipo, como en el ejemplo, y si se recurre a una captura amplia hay que relanzar explícitamente la cancelación antes de tratar el resto. Un flujo de errores por lo demás impecable puede quedar arruinado por esta única omisión.

El objeto de transferencia existe porque el contrato remoto no es tuyo

La objeción práctica contra los objetos de transferencia se formula siempre igual: son idénticos al modelo de dominio, escribirlos dos veces es ceremonia y el mapeador es código sin valor. La objeción es correcta durante las primeras semanas de un proyecto y deja de serlo el día en que el servidor cambia. Lo que ese día revela es que la duplicación aparente no era redundancia sino una diferencia de propiedad: el objeto de transferencia lo define un equipo ajeno y el modelo lo define el propio, y colapsarlos en un solo tipo es ceder la definición de los tipos internos a alguien que no conoce la aplicación ni asume sus consecuencias. Cuando ambos son el mismo tipo, un campo que el servidor hace opcional convierte en opcional un campo de negocio que era obligatorio, y ese cambio se propaga a cada uso, cada rama y cada test. Cuando son tipos separados, ese mismo cambio se detiene en una función de quince líneas donde alguien decide, con criterio y en un único lugar, qué significa la ausencia. Hay además una asimetría de tiempo que suele pasarse por alto: el objeto de transferencia cambia al ritmo del servidor, que puede ser semanal, mientras que el modelo de dominio cambia al ritmo del negocio, que es mucho más lento. Unir dos cosas que evolucionan a velocidades distintas obliga a la lenta a moverse al ritmo de la rápida, y eso es una definición razonable de deuda técnica. Por eso el mapeador no es código sin valor: es el amortiguador que absorbe la diferencia de velocidad entre dos mundos, y su ausencia no elimina el trabajo, solo lo reparte por todo el proyecto en forma de condiciones defensivas escritas por gente que ya no recordaba por qué hacían falta.

⚔️ Blinda tu frontera remota
  1. Revisa tus objetos de transferencia y separa los que hoy comparten tipo con el dominio. Documenta qué campo del servidor te obligó a hacerlo.
  2. Convierte en opcionales los campos de un objeto de transferencia y traslada la validación al mapeador, decidiendo caso por caso entre descarte, fallo y valor de reserva.
  3. Busca en todo el proyecto referencias a tipos o excepciones de la librería HTTP fuera del módulo de datos y elimínalas traduciéndolas a tu vocabulario de fallos.
  4. Guarda tres respuestas reales, una correcta, una con campos ausentes y una malformada, y escribe pruebas del mapeador sobre las tres.
  5. Audita cada captura amplia de excepciones en tus repositorios y comprueba que la cancelación se relanza en lugar de convertirse en un error.