Single source of truth: la estrategia offline-first
La fuente única de verdad no consiste en tener una base de datos, sino en aceptar una asimetría radical: la interfaz observa siempre lo local y la red nunca le habla directamente, se limita a escribir en la base. Esta lección desarrolla esa estrategia hasta sus consecuencias: cómo se separa el camino de lectura del camino de escritura, por qué el estado de sincronización es un dato distinto del contenido, cómo se decide la frescura sin ensuciar el dominio, y qué forma toma una escritura del usuario cuando debe aplicarse antes de que el servidor la confirme.
Casi todas las aplicaciones que muestran datos remotos empiezan escritas al revés, y lo hacen por un motivo comprensible: la manera intuitiva de programar una pantalla es pedir los datos, esperar y pintarlos. Esa intuición coloca la red en el camino de lectura, y con ella entran todas sus propiedades indeseables: la pantalla no puede mostrar nada sin conexión, cada visita repite una petición, dos pantallas que piden lo mismo obtienen respuestas distintas y el usuario ve una rueda girando cada vez que vuelve atrás. La estrategia contraria parte de una sola decisión y de ella se deduce todo lo demás: la interfaz observa exclusivamente la base de datos local, y la red no tiene permiso para hablar con la interfaz, solo para escribir en la base. Lo que parece un rodeo, hacer pasar por disco un dato que ya está en memoria, resuelve simultáneamente el modo sin conexión, la coherencia entre pantallas, la persistencia entre sesiones y la eliminación de las peticiones duplicadas, y lo hace sin ninguna coordinación explícita por parte de quien programa.
- Separar el camino de lectura del camino de escritura y justificar por qué nunca deben cruzarse.
- Modelar el estado de sincronización como un dato independiente del contenido mostrado.
- Decidir la política de frescura dentro de la capa de datos, sin que suba a la pantalla.
- Diseñar escrituras del usuario que se apliquen antes de la confirmación remota y se reconcilien después.
Dos caminos que no se cruzan
La regla se enuncia en dos frases y cambia por completo la forma del código. Leer significa observar la base de datos. Refrescar significa pedir a la red y escribir en la base de datos. Ninguna operación de refresco devuelve datos a quien la invocó, porque los datos llegarán solos por el otro camino.
class CatalogoRepositoryImpl(
private val dao: CatalogoDao,
private val api: CatalogoApi,
) : CatalogoRepository {
override fun observarProductos(categoria: CategoriaId): Flow<List<Producto>> =
dao.observar(categoria.valor).map { it.map(ProductoEntity::aDominio) }
override suspend fun refrescar(categoria: CategoriaId): Result<Unit> = runCatching {
val remotos = api.productos(categoria.valor).mapNotNull { it.aEntidadONulo() }
dao.reemplazarCategoria(categoria.valor, remotos)
}.mapError()
}
Que refrescar devuelva Result<Unit> en lugar de la lista no es una economía sintáctica: es la afirmación de que existe un único camino por el que los datos llegan a la pantalla. Si devolviera la lista, alguien acabaría usándola directamente en un caso particular, y a partir de ese momento habría dos rutas por las que el contenido puede aparecer, con la posibilidad de que discrepen. El valor devuelto sirve únicamente para informar de si la sincronización funcionó, que es una información distinta del contenido.
flowchart LR UI[Interfaz] -->|observa| DB[Base de datos local] UI -->|solicita refresco| REPO[Repositorio] REPO -->|pide| NET[Servidor] NET -->|escribe| DB DB -->|emite cambios| UI style DB fill:#a6e3a1,color:#11111b style NET fill:#f38ba8,color:#11111b
Esta separación tiene además una lectura conocida: es una segregación entre consultas y comandos aplicada al interior de una aplicación cliente. Las consultas son suscripciones que no modifican nada y siempre responden; los comandos modifican y devuelven únicamente el veredicto de si funcionaron. Reconocer el patrón ayuda porque trae consigo sus reglas: un comando nunca debe devolver la vista resultante, y una consulta nunca debe provocar efectos. En cuanto una de esas dos reglas se relaja por comodidad, la propiedad que hacía valiosa la arquitectura desaparece sin que nada avise.
Repara en que ninguna flecha va del servidor a la interfaz. Esa ausencia es la estrategia entera. Mientras no exista, la aplicación funciona sin conexión por construcción y no por un modo especial que alguien tuvo que programar, porque la pantalla nunca dependió de la red para pintar: dependía del disco, y el disco siempre responde.
El contenido y la sincronización son datos distintos
Separar los caminos obliga a separar también lo que se muestra. Una pantalla ya no tiene un único estado de carga que precede al contenido: tiene contenido, que puede existir aunque sea viejo, y tiene un estado de sincronización, que describe la salud del segundo camino.
data class CatalogoState(
val productos: List<Producto> = emptyList(),
val sincronizando: Boolean = false,
val fallo: FalloDeDatos? = null,
)
El contraste con el estado sellado de tres casos, cargando, contenido y error, es instructivo. Esa jerarquía es correcta cuando el contenido solo puede existir después de una carga exitosa, que es justo la premisa que la fuente única de verdad elimina: aquí el contenido puede existir mientras se carga y puede seguir existiendo tras un error. Forzar los tres casos excluyentes obliga entonces a elegir entre mentir, ocultando datos que sí están, o inventar casos combinados que reproducen con peor nombre la estructura de campos independientes.
Esta forma habilita comportamientos que la carga monolítica hacía imposibles. Se puede mostrar el catálogo guardado ayer mientras se refresca en segundo plano, con un indicador discreto en lugar de una pantalla vacía. Se puede fallar el refresco sin borrar lo que el usuario ya estaba leyendo, mostrando un aviso que no destruye contexto. Y se puede distinguir el caso genuinamente vacío, cuando no hay datos locales ni conexión, del caso en que hay datos viejos y el refresco falló, que exigen mensajes muy distintos.
Contenido
Viene de la base de datos. Existe o no existe, y su antigüedad es un atributo, no un estado de la interfaz.
Sincronizacion
Describe si hay una operación remota en curso. Nunca oculta el contenido, solo lo acompaña.
Fallo
Se refiere al último intento de refresco, no al contenido. Un fallo con datos en pantalla es un aviso, sin datos es un estado vacío.
Antiguedad
Cuándo se sincronizó por última vez. Permite explicar al usuario qué está viendo en lugar de mentirle con una lista silenciosa.
La frescura se decide abajo
Hay un caso límite que conviene resolver antes de que aparezca en producción: el arranque en frío sin datos locales y sin conexión. Ahí no hay contenido viejo que mostrar y el refresco falla, de modo que la pantalla debe distinguir ese vacío del vacío legítimo de una categoría sin productos. El estado con tres campos independientes lo permite sin esfuerzo, porque contenido ausente con fallo presente y contenido ausente sin fallo son combinaciones distintas y ambas expresables.
Queda la pregunta de cuándo refrescar, y la respuesta correcta es que la pantalla no debería tener que saberlo. Una pantalla pide refresco cuando el usuario lo solicita explícitamente o cuando aparece; el repositorio decide si esa solicitud se traduce en una petición real o se ignora porque los datos son suficientemente recientes.
private val ventana = 5.minutes
override suspend fun refrescarSiHaceFalta(categoria: CategoriaId): Result<Unit> {
val ultima = dao.ultimaSincronizacion(categoria.valor) ?: 0L
val vencida = reloj.ahora() - ultima > ventana.inWholeMilliseconds
return if (vencida) refrescar(categoria) else Result.success(Unit)
}
Conviene notar que el reloj entra por el constructor y no se consulta directamente al sistema. Una política de frescura basada en la hora del dispositivo es lógica que decide si habrá petición de red, y por tanto merece pruebas deterministas; con el reloj inyectado, un test puede avanzar el tiempo y comprobar que la segunda llamada sí refresca. Con la hora leída desde dentro, ese mismo test depende de esperas reales y acaba siendo intermitente, que es la peor clase de prueba porque enseña al equipo a ignorar los fallos.
La marca de tiempo vive en la base de datos, junto a los datos que describe, y desaparece en el mapeo hacia el dominio. Así la política de frescura queda encapsulada: cambiar la ventana de cinco minutos a una hora no toca ningún ViewModel, ninguna función de composición y ningún test de presentación. Colocar esa condición en la pantalla, en cambio, la reparte por tantas pantallas como consuman el dato, con la garantía estadística de que una de ellas usará un valor distinto.
La consecuencia práctica más apreciada de esta arquitectura aparece cuando varias pantallas piden refrescar lo mismo a la vez. Como el refresco no devuelve datos, se puede desduplicar libremente: basta con guardar el trabajo en curso por clave y devolver el mismo Deferred a quien llegue mientras esté vivo. Nadie nota la diferencia, porque todos reciben el resultado por el flujo de la base de datos. En una arquitectura donde la llamada devuelve el contenido, esa desduplicación es mucho más delicada, porque hay que repartir el mismo valor entre consumidores que esperaban respuestas independientes.
Escrituras del usuario y reconciliación
Falta el caso en que el usuario no lee sino que modifica. La regla se mantiene y se vuelve más exigente: la escritura se aplica primero en local, para que la interfaz reaccione de inmediato observando su propia fuente, y después se propaga al servidor.
override suspend fun marcarFavorito(id: ProductoId, favorito: Boolean): Result<Unit> {
dao.actualizarFavorito(id.valor, favorito, pendiente = true)
return runCatching {
api.favorito(id.valor, favorito)
dao.marcarSincronizado(id.valor)
}.onFailure {
dao.actualizarFavorito(id.valor, !favorito, pendiente = false)
}.mapError()
}
El caso realmente exigente aparece cuando la escritura debe sobrevivir a la falta de conexión en lugar de revertirse. Entonces la bandera de pendiente deja de ser un adorno y se convierte en una cola de operaciones almacenada en la propia base de datos, que un trabajo en segundo plano vacía cuando la red vuelve. Esa cola es la versión completa de la estrategia y trae consigo la única pregunta genuinamente difícil de todo el nivel, la de qué ocurre si el servidor tiene entretanto una versión distinta del mismo dato; pero incluso ahí la estructura se mantiene intacta, porque la interfaz sigue observando lo local y la resolución del conflicto sigue siendo asunto exclusivo de la capa de datos.
Hay tres piezas dignas de atención. La bandera de pendiente es un dato local que registra que la verdad todavía no está confirmada, y permite mostrar el elemento con un matiz visual o reintentar más tarde desde un trabajo en segundo plano. La reversión en caso de fallo devuelve el estado al valor anterior, con la honestidad de admitir que la operación no ocurrió. Y todo el procedimiento sucede dentro del repositorio, de modo que la pantalla nunca supo que hubo una fase optimista: observó la base y la base cambió dos veces.
La escritura optimista es apropiada cuando el resultado es predecible y el fallo es raro y reversible sin coste, como marcar un favorito o cambiar un nombre. Deja de serlo cuando el servidor puede rechazar la operación por reglas que el cliente desconoce, cuando la operación mueve dinero o cuando la reversión sería confusa para el usuario. En esos casos la escritura debe esperar la confirmación, y el estado intermedio debe ser visible como tal en lugar de fingir un éxito que aún no existe. Confundir ambos escenarios produce el peor fallo posible en una interfaz: el que da por hecho algo que nunca sucedió.
Lo que se discute bajo el nombre de fuente única de verdad no es un patrón de almacenamiento sino una decisión de autoridad, y por eso sus consecuencias exceden con mucho lo que aparenta ser un rodeo por disco. En la arquitectura ingenua, la autoridad sobre lo que el usuario ve la tiene la última respuesta que llegó, y como pueden llegar varias por caminos distintos y en orden impredecible, el estado de la aplicación es en realidad el resultado de una carrera. Nada de eso está escrito en el código: no hay ninguna línea que diga que la lista se decidirá por quien conteste antes, y sin embargo así funciona, y de ahí salen los fallos que solo se reproducen con mala red. Al declarar que la base de datos es la única autoridad, esa carrera se elimina de raíz, porque la concurrencia entre respuestas se resuelve donde existen herramientas para resolverla, que son las transacciones, y no en la memoria de un ViewModel que nunca fue diseñado para arbitrar. Hay una segunda consecuencia, más profunda todavía, sobre lo que significa el modo sin conexión. En la aproximación tradicional es una funcionalidad: alguien la planifica, la implementa con una caché aparte y la mantiene sincronizada con el camino normal, lo que en la práctica significa que existen dos comportamientos distintos que hay que probar por separado y que divergen en cuanto nadie mira. En la fuente única de verdad no existe tal funcionalidad, porque no hay dos caminos que puedan divergir: hay uno solo, y estar sin conexión significa simplemente que el camino de escritura está detenido mientras el de lectura sigue funcionando como siempre. Esa es la marca de un buen diseño arquitectónico y conviene reconocerla cuando aparece: no añade una capacidad, sino que elimina la distinción que hacía falta capacidad aparte. Lo que antes eran dos modos y una sincronización entre ellos pasa a ser un solo modo cuya salud varía, y con la distinción desaparecen todos los fallos que vivían exactamente en la costura entre ambos.
- Elige una pantalla que hoy pida datos a la red y los muestre directamente. Redirígela para que observe la base de datos y convierte la llamada remota en una escritura local.
- Divide su estado de carga en contenido, sincronización y fallo, y define qué se muestra en las cuatro combinaciones posibles de datos presentes o ausentes con fallo o sin él.
- Añade una marca de última sincronización en la tabla, implementa una ventana de frescura en el repositorio y verifica que ningún
ViewModella menciona. - Desduplica los refrescos concurrentes por clave y comprueba con un registro que cinco solicitudes simultáneas producen una única petición.
- Implementa una escritura optimista con bandera de pendiente y reversión, y describe por escrito qué operaciones de tu aplicación no deberían usarla y por qué.