wandres.dev
CAPA DE DATOS · repositorios y fuentes

El repositorio: la frontera entre el dominio y las fuentes

El repositorio no es una carpeta ni un sufijo en un nombre de clase: es la frontera donde el vocabulario del negocio deja de mezclarse con el vocabulario de la infraestructura. Esta lección define qué debe exponer un repositorio para que el dominio siga siendo comprensible sin saber nada de bases de datos ni de HTTP, qué tiene la obligación de esconder para que ese conocimiento no se filtre hacia arriba, cómo la inversión de dependencias coloca la interfaz en el dominio y la implementación en la capa de datos, y por qué los dos degeneres habituales de esta pieza, la pasarela sin criterio y el repositorio que lo sabe todo, son síntomas del mismo error de encuadre.

⏱ 18 min

Toda aplicación seria tiene una frontera que rara vez aparece dibujada en los diagramas y que sin embargo decide su longevidad: la línea donde el idioma del negocio deja de mezclarse con el idioma de la infraestructura. A un lado de esa línea existen productos, pedidos, cupones y usuarios; al otro existen filas, columnas, cuerpos JSON, códigos de estado, cursores y reintentos. El repositorio es exactamente esa línea hecha código. Su valor no está en lo que hace, porque a menudo su implementación no es más que unas pocas llamadas encadenadas, sino en lo que decide no dejar pasar. Cuando la frontera está bien trazada, un ViewModel puede leerse entero sin saber si los datos vienen de un servidor, de un fichero o de la memoria, y esa ignorancia es precisamente lo que permite cambiar la respuesta a esa pregunta sin reescribir la mitad de la aplicación. Cuando está mal trazada, el tipo de una anotación de persistencia acaba apareciendo en la firma de una función de presentación, y a partir de ahí ya no hay capa que valga.

🎯 Al terminar esta lección sabrás
  • Formular el contrato de un Repository exclusivamente con tipos y nombres del dominio.
  • Distinguir con criterio qué pertenece a la superficie pública y qué queda confinado en la implementación.
  • Aplicar la inversión de dependencias para que el dominio no compile nunca contra Room ni contra Retrofit.
  • Reconocer y corregir los dos degeneres clásicos: la pasarela sin criterio y el repositorio que lo sabe todo.

El contrato se escribe en el idioma del dominio

Un repositorio empieza siempre por su interfaz, y esa interfaz debe poder leerse en voz alta a alguien que no programa. Si al hacerlo aparecen palabras como fila, columna, endpoint o caché, la frontera ya se rompió antes de escribir la primera implementación.

interface CatalogoRepository {
    fun observarProductos(categoria: CategoriaId): Flow<List<Producto>>
    suspend fun refrescar(categoria: CategoriaId): Result<Unit>
    suspend fun detalle(id: ProductoId): Result<Producto>
}

Tres decisiones de esa firma merecen atención. La primera es que la lectura continua se expone como Flow y no como una llamada que devuelve una foto fija: el dominio no pregunta cómo están los productos ahora, se suscribe a cómo están en todo momento, que es lo que una interfaz reactiva necesita. La segunda es que la operación de refresco devuelve Result<Unit> y no la lista: quien refresca no consume el resultado directamente, porque el resultado llegará por el flujo, y esa asimetría es la primera pista de la estrategia que veremos en la cuarta lección. La tercera es que los identificadores son tipos propios y no cadenas sueltas, de modo que resulta imposible pasar el identificador de una categoría donde se esperaba el de un producto.

Fíjate también en lo que la firma no dice. No dice de dónde salen los productos. No dice si hay una petición de red implicada, ni si hay caché, ni cuánto dura. No dice si la lista viene paginada, porque la paginación, si existe, es un detalle de cómo se llena la fuente y no de qué significa observar un catálogo. Cada una de esas omisiones es una libertad que la implementación conserva y que podrá ejercer más adelante sin romper a nadie.

Hay una manera útil de comprobar si el vocabulario elegido pertenece de verdad al dominio: contar cuántas de esas palabras existirían igual si la aplicación se hiciera en papel. Un catálogo, una categoría, un producto y un precio existirían; un cursor, un identificador de fila y una marca de última sincronización no. Esa distinción no es filosófica, es la que separa los conceptos que solo cambian cuando cambia el negocio de los que cambian cuando cambia la infraestructura, y toda la estabilidad de las capas superiores depende de haber puesto únicamente los primeros en la frontera.

El tamaño del contrato es la otra decisión que se toma aquí, y la referencia útil es que un repositorio no debe tener un método por operación posible sobre una tabla. Las cuatro operaciones clásicas de creación, lectura, actualización y borrado describen lo que un almacén sabe hacer, no lo que el negocio necesita pedir; trasladarlas tal cual a la interfaz produce contratos de veinte métodos donde la mayoría existen porque eran posibles, no porque alguien los use. Un repositorio bien dimensionado suele tener entre tres y siete operaciones, cada una con un caso de uso real detrás, y crece únicamente cuando aparece una necesidad nueva.

💡
La prueba del cambio de fuente

Hay una comprobación barata para saber si una interfaz de repositorio está bien planteada: imagina que mañana el equipo decide sustituir el servidor por un fichero local, o al revés. Si esa sustitución obliga a cambiar la interfaz, entonces la interfaz estaba describiendo una fuente concreta en lugar de una necesidad del dominio. Si solo obliga a cambiar la implementación, la frontera cumple su función. Esta prueba no es hipotética: la migración de un backend, la aparición de un modo sin conexión o la llegada de una versión de escritorio la ejecutan tarde o temprano en todos los proyectos que sobreviven.

Lo que la frontera tiene la obligación de esconder

Definir qué expone un repositorio es la mitad fácil. La mitad difícil, y la que decide si la arquitectura aguanta, es ser implacable con lo que queda dentro.

🗄️

El esquema de persistencia

Entidades anotadas, claves primarias, migraciones y objetos de acceso a datos viven dentro. Ningún Entity cruza la frontera hacia arriba.

🌐

El protocolo de red

Cuerpos JSON, cabeceras, códigos de estado, tiempos de espera y reintentos son asunto interno. Un ViewModel jamás debe ver un tipo de la librería HTTP.

🧭

La política de datos

Qué se lee primero, cuánto vale un dato antes de considerarse viejo y cuándo se dispara un refresco son decisiones del repositorio, no de la pantalla.

🧵

El hilo de ejecución

Que una consulta se ejecute fuera del hilo principal es una obligación de la implementación. Quien la llama solo sabe que la función es suspend.

La razón por la que estas cuatro cosas deben quedarse dentro es la misma en los cuatro casos: son detalles que cambian por motivos ajenos al negocio. El esquema cambia cuando alguien optimiza una consulta, el protocolo cambia cuando el equipo de servidor publica una versión nueva, la política cambia cuando se mide el consumo de datos, el hilo cambia cuando aparece un cuello de botella. Ninguno de esos motivos tiene relación con lo que significa un producto en el catálogo, y por tanto ninguno debería propagar cambios a las capas que hablan de productos.

Existe además una consecuencia inmediata sobre las pruebas que suele decidir la discusión mejor que cualquier argumento de diseño. Si el contrato solo menciona tipos de dominio, entonces una implementación falsa que devuelva una lista fija y un flujo controlado cabe en diez líneas y no necesita base de datos, servidor simulado ni instrumentación. Si el contrato menciona tipos de infraestructura, esa implementación falsa tiene que fabricar respuestas HTTP o entidades anotadas, y a partir de ahí las pruebas de presentación empiezan a depender del esquema y del protocolo. La facilidad para escribir un doble de prueba no es un beneficio colateral de una buena frontera: es su medida más fiable.

flowchart LR
VM[ViewModel usa el contrato] --> R[Interfaz de repositorio en el dominio]
R -.implementada por.-> I[Implementacion en la capa de datos]
I --> L[Fuente local]
I --> N[Fuente remota]
I --> M[Mapeadores de DTO y entidad a dominio]
style R fill:#f9e2af,color:#11111b
style I fill:#89b4fa,color:#11111b

Ese diagrama contiene la asimetría que hace funcionar todo lo demás: la flecha que va del ViewModel al contrato es una dependencia real de compilación, mientras que la que va del contrato a la implementación es una relación que solo existe en tiempo de ejecución, resuelta por el inyector de dependencias. El dominio no sabe que la implementación existe, y por eso puede compilarse, probarse y razonarse sin ella.

Inversión de dependencias y forma de los módulos

La colocación física del código no es decoración: es lo que convierte la regla en una barrera que el compilador vigila. La interfaz pertenece al módulo de dominio; la implementación, al de datos; y el módulo de dominio no declara ninguna dependencia hacia el de datos.

// modulo :domain  -- sin dependencias de Android ni de librerias de datos
interface CatalogoRepository { /* ... */ }

// modulo :data  -- depende de :domain, nunca al reves
internal class CatalogoRepositoryImpl(
    private val local: CatalogoDao,
    private val remoto: CatalogoApi,
    private val io: CoroutineDispatcher,
) : CatalogoRepository {
    override fun observarProductos(categoria: CategoriaId): Flow<List<Producto>> =
        local.observar(categoria.valor).map { filas -> filas.map(ProductoEntity::aDominio) }
}

El modificador internal en la implementación no es un detalle menor. Impide que otro módulo la nombre, y con ello impide el atajo más frecuente de todos: inyectar la clase concreta en lugar de la interfaz para poder llamar a un método extra que la interfaz no ofrece. Ese atajo parece inofensivo la primera vez y es el mecanismo exacto por el que las fronteras se disuelven en los proyectos grandes.

El enlace entre ambas piezas se declara una sola vez, en el módulo de inyección de dependencias del lado de datos, y es el único lugar de toda la aplicación donde el nombre de la implementación aparece escrito.

@Module
@InstallIn(SingletonComponent::class)
internal abstract class DataModule {
    @Binds
    abstract fun catalogo(impl: CatalogoRepositoryImpl): CatalogoRepository
}

Que ese enlace sea el único punto de contacto tiene una consecuencia inmediata en las pruebas: sustituir la implementación real por un doble no exige tocar ninguna clase consumidora, porque ninguna la nombraba. Y tiene otra en la evolución del proyecto, porque permite tener dos implementaciones vivas a la vez, una contra el servidor real y otra contra datos de demostración, seleccionadas por variante de compilación sin que el resto del código sepa cuál está corriendo.

El despachador tampoco llega por casualidad como parámetro del constructor. Un repositorio que invoca directamente al despachador de entrada y salida deja de ser comprobable con un planificador de pruebas, porque el test ya no controla el tiempo ni el orden de ejecución. Recibirlo desde fuera cuesta una línea y convierte una clase difícil de probar en una que se ejecuta de forma determinista. Es la misma regla que rige para el reloj: todo lo que introduce indeterminismo debería entrar por el constructor en vez de invocarse desde dentro.

ℹ️
Por que el dominio no debe conocer ni el Flow de la base de datos ni el modelo de red

Que el dominio use Flow es aceptable porque Flow es una abstracción del lenguaje sobre secuencias asíncronas, no una tecnología de almacenamiento; puede alimentarlo una base de datos, un fichero o una lista en memoria. Que el dominio use un tipo generado por la librería de persistencia no lo es, porque ese tipo arrastra consigo un esquema, unas anotaciones y una versión concreta de una dependencia. La distinción práctica es esta: una abstracción es admisible en la frontera si puedes sustituir por completo la tecnología que hay detrás sin tocar la firma. Flow pasa esa prueba; un Cursor, un Response o una clase anotada con Entity no la pasan.

Los dos degeneres del repositorio

Cuando esta pieza sale mal, casi siempre lo hace en una de dos direcciones opuestas, y ambas nacen de no haber decidido qué problema resuelve.

Conviene añadir que estos degeneres rara vez nacen así. Suelen aparecer por acumulación, cuando una interfaz razonable recibe durante un año pequeñas adiciones que nadie discute por separado, y el resultado se descubre el día en que alguien intenta escribir un doble de prueba y necesita implementar veintitrés métodos. Revisar periódicamente la interfaz completa, y no solo el método que se añade, es la única defensa práctica contra esa deriva.

El primer degenere es la pasarela: un repositorio con un método por cada método del servicio de red, con los mismos nombres, los mismos parámetros y los mismos tipos, que se limita a delegar. Añade una capa de indirección y no compra ninguna libertad, porque el vocabulario que atraviesa la frontera sigue siendo el de la infraestructura. La señal inequívoca es que al cambiar el servicio de red hay que cambiar también el repositorio, uno a uno; si eso ocurre, el repositorio no era una frontera sino un eco.

El segundo degenere es el repositorio dios: una clase que acumula el acceso a datos de media aplicación, con treinta métodos que mezclan catálogo, sesión, carrito y preferencias. Aquí sí hay traducción, pero se ha perdido la cohesión: nada se puede probar de forma aislada, cualquier cambio recompila todo y dos equipos no pueden tocar la misma pantalla sin pisarse. La cura es partir por agregado del dominio y no por tecnología: un repositorio por concepto de negocio, no un repositorio por base de datos.

Ambos degeneres comparten la misma raíz, y por eso conviene tratarlos juntos. En los dos casos alguien creó el repositorio porque la arquitectura de referencia decía que debía existir, no porque hubiera identificado un concepto del negocio que mereciera una frontera propia. Cuando el punto de partida es la plantilla, el resultado es un espejo de la infraestructura con otro nombre; cuando el punto de partida es la pregunta de qué concepto quiere el negocio poder pedir y observar, la interfaz sale sola y su tamaño lo fija el concepto, no la tecnología que hay detrás.

📝
Casos de uso, cuando hacen falta y cuando sobran

Una duda razonable en este punto es si entre el ViewModel y el repositorio debe existir una capa de casos de uso. La respuesta honesta es que depende de si hay lógica que orquestar. Un caso de uso que se limita a llamar a un método del repositorio y devolverlo tal cual es una indirección sin contenido, y multiplicarla por cada operación produce proyectos donde llegar al dato exige abrir cuatro ficheros. Un caso de uso que combina dos repositorios, aplica una regla de negocio o decide un orden de operaciones sí gana su sitio, porque esa lógica no pertenece ni a la presentación ni a ninguna fuente concreta. La regla práctica es introducirlos cuando aparece la primera combinación real, no antes.

El repositorio no oculta una fuente: protege una forma de pensar

Hay una manera pobre de justificar el repositorio, que es la que se repite en las entrevistas de trabajo: sirve para poder cambiar de base de datos. Casi nadie cambia de base de datos, y quien lo hace descubre que la migración de datos duele mucho más que la firma de las funciones. El valor real es otro y es mucho más cotidiano. Un repositorio bien trazado establece que existe un conjunto de conceptos, los del negocio, que tienen derecho a permanecer estables aunque todo lo demás cambie, y crea un lugar físico donde esa estabilidad se defiende. Lo que protege no es la independencia respecto de una tecnología, sino la posibilidad de razonar sobre la aplicación en un solo vocabulario. Cuando un desarrollador abre un ViewModel y ve productos, pedidos y errores de negocio, puede pensar en el problema; cuando ve respuestas HTTP, códigos de estado y filas anotadas, tiene que pensar simultáneamente en el problema y en tres infraestructuras, y esa carga cognitiva doble es la que convierte una función de cuarenta líneas en un sitio donde nadie quiere entrar. Por eso la pregunta correcta al revisar una interfaz de repositorio no es si permite cambiar la fuente, sino si permite olvidarse de la fuente. Un contrato que obliga a quien lo consume a saber que detrás hay una red, porque expone un tipo de red, un error de red o una semántica de red, ha fallado aunque sea sustituible. Y un contrato que permite escribir toda la lógica de una pantalla hablando solo de negocio ha triunfado aunque su implementación sea, por dentro, la delegación más aburrida del mundo. La frontera se juzga por lo que deja pensar del otro lado, no por lo que hace en el suyo.

⚔️ Traza la frontera de un repositorio real
  1. Toma un repositorio existente de tu proyecto y subraya en su interfaz cada palabra que pertenezca a la infraestructura. Reescribe la firma hasta que no quede ninguna.
  2. Comprueba si algún tipo de persistencia o de red aparece en la firma pública. Introduce los mapeadores necesarios para que dejen de aparecer y anota qué acoplamiento has cortado.
  3. Mueve la interfaz a un módulo sin dependencias de Android y verifica que compila. Cada error de compilación es una filtración que estaba oculta.
  4. Marca la implementación como internal y localiza los sitios que dejan de compilar: ahí estaba alguien saltándose el contrato.
  5. Aplica la prueba del cambio de fuente sobre tu interfaz y describe por escrito qué habría que tocar si mañana los datos llegaran de un fichero local.