wandres.dev
ARQUITECTURA RECOMENDADA · capas y flujo unidireccional

Las tres capas: UI, dominio y datos

La guía oficial de arquitectura de Android no propone una plantilla de carpetas sino una regla de dependencia, y casi toda la confusión del terreno nace de leer lo segundo como lo primero. Esta lección reconstruye el modelo desde su motivación: qué responsabilidad exacta asume cada capa y qué deja deliberadamente fuera, en qué dirección apuntan las flechas y qué propiedades se pierden cuando alguien las invierte por comodidad, por qué la capa de dominio es opcional y qué significa realmente esa opcionalidad, y cuál es la consecuencia más citada y peor entendida del conjunto: que la interfaz nunca sabe si un dato vino de la red, del disco o de la memoria, y que esa ignorancia deliberada es la propiedad que sostiene todo lo demás.

⏱ 21 min

Hay un malentendido tenaz sobre la arquitectura recomendada de Android y consiste en leerla como una distribución de paquetes. Se crean tres carpetas con los nombres correctos, se reparte entre ellas el código que ya existía y se da el asunto por resuelto, sin que ninguna propiedad del sistema haya cambiado en el proceso. Lo que la guía oficial propone no es un mapa de archivos sino una restricción sobre quién puede nombrar a quién, y esa restricción tiene consecuencias medibles: qué puedes probar sin un dispositivo, qué puedes sustituir sin reescribir la pantalla, qué se rompe el día que un servidor cambia el nombre de un campo. Las capas son el vocabulario; la regla de dependencia es el teorema. Quien copia el vocabulario y omite el teorema se queda con la burocracia de la arquitectura sin ninguno de sus beneficios.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión la responsabilidad de cada capa y lo que cada una deja deliberadamente fuera.
  • Aplicar la regla de dependencia y reconocer las inversiones que la violan sin parecerlo.
  • Decidir con criterio cuándo introducir una capa de dominio y cuándo su ausencia es la respuesta correcta.
  • Explicar por qué la ignorancia de la interfaz sobre el origen de los datos es una propiedad y no una carencia.

Tres responsabilidades antes que tres carpetas

La capa de datos es la dueña de la verdad. Contiene las reglas del negocio que dicen cómo se crea, se almacena y se modifica cada dato, y expone al resto de la aplicación una superficie que oculta por completo de dónde salió cada valor. Sus piezas visibles son los repositorios; sus piezas internas son las fuentes de datos, una por origen físico: la base local, el servicio remoto, las preferencias, el sistema de archivos.

La palabra dueña no es retórica: significa que ninguna otra parte del sistema puede modificar esos datos sin pasar por ella, y que cualquier regla sobre cómo cambian vive dentro de sus fronteras. Una capa que no es dueña de nada es un intermediario, y los intermediarios se pueden eliminar sin pérdida.

La capa de UI es la dueña de lo que se ve. Su trabajo consiste en convertir el estado de la aplicación en algo dibujable y en convertir los gestos del usuario en llamadas hacia abajo. No decide nada sobre la verdad: la muestra. Su frontera es el ViewModel, que traduce entre el lenguaje del dominio y el lenguaje de la pantalla.

La capa de dominio, cuando existe, es la dueña de las reglas que no pertenecen a nadie. Contiene lógica de negocio compleja o compartida por varias pantallas, encapsulada en clases de un solo método público que la guía llama casos de uso. Es la única capa que la propia documentación oficial declara opcional, y ese detalle no es una nota al pie: es una declaración sobre el coste de las abstracciones que no se ganan.

Conviene observar qué queda fuera de esa repartición, porque el hueco es informativo. No hay una capa para la navegación, no hay una capa para la inyección de dependencias y no hay una capa para las utilidades. Esas tres cosas existen en cualquier aplicación real y el modelo las deja deliberadamente sin sitio, porque son infraestructura transversal y no responsabilidades del producto. Intentar encajarlas en el esquema produce discusiones interminables sobre en qué carpeta va un formateador de fechas, que es el síntoma clásico de haber confundido una guía de diseño con un sistema de clasificación exhaustivo.

Hay además un cuarto elemento que no es una capa y sin el cual el esquema no se sostiene: los modelos de dominio. No pertenecen a ninguna de las tres porque las tres los usan, y esa posición transversal es justamente lo que les permite servir de vocabulario común. Un Articulo no es de la interfaz ni de la persistencia: es la palabra con la que ambas hablan sin conocerse.

🖼️

UI

Estado observable, eventos hacia abajo. No conoce la red, ni la base de datos, ni el formato en que llegan los datos.

⚖️

Dominio

Reglas complejas o compartidas. Sin estado propio, sin dependencias de la plataforma. Opcional por diseño.

🗄️

Datos

Repositorios como fuente única de verdad y fuentes concretas por origen. Es la única capa que sabe qué es una petición.

La regla de dependencia y sus violaciones disfrazadas

El enunciado es breve: las capas superiores dependen de las inferiores y jamás al revés. La UI conoce el dominio, el dominio conoce los datos, y ninguna flecha vuelve. La capa de datos no sabe que existe una pantalla, no importa nada del paquete de la interfaz y no cambia cuando el diseño cambia.

Que exista una flecha punteada en el diagrama que salta el dominio no es una excepción tolerada a regañadientes: es parte del modelo oficial. Lo importante es que ese atajo respeta la dirección. Saltarse una capa hacia abajo es legítimo; ir hacia arriba, aunque sea un solo nivel, no lo es nunca.

La utilidad de la regla se ve al invertirla mentalmente. Si un repositorio conociera el ViewModel, rediseñar la pantalla obligaría a tocar el almacenamiento; probar la persistencia exigiría construir media interfaz; y dos pantallas que comparten datos quedarían acopladas a través de la capa que debía desacoplarlas. Cada una de esas consecuencias es un coste concreto que aparece meses después de la decisión que lo causó.

flowchart TD
A[Capa de UI] --> B[Capa de dominio opcional]
B --> C[Capa de datos]
A -.atajo permitido.-> C
C --> D[Fuente local]
C --> E[Fuente remota]
F[Modelos de dominio] --> A
F --> C
style A fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b

Las violaciones interesantes no son las obvias sino las que se cuelan con buenas intenciones. La más común es la fuga de tipos: el repositorio devuelve directamente la clase que generó el cliente de red, con sus anotaciones de serialización y sus campos opcionales, y la pantalla acaba leyendo ese objeto. No hay ninguna importación ilegal, la regla parece respetada, y sin embargo la UI depende ahora del contrato del servidor. El día que ese contrato cambie, el compilador señalará un fichero de la capa de presentación.

La segunda es el contexto viajero. Alguien necesita una cadena traducida dentro de un repositorio y le pasa un Context, o peor, resuelve ahí un recurso de texto. La capa de datos acaba de aprender que existe una interfaz de usuario en algún idioma. La solución correcta es devolver un tipo que describa el error o el estado y dejar que la capa superior decida cómo se llama eso en cada idioma.

La tercera es la callback ascendente: el repositorio recibe un objeto para notificar resultados que en realidad es la pantalla disfrazada. La dependencia existe aunque el tipo declarado sea una interfaz, porque el flujo de control sube. La forma sana de que la información suba es que baje una suscripción: la capa superior observa, la inferior emite, y nadie guarda una referencia a nadie.

Vale la pena verlas juntas en código, porque las tres se reconocen mucho mejor por su forma que por su descripción, y ninguna de ellas requiere una importación ilegal para existir.

// tres formas de romper la regla sin importar nada del paquete de interfaz
interface MalRepositorio {
    suspend fun cargar(): ArticuloDto             // fuga del contrato del servidor
    fun mensajeDeError(ctx: Context): String      // conoce recursos y textos
    fun observar(oyente: (Articulo) -> Unit)      // el control sube en lugar de bajar
}

Un matiz que evita malentendidos: la regla habla de dependencias de compilación, no de dirección del dato. Los datos suben continuamente, y deben hacerlo, porque de eso trata una aplicación. Lo que no sube nunca es el conocimiento de tipos: la capa inferior emite valores que la superior sabe leer, sin que la inferior haya oído hablar jamás de quién la escucha.

⚠️
El detector barato de inversiones

Abre el fichero de un repositorio y lee solamente sus importaciones. Si aparece cualquier cosa del paquete de tu interfaz, del sistema de recursos o del ciclo de vida, la regla ya está rota aunque el código funcione. Este control cuesta treinta segundos por archivo, no requiere herramientas y detecta la mayoría de las erosiones antes de que se vuelvan estructurales. Cuando el proyecto crezca lo suficiente, la misma comprobación se automatiza con módulos de Gradle, que es exactamente el asunto de la última lección de este nivel.

El dominio es opcional, y conviene entender por qué

Una capa de dominio bien puesta resuelve dos problemas reales. El primero es la lógica repetida: si tres pantallas calculan el mismo descuento, ese cálculo tiene un dueño y no son ellas. El segundo es el ViewModel obeso: cuando una pantalla necesita combinar cuatro repositorios y aplicar reglas encima, el resultado es una clase que hace demasiadas cosas y que nadie sabe probar.

class ObtenerCarritoConDescuentos(
    private val carritos: CarritoRepository,
    private val promos: PromocionRepository,
) {
    operator fun invoke(usuario: UsuarioId): Flow<Carrito> =
        combine(carritos.observar(usuario), promos.vigentes()) { carrito, promociones ->
            carrito.aplicar(promociones)   // la regla vive aqui, no en la pantalla
        }
}

Lo que no resuelve una capa de dominio es la ansiedad arquitectónica. Un caso de uso que se limita a llamar al método homónimo del repositorio no añade ninguna información al sistema: añade un archivo, una inyección y un salto más al leer una traza. La guía oficial permite explícitamente que la UI hable con la capa de datos sin intermediarios, y ese atajo es la elección correcta en la mayoría de las pantallas de la mayoría de las aplicaciones.

Hay un segundo servicio que un caso de uso presta y que se menciona poco: nombrar. Una clase llamada ObtenerCarritoConDescuentos documenta una operación del negocio en un sitio donde alguien la encontrará buscando por concepto y no por pantalla. En dominios con vocabulario propio, ese valor de catálogo puede justificar la capa por sí solo aunque la lógica sea modesta, porque lo que se está preservando no es código sino el hecho de que esa regla existe y tiene nombre.

El criterio operativo cabe en una pregunta: ¿esta regla tiene sentido fuera de esta pantalla concreta? Si la respuesta es sí, merece un caso de uso; si es no, vive donde ya está. Introducir la capa entera antes de que exista la primera regla compartida es pagar por adelantado una estructura cuya forma todavía no conoces, y la estructura pagada por adelantado casi nunca coincide con la que después hace falta.

📝
Sin estado y sin plataforma

Un caso de uso no guarda estado entre invocaciones y no depende de nada específico de Android. Ambas restricciones son las que lo hacen barato: se prueba con una llamada y se puede llevar a código compartido entre plataformas sin tocarlo. Si el tuyo necesita un Context, un ámbito de corrutina propio o recordar algo entre llamadas, entonces no es un caso de uso sino un componente distinto que se está haciendo pasar por uno.

Por qué la interfaz nunca conoce la red

La consecuencia más citada del modelo suele enunciarse como una prohibición, y funciona mucho mejor entendida como una garantía. No es que la pantalla tenga vetado hablar con el servidor: es que la pantalla no puede saber si hay servidor, porque esa incógnita es lo que permite cambiarlo todo por debajo sin tocarla.

Formulada así, la regla deja de sonar dogmática y empieza a sonar útil, porque señala qué hay que comprobar: no si alguien escribió una importación prohibida, sino si el conjunto de cosas que la pantalla puede llegar a saber está acotado y es pequeño.

Piensa qué consultas hace realmente una pantalla. Pregunta si hay datos, si están cargando y si algo falló. Ninguna de esas tres preguntas menciona el protocolo, el código de respuesta, la caducidad de la caché o el estado de la conectividad. Cuando el repositorio expone exactamente esas tres respuestas y nada más, la interfaz queda inmunizada frente a cualquier cambio en la infraestructura: migrar a otro cliente de red, añadir una caché, invertir el orden de las fuentes o volverse offline-first son operaciones que no producen ni una sola línea de diferencia en la capa de presentación.

// la pantalla observa esto y no puede deducir de donde vino
interface ArticuloRepository {
    fun observar(id: ArticuloId): Flow<Articulo>
    suspend fun refrescar(id: ArticuloId): Result<Unit>
}

Conviene notar que ese contrato tiene dos métodos y que ninguno habla de tiempo, de protocolo ni de conectividad. No hay una consulta para saber si hay red ni un parámetro para configurar reintentos, porque la pantalla carece de criterio para decidir esas cosas. Toda pregunta que la interfaz no sabría contestar bien es una pregunta que no debería poder formular, y limitar la superficie es la forma más económica de garantizarlo.

Esa inmunidad tiene además un efecto sobre las pruebas que conviene subrayar porque es el que más tiempo devuelve. Un ViewModel que solo depende de una interfaz como la anterior se prueba con una implementación falsa de diez líneas, sin dispositivo, sin servidor de mentira y sin esperas artificiales. La misma clase acoplada a un cliente de red necesita infraestructura, tolera intermitencias y acaba desactivada en la integración continua por inestable.

La objeción razonable a todo esto es que la ignorancia absoluta tiene un límite práctico: a veces la pantalla sí necesita distinguir entre todavía no hay datos y no hay datos porque no hay red, para poder decir algo útil. La respuesta correcta no es abrir la frontera sino enriquecer el contrato: el repositorio expone un estado de sincronización con vocabulario propio, sin mencionar protocolos ni códigos, y la interfaz decide qué mensaje corresponde a cada caso. La diferencia entre ambas soluciones parece sutil y no lo es, porque una mantiene acotado el número de conceptos que la pantalla conoce y la otra lo deja crecer sin techo.

La arquitectura es una apuesta sobre qué va a cambiar

Conviene enunciar sin rodeos lo que las tres capas realmente son, porque la formulación habitual, separar responsabilidades, es cierta y a la vez tan vaga que no ayuda a decidir nada. Una arquitectura es una predicción sobre el ritmo al que cambiarán las distintas partes del sistema, materializada en dónde se colocan las fronteras. La guía oficial de Android apuesta por algo muy concreto y empíricamente sólido: la interfaz cambia rápido, los datos cambian despacio, y por tanto la frontera valiosa es la que impide que la velocidad de una contamine la estabilidad de la otra. Un rediseño completo de una pantalla ocurre cada pocos trimestres; el concepto de artículo, de pedido o de usuario sobrevive a todos esos rediseños casi intacto. Si esa predicción es correcta, y en aplicaciones de producto lo es casi siempre, entonces situar la frontera entre presentación y datos maximiza la cantidad de código que no hay que tocar en cada ciclo. De ahí se sigue el corolario que explica por qué el dominio es opcional y las otras dos capas no: el dominio solo se gana su sitio cuando existe lógica que cambia a un ritmo distinto tanto de la pantalla como del almacenamiento, y esa lógica existe en aplicaciones con reglas de negocio propias y no existe en un cliente que fundamentalmente muestra lo que un servidor le dicta. Introducir la capa igualmente es hacer una apuesta sobre una variabilidad que no está ahí, y las apuestas equivocadas en arquitectura no se cobran en errores sino en fricción permanente: un salto más que leer en cada traza, un archivo más que abrir en cada cambio, una conversación más en cada revisión. Hay una última implicación, y es la que separa a quien ha entendido el modelo de quien lo ha copiado. Si la arquitectura es una apuesta, entonces es revisable. Cuando una parte del sistema empieza a cambiar a un ritmo distinto del previsto, la frontera correcta se ha movido y mantenerla donde estaba deja de ser rigor para convertirse en inercia. Las tres capas no son una verdad revelada sobre el software: son la respuesta correcta a un patrón de cambio muy común, y su valor depende por completo de que ese patrón siga describiendo tu aplicación.

⚔️ Audita tus flechas
  1. Elige una pantalla real y dibuja todas las dependencias efectivas, incluidas las que viajan dentro de tipos de retorno. Marca cuáles apuntan hacia arriba.
  2. Busca en tus repositorios cualquier importación del paquete de interfaz, de recursos o de ciclo de vida, y propón para cada una una alternativa que no cruce la frontera.
  3. Localiza un tipo generado por tu cliente de red que llegue vivo hasta un Composable y traza cuántos ficheros de la capa de UI tendrías que tocar si el servidor renombrara un campo.
  4. Toma tres casos de uso de tu proyecto, si los hay, y clasifícalos en regla compartida, regla compleja o delegación vacía. Elimina los del tercer grupo.
  5. Escribe un doble de prueba de un repositorio en menos de quince líneas y usa el resultado para probar un ViewModel sin dispositivo. Si no lo consigues, la frontera todavía no está donde crees.