wandres.dev
NIVEL DIOS: SÍNTESIS · construir y sostener

Arquitectar una app real de principio a fin

El salto que separa a quien sabe Android de quien entrega productos no está en ninguna API concreta, sino en la capacidad de dibujar el plano completo antes de escribir la primera línea: qué módulos existen y por qué, dónde vive la identidad del usuario y qué pasa cuando se va, cómo se resuelve un conflicto de escritura cuando el dispositivo estuvo tres días sin red, y qué significa exactamente que la misma aplicación funcione en un móvil plegado, en una tablet y en una pantalla externa. Esta lección construye ese plano de principio a fin, con las decisiones ordenadas por coste de reversión y con las trampas nombradas en el momento en que se toman, no cuando ya es tarde.

⏱ 30 min

Un producto real es un ejercicio de decisiones irreversibles tomadas en el peor momento posible, que es el primero, cuando todavía no se sabe casi nada. La estructura de módulos, la forma de la clave primaria, el modelo de sesión y la política de sincronización se eligen en la primera semana y se pagan durante años, mientras que las decisiones que los equipos debaten con pasión —qué biblioteca de inyección, qué cliente HTTP, qué convención de nombres— son casi todas reversibles en una tarde. La habilidad que define a un arquitecto no es tener opinión sobre lo segundo, sino reconocer lo primero y darle el tiempo que merece. Esta lección recorre el plano completo de una aplicación con usuarios reales, datos que cambian, funcionamiento sin conexión y varias formas de pantalla, y lo hace en el orden en que las decisiones se vuelven caras: primero la frontera de los módulos, luego la identidad, luego el motor de sincronización y por último la adaptación a la forma del dispositivo. Ese orden no es arbitrario. Es exactamente el orden inverso al que la mayoría de los equipos sigue, y esa inversión explica una fracción sorprendente de las reescrituras que se anuncian como necesarias en el segundo año de vida de un producto.

🎯 Al terminar esta lección sabrás
  • Diseñar la frontera de módulos de una aplicación y justificar cada corte por una razón medible.
  • Modelar la identidad del usuario, la sesión y el cierre de sesión sin dejar datos huérfanos ni fugas entre cuentas.
  • Construir un motor de sincronización con cola de salida, resolución de conflictos y reintentos que sobreviva a días sin red.
  • Convertir una aplicación de móvil en una aplicación multiforma sin duplicar la lógica ni fragmentar el estado.

El plano: módulos, capas y la dirección de las dependencias

El primer corte es horizontal y ya lo conocemos: interfaz, dominio y datos, con las dependencias apuntando siempre hacia dentro. El segundo corte es vertical, por funcionalidad, y es el que de verdad decide si el proyecto escalará. Un módulo por pantalla produce cientos de módulos y una configuración de compilación inmanejable; un módulo único produce compilaciones de diez minutos y un grafo de dependencias donde todo toca todo. El punto medio defendible es el módulo por dominio funcional, con una interfaz pública mínima y una implementación privada, de modo que el compilador impida el atajo antes de que alguien lo escriba.

// :feature:pedidos expone solo esto al resto del mundo.
interface PedidosEntrada {
    val rutaInicial: String
    fun registrarGrafo(builder: NavGraphBuilder, navegar: (Destino) -> Unit)
}

// La implementacion, las pantallas y los ViewModel no salen del modulo.
internal class PedidosEntradaImpl @Inject constructor() : PedidosEntrada { /* ... */ }

Antes incluso de ese corte hay una decisión de plano que condiciona todas las demás y que conviene tomar con los ojos abiertos: cuánto del producto vivirá en código compartido entre plataformas. La respuesta razonable rara vez es todo o nada. Compartir el dominio y los datos —modelos, reglas, repositorios, cliente de red— y mantener nativa cada interfaz es el reparto que más equipos sostienen a largo plazo, porque la lógica cambia poco y la interfaz es exactamente donde cada plataforma se distingue. Lo importante no es qué opción se elige, sino que la frontera quede dibujada el primer día, porque desplazarla más tarde implica mover justamente el código que más pruebas tiene.

La prueba de que un corte es bueno no es estética sino empírica: un corte correcto reduce el tiempo de compilación incremental cuando se toca una funcionalidad, permite ejecutar sus pruebas de forma aislada y hace que un desarrollador nuevo pueda cambiar algo sin leer el resto del repositorio. Si un módulo no consigue ninguna de las tres cosas, no es un módulo: es una carpeta con burocracia de Gradle alrededor. Conviene además decidir pronto la regla de dependencias entre funcionalidades, porque la respuesta natural —que dos funcionalidades no se conozcan nunca— exige un mecanismo de navegación y de datos compartidos que hay que construir desde el principio, con un módulo de dominio común y un contrato de navegación por rutas y no por tipos concretos.

💡
El módulo que casi nadie crea y siempre hace falta

Además de las capas y las funcionalidades, todo proyecto que llega a producción termina necesitando un módulo de infraestructura transversal: identificadores de sesión, reloj inyectable, generación de identificadores, registro de eventos y banderas de funcionalidad. Crearlo el primer día cuesta una hora; extraerlo el segundo año, cuando esas cinco cosas están esparcidas por dieciocho sitios y tres de ellas usan System.currentTimeMillis directamente, cuesta semanas y hace imposible cualquier prueba que dependa del tiempo.

Identidad, sesión y el momento de irse

La identidad es la decisión que más silenciosamente contamina un producto. La pregunta que hay que responder antes de escribir la primera tabla es si el dato pertenece al dispositivo o a la cuenta, porque de ahí se derivan el esquema, la sincronización y el borrado. Un ajuste de tema pertenece al dispositivo. Un borrador sin enviar pertenece a la cuenta aunque todavía no exista en el servidor. Y todo lo que pertenece a la cuenta necesita llevar la cuenta consigo, no en una variable global sino en la propia clave, porque la alternativa —una base de datos por sesión que se borra al salir— parece más simple y se rompe en cuanto el producto admite dos cuentas a la vez.

@Entity(
    tableName = "pedido",
    primaryKeys = ["cuentaId", "id"],
    indices = [Index("cuentaId")],
)
data class PedidoEntity(
    val cuentaId: String,
    val id: String,
    val actualizadoEn: Long,
    val pendienteDeEnvio: Boolean,
)

El cierre de sesión es la operación más subestimada de cualquier aplicación con usuarios y la que produce los incidentes de privacidad más caros. Cerrar sesión no es borrar el testigo de autenticación: es garantizar que ningún dato de la cuenta anterior es observable después, lo que incluye la base local, las preferencias, la caché de imágenes, los trabajos encolados, las notificaciones ya mostradas, el registro de mensajes de inserción y cualquier estado que un ViewModel vivo esté sosteniendo en memoria. La forma robusta de conseguirlo es tratar la sesión como el ámbito raíz del que cuelga todo lo demás, de modo que cerrarla destruya el grafo entero por construcción en lugar de por una lista de tareas de limpieza que alguien tiene que acordarse de ampliar.

⚠️
El identificador que genera el servidor es una trampa en modo sin conexión

Si las entidades solo tienen identificador cuando el servidor responde, es imposible crear nada sin red, imposible referenciarlo desde otra tabla y imposible reintentar sin duplicar. La solución es que el cliente genere un identificador único al crear, lo envíe como clave de idempotencia y conserve la correspondencia si el servidor devuelve otro distinto. Esta decisión hay que tomarla el primer día porque cambiar el tipo de una clave primaria con datos en producción es una de las migraciones más desagradables que existen.

El motor de sincronización

Aquí es donde una aplicación deja de ser una interfaz sobre una API y se convierte en un sistema distribuido pequeño, con todas las consecuencias. El patrón que resiste es la cola de salida: la interfaz escribe siempre en la base local y encola una intención de envío; un trabajador procesa esa cola cuando hay red; y la pantalla refleja el estado de cada elemento —confirmado, pendiente, en conflicto— porque mentirle al usuario sobre lo que ya está guardado en el servidor es la forma más rápida de perder su confianza.

suspend fun confirmarPedido(id: String) {
    baseDatos.withTransaction {
        pedidoDao.marcarPendiente(id)
        salidaDao.encolar(Operacion(tipo = CONFIRMAR, entidadId = id, intentos = 0))
    }
    workManager.enqueueUniqueWork(
        "sincronizar-salida",
        ExistingWorkPolicy.KEEP,
        OneTimeWorkRequestBuilder<SalidaWorker>()
            .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build(),
    )
}

Tres propiedades hacen que ese motor sea correcto y las tres se olvidan por separado. La idempotencia, para que un reintento tras una respuesta perdida no duplique nada, y se consigue con una clave generada por el cliente y respetada por el servidor. El orden, porque dos operaciones sobre la misma entidad no son conmutativas y enviarlas en paralelo produce estados imposibles; la cola debe serializar por entidad aunque paralelice entre entidades distintas. Y la terminación, porque una operación que falla de forma permanente —el recurso ya no existe, el servidor la rechaza siempre— tiene que salir de la cola con un estado visible en lugar de reintentarse indefinidamente consumiendo batería y ocultando el problema.

La resolución de conflictos exige una decisión explícita por tipo de dato y no admite una política única. Para datos que solo su dueño modifica, la última escritura gana y basta con marcas de tiempo del servidor. Para contadores y agregados, la operación conmutativa gana: enviar el incremento en lugar del total elimina el conflicto de raíz. Para documentos editables por varios, no hay atajo honesto: o se fusiona en el servidor con una versión conocida y se rechaza lo que no coincide, o se le pide al usuario que decida. Lo que nunca funciona es no decidir, porque la política implícita —la última escritura que llegó al servidor gana— borra trabajo del usuario en silencio, y ese es el fallo que ninguna métrica captura y todos los usuarios recuerdan.

flowchart TD
A[Accion del usuario] --> B[Transaccion local]
B --> C[Entidad marcada como pendiente]
B --> D[Operacion encolada en la salida]
C --> E[La pantalla muestra pendiente]
D --> F{Hay red y bateria}
F -->|no| G[Espera con backoff exponencial]
G --> F
F -->|si| H[Envio con clave de idempotencia]
H --> I{Respuesta del servidor}
I -->|exito| J[Marcar confirmado y vaciar de la salida]
I -->|conflicto| K[Aplicar politica del tipo de dato]
I -->|error temporal| G
K --> L[Resolver o pedir decision al usuario]

Una sola aplicación en muchas pantallas

La última capa del plano es la forma del dispositivo, y conviene abordarla al final no porque sea menos importante sino porque solo se resuelve bien si las tres anteriores están limpias. Una aplicación se adapta a una tablet, a un plegable o a una pantalla externa sin dolor cuando su estado ya está izado fuera de la interfaz y su navegación ya es un dato observable; y se convierte en un infierno de ramas cuando cada pantalla guarda su propio estado, porque entonces cambiar de disposición significa reconstruir componentes distintos y el estado se pierde en la transición.

@Composable
fun PedidosPantalla(estado: PedidosUiState, alSeleccionar: (String) -> Unit) {
    val ancho = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass

    // Misma fuente de estado; solo cambia como se coloca en pantalla.
    if (ancho == WindowWidthSizeClass.COMPACT) {
        if (estado.seleccionado == null) Lista(estado, alSeleccionar) else Detalle(estado)
    } else {
        Row {
            Lista(estado, alSeleccionar, Modifier.weight(1f))
            Detalle(estado, Modifier.weight(2f))
        }
    }
}

La regla operativa es que la clase de tamaño de ventana decide la disposición y nunca el comportamiento. Lista y detalle son el mismo estado en los dos casos: en pantalla compacta se muestra uno de los dos, en pantalla expandida se muestran ambos, pero el elemento seleccionado es la misma variable y el botón de retroceso opera sobre el mismo grafo. Cuando el usuario pliega el dispositivo con un detalle abierto, no debe pasar nada notable: la disposición cambia y el estado permanece, porque el estado nunca estuvo dentro del componente que desapareció.

Conviene extender la misma idea más allá de la forma de la pantalla, porque el producto real tiene más superficies de las que el equipo suele contar: un widget en la pantalla de inicio, una notificación con acciones, un enlace profundo que entra desde fuera, una entrada por asistente de voz y, cada vez más, una pantalla de coche o un reloj. Todas ellas son puntos de entrada al mismo estado, y todas fallan de la misma manera cuando la lógica vive dentro de la pantalla en lugar de detrás de ella. La prueba que decide si una arquitectura está lista para crecer en superficies es concreta y se puede hacer hoy: si añadir un widget que muestra el mismo dato obliga a duplicar una sola línea de lógica de negocio, la frontera está mal puesta.

Y queda una superficie que casi nadie considera arquitectura y lo es: la accesibilidad. Un árbol de semántica correcto, un orden de recorrido que tenga sentido y unas acciones etiquetadas no son un añadido de la capa visual, son una segunda representación del mismo estado, y solo salen bien si ese estado es explícito y exhaustivo. Los productos que resuelven bien lo multiforma resuelven bien la accesibilidad casi por accidente, porque ambas cosas dependen de la misma propiedad: que la interfaz sea una proyección de un estado que existe fuera de ella.

🔱

Cortes que se pagan

Módulos, claves primarias, modelo de sesión y política de conflictos. Cuatro decisiones de la primera semana que gobiernan los cinco años siguientes.

🧭

Navegación como dato

Si el destino actual es estado observable y no una pila oculta dentro de un componente, la multiforma y la restauración salen casi gratis.

📤

Cola de salida

Escribir local, encolar, reintentar con retroceso exponencial y clave de idempotencia. Es el corazón de cualquier producto que funcione sin red.

🚪

Salida limpia

Cerrar sesión destruye un ámbito completo, no ejecuta una lista de limpiezas. Lo segundo siempre se queda corto y siempre se descubre tarde.

La arquitectura es la forma de las decisiones que no quieres volver a tomar

Vale la pena preguntarse qué es realmente la arquitectura de una aplicación, porque la respuesta habitual —un diagrama de capas— es la menos útil de todas. Una arquitectura es el conjunto de decisiones cuyo coste de reversión es alto, y su valor no está en ser correcta sino en ser explícita. Esto tiene una consecuencia incómoda para quien busca la estructura ideal: no existe una arquitectura mejor en abstracto, existe una relación entre lo que un producto va a necesitar cambiar y lo que su estructura hace barato cambiar. Un proyecto cuya hipótesis de negocio se mueve cada mes necesita optimizar el coste de tirar pantallas enteras a la basura, y llenarlo de capas de abstracción para desacoplar una fuente de datos que nunca cambiará es puro despilfarro disfrazado de rigor. Un proyecto que llevará ocho años en producción con requisitos regulatorios necesita exactamente lo contrario. La pregunta correcta nunca es si conviene aplicar tal patrón, sino qué cambio futuro estoy comprando con esta complejidad presente y con qué probabilidad ocurrirá. Hay un segundo aspecto que la experiencia enseña tarde: las decisiones estructurales tienen un componente organizativo que ningún diagrama muestra. La frontera de un módulo es también la frontera de una conversación, y un corte que separa dos cosas que el mismo equipo modifica siempre a la vez no reduce complejidad, la traslada a reuniones de coordinación. Al revés, un corte que separa lo que dos equipos distintos tocan a ritmos distintos elimina fricción real todos los días. Por eso los planos que funcionan se parecen a la estructura de quien los mantiene, y por eso copiar la arquitectura de una empresa de tres mil ingenieros en un equipo de cuatro personas produce siempre el mismo resultado: toda la ceremonia y ninguno de los beneficios. La madurez consiste en saber que la respuesta empieza por depende, y en ser capaz de enunciar exactamente de qué depende, con qué evidencia y bajo qué condiciones habría que revisarla. Escribir esa justificación al lado del código, y fecharla, es la práctica más rentable y menos extendida de todo el oficio.

⚔️ Dibuja el plano completo y ponlo a prueba
  1. Dibuja el grafo de módulos de un producto real con cuatro funcionalidades y justifica cada corte con una razón medible en tiempo de compilación o de prueba.
  2. Modela la identidad con dos cuentas simultáneas y demuestra que ningún dato de una es observable desde la otra.
  3. Implementa la cola de salida completa, desconecta la red tres días simulados y verifica que ninguna operación se pierde ni se duplica.
  4. Elige una política de conflictos distinta para tres tipos de dato y escribe la prueba que demuestra cada una.
  5. Ejecuta la aplicación en móvil, plegable y tablet, cambia de forma con un detalle abierto y comprueba que no se pierde ni un carácter del formulario.