Arquitectar una app completa: capas, módulos, navegación y testing
El plano entero de una aplicación Android construida sobre MVI, desde la dirección de las dependencias hasta la última prueba automatizada. Esta lección fija las tres capas y por qué la de dominio no conoce a nadie, traduce esas capas a módulos Gradle explicando qué compra realmente cada frontera y qué cuesta, resuelve la navegación como un problema de propiedad del estado y no de rutas, y termina con la pirámide de pruebas que MVI hace posible: un reductor que se comprueba con una tabla, un container que se comprueba con una traza y una interfaz que apenas necesita instrumentación.
Una arquitectura no es un diagrama de cajas: es un conjunto de restricciones sobre quién puede importar a quién, y la calidad de la arquitectura se mide por cuántos errores hace imposibles en vez de meramente desaconsejables. Este es el punto del track donde las piezas dejan de ser una pantalla y pasan a ser una aplicación con veinte pantallas, siete equipos, un ciclo de compilación que duele y una base de código que sobrevivirá a sus autores. Lo que sigue es el plano completo, escrito con la honestidad de decir también qué cuesta cada decisión, porque una arquitectura que solo enumera ventajas es una arquitectura que nadie ha llevado a producción.
- Fijar las tres capas y justificar por qué la dependencia apunta siempre hacia dominio.
- Traducir las capas a módulos Gradle sabiendo qué compra cada frontera y qué peaje cobra.
- Resolver la navegación como una cuestión de propiedad del estado y no de sintaxis de rutas.
- Construir la pirámide de pruebas que la pureza del reductor hace barata.
Las capas y la dirección de las flechas
Tres capas bastan y sobran para casi cualquier aplicación: datos, dominio y presentación. Lo que importa no es su número sino la dirección de las dependencias, que es siempre la misma y no admite excepciones cómodas: presentación conoce dominio, datos conoce dominio, y dominio no conoce a nadie.
La capa de dominio contiene los modelos del negocio, las reglas que no dependen de dónde vengan los datos y las interfaces de los repositorios. Es Kotlin puro: sin Android, sin Retrofit, sin Room, sin Compose. Esa pobreza es su valor. Un dominio que no importa nada se compila en un segundo, se prueba en la JVM sin emulador y se puede llevar a otra plataforma sin tocarlo.
La capa de datos implementa esas interfaces. Aquí viven la red, la base de datos, la caché, el mapeo entre los modelos de transporte y los del dominio, y la política de qué se sirve desde disco y qué se pide al servidor. Es la capa que más cambia y la que menos debe filtrar hacia arriba: si un DataException de Retrofit llega a un ViewModel, la frontera está rota.
La capa de presentación es donde vive todo el track: el estado, las intenciones, el reductor, el container, los efectos y los componibles. Depende de dominio, jamás de datos.
flowchart TD P[Presentacion: estado intent reduce container Compose] --> D[Dominio: modelos reglas interfaces de repositorio] DA[Datos: red base de datos cache mapeadores] --> D style D fill:#a6e3a1,color:#11111b style P fill:#89b4fa,color:#11111b style DA fill:#f9e2af,color:#11111b
La inversión de dependencias es lo que permite que la flecha de datos apunte hacia arriba en vez de hacia abajo: el dominio declara qué necesita, datos lo implementa, y el grafo de inyección los une en el arranque. Sin esa inversión, dominio dependería de datos y toda la ventaja se evaporaría.
// dominio: no conoce a nadie
interface PerfilRepository {
suspend fun perfil(id: String): Perfil
fun observarPerfil(id: String): Flow<Perfil>
}
// datos: implementa la interfaz del dominio
class PerfilRepositoryImpl(
private val api: PerfilApi,
private val dao: PerfilDao,
) : PerfilRepository {
override suspend fun perfil(id: String): Perfil =
runCatching { api.perfil(id).also { dao.guardar(it.aEntidad()) } }
.getOrElse { dao.leer(id) ?: throw PerfilNoDisponible(id) }
.aDominio()
override fun observarPerfil(id: String): Flow<Perfil> =
dao.observar(id).map { it.aDominio() }
}
Nótese el mapeo explícito en ambos sentidos. Es código aburrido y repetitivo, y es exactamente lo que impide que un cambio de nombre en un campo del servidor se propague hasta un componible. El día que ese mapeo se sienta como una molestia innecesaria, conviene recordar cuántos incidentes ha evitado sin que nadie llevara la cuenta.
Muchos equipos añaden una clase por operación entre presentación y repositorio. Es útil cuando una operación combina varios repositorios o encapsula una regla real; es ceremonia pura cuando se limita a delegar una llamada. El criterio honesto es preguntarse si la clase contiene alguna decisión: si no la contiene, el ViewModel puede hablar con el repositorio directamente sin que la arquitectura sufra. Lo que no es negociable es la interfaz en dominio, porque ahí es donde se invierte la dependencia.
Los módulos y su peaje
Las capas son una convención; los módulos Gradle son esa convención impuesta por el compilador. La diferencia práctica es enorme: una regla escrita en un documento se rompe en la revisión de código que nadie hizo el viernes, y una regla escrita en un build.gradle.kts rompe la compilación.
La estructura que funciona en la mayoría de aplicaciones medianas separa por característica en la parte alta y por responsabilidad técnica en la baja. Cada característica agrupa su presentación y su dominio propio; los módulos centrales alojan lo compartido.
// settings.gradle.kts — el mapa entero del proyecto en una lectura
include(":app") // ensambla, inyecta y declara la navegacion
include(":core:designsystem") // tema y componentes visuales sin logica
include(":core:common") // resultados, dispatchers, utilidades
include(":core:domain") // Kotlin puro: modelos, reglas, interfaces
include(":core:data") // implementaciones de repositorio
include(":core:network", ":core:database")
include(":feature:perfil") // estado, intents, reducer, container, pantallas
include(":feature:pedidos")
Cada frontera compra tres cosas concretas. Compra compilación incremental, porque tocar una característica no recompila las demás. Compra imposibilidad de atajos, porque :feature:perfil no declara a :feature:pedidos y por tanto no puede importarlo aunque a alguien le venga bien. Y compra claridad de propiedad, porque un módulo tiene dueño y un paquete no.
También cobra un peaje que conviene decir en voz alta: cada módulo nuevo añade configuración, obliga a exponer explícitamente lo que antes era internal accesible y multiplica el trabajo de cualquier refactor transversal. Veinte módulos en una aplicación de cinco pantallas no es arquitectura, es sobrecoste. La regla razonable es modularizar cuando duele algo medible —el tiempo de compilación, los conflictos entre equipos, una dependencia que se coló— y no antes.
Dominio sin Android
Si :core:domain es un módulo Kotlin puro, la imposibilidad de importar Context deja de ser disciplina y pasa a ser un error de compilación.
Característica sin característica
Dos características que necesitan hablarse lo hacen a través de dominio o de navegación, nunca importándose. Si se importan, se han fusionado de hecho.
Sistema de diseño sin lógica
Los componentes visuales no conocen estados de pantalla ni intenciones. Reciben datos y devuelven llamadas; así se reutilizan y se previsualizan.
El módulo app solo ensambla
Contiene el grafo de navegación, la inyección y poco más. Si tiene lógica, esa lógica pertenecía a una característica.
Navegación como propiedad del estado
La pregunta difícil de la navegación no es cómo se declara una ruta, sino quién es dueño del estado durante el trayecto. Casi todos los bugs de navegación son bugs de propiedad mal asignada: un dato que se recalcula porque se perdió, un dato que se queda viejo porque se compartió, una pantalla que vuelve y no recuerda dónde estaba.
El criterio se puede reducir a tres casos. Si el dato pertenece a una sola pantalla, vive en su ViewModel y muere con ella. Si el dato es un identificador que la pantalla siguiente necesita para pedir lo suyo, viaja como argumento de ruta y nada más. Y si el dato pertenece a un flujo de varias pantallas —un formulario largo, un proceso de compra— vive en un ViewModel con ámbito de grafo anidado, no en un singleton ni en un objeto compartido a mano.
// El identificador viaja por la ruta; los datos los pide la pantalla destino
composable(
route = "perfil/{perfilId}",
arguments = listOf(navArgument("perfilId") { type = NavType.StringType }),
) { PerfilRoute() }
// El ViewModel recupera el argumento y sobrevive a la muerte del proceso
class PerfilViewModel(
handle: SavedStateHandle,
private val repo: PerfilRepository,
) : ContainerHost<PerfilState, PerfilEfecto>, ViewModel() {
private val perfilId: String = checkNotNull(handle["perfilId"])
override val container = container<PerfilState, PerfilEfecto>(PerfilState())
}
Pasar identificadores en vez de objetos serializados no es purismo: los argumentos de ruta viajan en un Bundle que sobrevive a la muerte del proceso, y un objeto grande metido ahí es a la vez frágil, propenso a quedarse obsoleto y una vía silenciosa de fuga de datos. El identificador es pequeño, estable y obliga a la pantalla destino a leer la verdad más reciente.
La navegación en sí, en MVI, es un efecto y no un estado: se emite por el canal, se consume en el componente de ruta y se traduce en una llamada al controlador. Con una excepción ya conocida —los trayectos que no pueden perderse— que se modelan como estado con consumo explícito.
Si el componible de contenido recibe un NavController, deja de ser una función del estado y ya no se puede previsualizar ni reutilizar. El patrón que escala es que el componente de ruta reciba callbacks concretos, del estilo de una función que navega al pedido dado su identificador, y que la pantalla los invoque sin saber qué hay detrás. La navegación queda declarada en un único sitio y las pantallas siguen siendo funciones puras de su entrada.
La pirámide que la pureza abarata
El mayor dividendo de todo el track se cobra aquí. Cuando la lógica vive en una función pura, la mayor parte de las pruebas dejan de necesitar Android, y una suite que corre en segundos es una suite que la gente ejecuta.
La base son los tests del reductor: una tabla de estado inicial, intención y estado esperado. Sin dobles, sin dispatchers, sin esperas. Son los más numerosos y los más rápidos, y cubren la mayoría de las reglas de negocio de una pantalla.
El nivel intermedio son los tests del container: aquí sí hay corrutinas, y lo que se comprueba es la secuencia de estados y de efectos que produce una intención, incluidos los caminos de fallo. Orbit ofrece un modo de prueba que permite afirmar esa traza de forma declarativa.
@Test
fun `cargar perfil emite cargando y luego el perfil`() = runTest {
val vm = PerfilViewModel(handle, repoFalso).test(this)
vm.invokeIntent { cargarPerfil("42") }
vm.assert(PerfilState()) {
states(
{ copy(cargando = true) },
{ copy(cargando = false, perfil = perfilDePrueba) },
)
}
}
La cúspide son los tests de interfaz, escasos por diseño y caros por naturaleza. Si la pantalla es una función del estado, muchos de ellos se pueden sustituir por pruebas de captura de pantalla que fijan un estado concreto y comparan el resultado visual, sin emulador ni interacción. Reserva la instrumentación real para los recorridos que cruzan pantallas y para las integraciones con el sistema.
Todo lo anterior parece un catálogo de buenas prácticas y no lo es: es una apuesta, y como toda apuesta puede perderse. Cuando decides que el dominio no conoce a nadie, estás afirmando que las reglas de negocio cambiarán más despacio que la forma de obtener los datos, y por eso pagas mapeadores para aislarlas. Cuando separas por característica en vez de por capa técnica, estás afirmando que los cambios llegarán agrupados por funcionalidad y no por tecnología, y organizas el código para que un cambio típico toque un solo módulo. Cuando pasas identificadores en la ruta en vez de objetos, estás afirmando que la verdad puede cambiar entre pantalla y pantalla y prefieres releerla que arrastrarla. Cada frontera de tu sistema es una predicción sobre el futuro, y su coste —los mapeadores, la ceremonia de los módulos, la interfaz que solo tiene una implementación— es la prima que pagas por ese seguro. De ahí se siguen dos consecuencias que rara vez se enuncian. La primera es que no existe la arquitectura correcta en abstracto: existe la arquitectura cuyas predicciones aciertan en tu contexto, y una aplicación de dos pantallas con un solo servidor y un único desarrollador tiene predicciones tan distintas de una aplicación bancaria con siete equipos que copiar la estructura de la segunda a la primera es puro daño. La segunda es que una arquitectura debe revisarse cuando sus predicciones fallan: si llevas dos años y el dominio ha cambiado más que la capa de datos, tus mapeadores estaban asegurando el riesgo equivocado y conviene decirlo en voz alta en vez de defenderlos por lealtad. Por eso la pregunta que hay que saber responder ante cualquier frontera nueva no es si es una buena práctica, sino esta otra: ¿qué cambio futuro hace barato esta frontera, cuánto me cuesta mientras ese cambio no llega, y qué evidencia tengo de que llegará? Quien sabe responderla diseña sistemas proporcionados. Quien no, acumula capas por miedo y llama a eso arquitectura.
- Dibuja el grafo de dependencias real de tu proyecto y marca en rojo cada flecha que va en dirección contraria a la que esta lección propone.
- Elige una característica y comprueba si podría compilarse sin conocer ninguna otra; si no puede, identifica exactamente qué símbolo lo impide.
- Escribe para tres fronteras de tu sistema qué cambio futuro hacen barato, y qué evidencia tienes de que ese cambio ocurrirá.
- Busca un objeto completo que estés pasando como argumento de navegación y sustitúyelo por un identificador; anota qué se rompió y qué mejoró.
- Mide cuánto tarda tu suite de pruebas y calcula qué porcentaje del tiempo se va en pruebas que necesitan Android; propón un plan concreto para bajar ese porcentaje.