wandres.dev
ARQUITECTURA RECOMENDADA · capas y flujo unidireccional

La capa de datos: repositorios, fuentes y modelos por capa

La capa de datos es la parte de la arquitectura recomendada que más se cita y peor se aplica, porque su pieza central se confunde de forma casi universal con un envoltorio del cliente de red. Esta lección la reconstruye desde su definición real: el repositorio como fuente única de verdad y dueño de las reglas que gobiernan un tipo de dato, las fuentes concretas como piezas privadas de un solo origen y sin conocimiento las unas de las otras, la disciplina de mantener un modelo distinto por capa y qué se paga exactamente al ahorrárselo, y el diseño de los errores como parte del contrato en lugar de como excepciones que atraviesan fronteras que nadie declaró.

⏱ 21 min

Si hay una pieza de la arquitectura recomendada que se aplica mal de forma sistemática, es el repositorio. La versión degradada aparece en casi todos los proyectos y es fácil de reconocer: una clase con el sufijo correcto cuyos métodos se corresponden uno a uno con los del cliente de red, cada uno reenviando la llamada y traduciendo el resultado. Esa clase no es una fuente de verdad ni una frontera ni una abstracción; es una capa de reenvío que añade un archivo y no elimina ninguna dependencia, porque quien la usa sigue estando acoplado a la forma del servidor, solo que con un intermediario por medio. El repositorio de verdad se define por otra cosa: es el único sitio del sistema que sabe responder qué es cierto sobre un tipo de dato, y esa responsabilidad implica decidir entre fuentes, resolver conflictos y sostener una respuesta cuando ninguna fuente está disponible.

🎯 Al terminar esta lección sabrás
  • Definir un repositorio por su responsabilidad sobre un tipo de dato y no por su correspondencia con un servicio remoto.
  • Descomponer la capa en fuentes concretas con un único origen y sin conocimiento mutuo.
  • Mantener modelos distintos por capa y ejecutar las traducciones en el lugar correcto.
  • Diseñar los errores como parte del contrato del repositorio en lugar de dejarlos escapar hacia arriba.

El repositorio manda sobre un tipo de dato

La guía oficial recomienda un repositorio por cada tipo de dato relevante de la aplicación, y esa formulación es más exigente de lo que parece. No dice un repositorio por servicio remoto, ni por pantalla, ni por tabla: dice por tipo de dato del negocio. Un ArticuloRepository es el dueño del concepto artículo, y todo lo que la aplicación necesite saber o hacer con artículos pasa por él, vengan los datos de donde vengan y sean cuantas sean las fuentes implicadas.

Esa formulación tiene una consecuencia inmediata que sorprende a mucha gente: el número de repositorios de una aplicación no guarda ninguna relación con el número de rutas de su servidor. Un solo repositorio puede consumir seis rutas, y seis rutas de un mismo recurso pueden colapsar en tres métodos si el negocio no distingue entre ellas. Cuando la correspondencia sale uno a uno de forma sistemática, la capa no se diseñó: se transcribió.

De ahí se sigue lo que realmente distingue a un repositorio de un reenviador. El repositorio toma decisiones. Decide si un dato en caché sigue siendo válido, decide qué fuente consultar primero, decide qué hacer cuando la red contradice al disco, decide si una escritura se confirma antes o después de llegar al servidor. Cada una de esas decisiones es una regla del negocio, no una operación técnica, y su lugar natural es exactamente aquí.

class ArticuloRepositoryImpl(
    private val local: ArticuloLocalDataSource,
    private val remota: ArticuloRemoteDataSource,
) : ArticuloRepository {

    override fun observar(id: ArticuloId): Flow<Articulo> =
        local.observar(id).map { entidad -> entidad.aDominio() }

    override suspend fun refrescar(id: ArticuloId): Result<Unit> = runCatching {
        val respuesta = remota.obtener(id)
        local.guardar(respuesta.aEntidad())     // la verdad se escribe en un solo sitio
    }
}

Fíjate en la asimetría del contrato: se observa de un sitio y se refresca desde otro, pero lo que sale hacia arriba tiene un único origen. Esa es la fuente única de verdad, y es lo que garantiza que dos pantallas que muestran el mismo artículo no puedan discrepar. Si cada una hubiera pedido su copia al servidor, la coherencia dependería del azar de los tiempos de respuesta.

La fuente única no tiene por qué ser el disco en todos los casos. Para datos que no merecen persistirse puede ser una caché en memoria dentro del propio repositorio, y para datos que la aplicación no posee puede ser directamente el servidor. Lo que la propiedad exige no es una tecnología concreta sino que exista exactamente un lugar capaz de responder, y que todo lo demás sea una vista de ese lugar.

Otra consecuencia que ordena mucho el diseño es que un repositorio debería ser único en el proceso para su tipo de dato. Dos instancias del mismo repositorio con cachés separadas son dos verdades con el mismo nombre, y el fallo que producen aparece meses después en forma de pantallas que discrepan sin motivo aparente. Por eso su ciclo de vida pertenece al ámbito de la aplicación y no al de una pantalla, y por eso conviene desconfiar de cualquier lugar donde se construya uno a mano.

🏛️

Repositorio

Público, uno por tipo de dato, dueño de las reglas. Es lo único que ven las capas superiores.

🔌

Fuente de datos

Privada, una por origen físico. Sabe hablar con su medio y nada más. Nunca conoce a otra fuente.

Fuentes: una por origen y ciegas entre sí

Una fuente de datos encapsula exactamente un origen: la base local, el servicio remoto, el almacén de preferencias, el sistema de archivos, un sensor. Su trabajo consiste en traducir entre el lenguaje de ese medio y algo utilizable, y ahí termina. No orquesta, no decide, no combina.

La regla de que las fuentes no se conocen entre sí es la que más se incumple y la que más caro se paga. En cuanto la fuente remota escribe en la local, la lógica de coordinación queda repartida entre dos clases que además dependen la una de la otra, y la pregunta de qué ocurre cuando falla la escritura después de una respuesta correcta deja de tener un único sitio donde responderse. Toda la orquestación pertenece al repositorio, sin excepciones que valgan la pena.

flowchart TD
A[ViewModel] --> B[ArticuloRepository]
B --> C[Fuente local con base de datos]
B --> D[Fuente remota con cliente de red]
B --> E[Fuente de preferencias]
C -.no se conocen.- D
style B fill:#a6e3a1,color:#11111b

Conviene también resistirse a la simetría automática. Nada obliga a que cada repositorio tenga exactamente una fuente local y una remota: hay datos que solo viven en el dispositivo, como las preferencias de visualización, y datos que no tiene sentido guardar, como el resultado de una búsqueda libre. Crear una fuente vacía para cumplir con el patrón es exactamente el mismo error que crear un caso de uso que no hace nada, y se paga en el mismo tipo de moneda.

Hay un segundo motivo para esta separación que solo se aprecia al probar. Una fuente con un único origen se sustituye por una implementación en memoria en cuatro líneas, y con eso el repositorio entero, que es donde está la lógica interesante, se prueba sin dispositivo, sin servidor de mentira y sin base de datos real. Cuando la orquestación vive dentro de las fuentes, esa prueba deja de ser posible y el equipo acaba probando la lógica de negocio a través de la interfaz, que es el sitio más caro y más frágil para hacerlo.

💡
El nombre delata la responsabilidad

Si tus fuentes se llaman igual que tus repositorios con un sufijo distinto y tienen los mismos métodos, la descomposición es cosmética. Una fuente local sana habla de entidades, consultas y transacciones; una fuente remota habla de rutas, cuerpos y códigos de respuesta. Que sus vocabularios sean distintos es la señal de que cada una está en su medio, y que el repositorio los traduzca a un tercero es la señal de que está haciendo su trabajo.

Un modelo por capa y las traducciones que lo sostienen

La disciplina más cuestionada de esta capa es también la que más protege: cada capa tiene sus propios tipos, y en las fronteras hay traducción. La fuente remota trabaja con tipos de transporte, con sus anotaciones de serialización y sus campos opcionales porque el servidor los puede omitir. La fuente local trabaja con entidades, con sus anotaciones de persistencia, sus claves primarias y sus índices. El repositorio expone modelos de dominio, que no tienen ninguna anotación porque no pertenecen a ninguna tecnología.

@Serializable
data class ArticuloDto(val id: String, val nombre: String?, val precio_centimos: Long?)

@Entity(tableName = "articulos")
data class ArticuloEntity(@PrimaryKey val id: String, val nombre: String, val precioCentimos: Long)

data class Articulo(val id: ArticuloId, val nombre: String, val precio: Dinero)

La objeción habitual es que son tres declaraciones casi idénticas y dos funciones de conversión para el mismo concepto, y la objeción es correcta el primer día. Lo que compensa aparece después, y conviene enumerarlo porque es concreto. La opcionalidad se resuelve una sola vez, en la traducción, en lugar de propagarse por toda la aplicación en forma de comprobaciones defensivas. El identificador deja de ser una cadena que se puede confundir con cualquier otra cadena y pasa a ser un tipo que el compilador vigila. El importe deja de ser un número entero cuya unidad hay que recordar. Y cuando el servidor renombre un campo, el cambio se detiene en la función de conversión, que es exactamente donde debería detenerse.

Ese último punto merece medirse. En un proyecto con modelo compartido, un renombrado en el servidor produce errores de compilación repartidos por pantallas, ViewModel y pruebas. En un proyecto con modelos por capa, produce un error en una función de veinte líneas. La diferencia es el radio de daño, y el radio de daño es lo que determina si un cambio pequeño se puede hacer un jueves por la tarde.

El sitio donde ocurre la traducción también importa y suele elegirse por descuido. Debe ocurrir en la frontera, es decir, en el repositorio o inmediatamente dentro de cada fuente, y nunca en la capa superior. Una función de conversión invocada desde un ViewModel significa que el tipo ajeno ya cruzó la frontera y que la traducción llegó tarde; el compilador no lo señalará, porque compila igual de bien, pero el acoplamiento ya está hecho.

internal fun ArticuloDto.aEntidad() = ArticuloEntity(
    id = id,
    nombre = nombre.orEmpty(),                 // la opcionalidad muere aqui
    precioCentimos = precio_centimos ?: 0L,
)

Que esas funciones sean internal y vivan junto al tipo que traducen es una convención pequeña con un efecto grande: impide que alguien las use desde fuera para colar el tipo ajeno un nivel más arriba, y mantiene la conversión al lado del código que hay que cambiar cuando el contrato cambie.

Dicho esto, la disciplina admite grados y aplicarla sin criterio también tiene coste. Una aplicación pequeña, con un servidor que controla el mismo equipo y un dominio que no es más que lo que el servidor dicta, puede compartir modelo entre la fuente remota y el repositorio sin drama. Lo que casi nunca sale bien es lo contrario: dejar que la entidad de persistencia, con sus restricciones de forma impuestas por la base de datos, sea el modelo que ve la pantalla, porque entonces cualquier migración de esquema se convierte en un cambio de interfaz.

Los errores forman parte del contrato

La última decisión de diseño de esta capa es la que más se posterga: qué ocurre cuando algo falla. Dejar que las excepciones suban es la opción por defecto y tiene un defecto grave, y es que la excepción viaja con el vocabulario de su origen. Un fallo de resolución de nombres, un tiempo de espera agotado, una violación de restricción de unicidad. Ninguno de esos conceptos pertenece a la pantalla, y sin embargo la pantalla acabará capturándolos porque alguien tiene que decidir qué se le dice al usuario.

El repositorio, que ya es la frontera del origen de los datos, debe serlo también del vocabulario de los fallos. Lo que sale hacia arriba son resultados que la capa superior sabe interpretar sin saber nada del medio.

sealed interface ErrorDeDatos {
    data object SinConexion : ErrorDeDatos
    data object NoEncontrado : ErrorDeDatos
    data class Rechazado(val motivo: String) : ErrorDeDatos
    data object Inesperado : ErrorDeDatos
}

Ese tipo cerrado tiene además una virtud que las excepciones no pueden ofrecer: obliga a que alguien decida. Una excepción se puede ignorar sin que nada avise, y de hecho se ignora por omisión hasta que alguien la ve en producción; una rama sin cubrir en una expresión exhaustiva no compila. La diferencia entre ambos regímenes es la diferencia entre confiar en la disciplina del equipo y confiar en el compilador.

⚠️
Los reintentos también son una regla de negocio

Dónde vive la política de reintentos es una pregunta que casi nunca se hace explícita y cuya respuesta por defecto suele ser la peor: el cliente de red, configurado globalmente para todo. Que una consulta de catálogo se reintente tres veces es sensato; que se reintente una orden de pago puede duplicar un cargo. La decisión pertenece al repositorio, que es quien sabe qué significa cada operación, y no a una capa de transporte que solo ve rutas.

Hay una distinción que conviene hacer explícita porque ordena todo lo demás. Los fallos esperados forman parte del dominio: que no haya conexión, que el recurso no exista, que el servidor rechace la operación. Son situaciones previstas y merecen un tipo en el contrato. Los fallos inesperados, en cambio, son errores de programación disfrazados y no deberían modelarse como casos: deben registrarse, notificarse y colapsar en un único caso genérico, porque enumerar sus variantes solo consigue que la pantalla tenga que manejar situaciones que nadie sabe explicar en lenguaje humano.

Una capa de datos es una teoría sobre qué significa que algo sea cierto

Vale la pena mirar esta capa con una lente que rara vez se usa y que explica de golpe casi todas sus decisiones de diseño. Un repositorio no es un mecanismo de acceso a datos: es la implementación de una teoría sobre qué cuenta como verdad en tu aplicación, y esa teoría es una decisión de producto disfrazada de decisión técnica. Cuando eliges que la base local sea la fuente única y la red solo la sincronice, estás afirmando que el usuario debe ver lo último que sabemos aunque no sea lo último que hay; cuando eliges lo contrario, estás afirmando que es preferible no mostrar nada antes que mostrar algo desactualizado. Ninguna de las dos afirmaciones es más correcta en abstracto: una aplicación de mensajería y una de precios bursátiles tienen respuestas opuestas y ambas son sensatas. Lo que no es sensato es no haber elegido, y ese es el estado real de la mayoría de las bases de código, donde la respuesta a qué es cierto depende de qué pantalla se abrió antes y de qué petición terminó primero. De aquí se sigue la razón profunda de que la fuente única de verdad sea la propiedad central de toda la capa y no una recomendación entre otras. En cuanto existen dos lugares que pueden contestar a la misma pregunta, existe la posibilidad de que contesten distinto, y la coherencia deja de ser una propiedad del sistema para convertirse en una coincidencia estadística que se sostiene mientras las latencias sean amables. Los fallos que produce esa situación son característicos y a la vez casi imposibles de reproducir: dos pantallas que muestran importes distintos del mismo pedido, un contador que baja al volver atrás, un elemento que reaparece después de borrarlo. Todos ellos comparten la misma causa, que no es un error de código sino una arquitectura que nunca decidió quién manda. Y hay una consecuencia final que conviene enunciar, porque es donde esta capa deja de ser un asunto de organización y pasa a ser un asunto de honestidad. Una vez que el repositorio es el único que puede responder qué es cierto, también es el único que puede decir no lo sé, y esa respuesta necesita existir en el contrato con la misma dignidad que las demás. Un sistema que solo sabe expresar tengo el dato o ha fallado algo es un sistema que mentirá en el caso intermedio, que además es el más frecuente: tengo un dato viejo y estoy intentando mejorarlo. Modelar bien ese caso es lo que separa una aplicación que se comporta con dignidad en el metro de una que se queda en blanco.

⚔️ Convierte un reenviador en un repositorio
  1. Elige un repositorio de tu proyecto y comprueba si sus métodos son una copia de los del cliente de red. Si lo son, escribe la lista de decisiones que debería estar tomando y no toma.
  2. Localiza cualquier fuente de datos que escriba en otra fuente o que la conozca, y traslada esa coordinación al repositorio.
  3. Sigue el rastro de un tipo generado por tu cliente de red y anota cuántos ficheros lo mencionan. Introduce un modelo de dominio y vuelve a contar.
  4. Sustituye una fuente local por una implementación en memoria y prueba la lógica del repositorio sin dispositivo. Anota qué te lo impide, porque eso es tu deuda real.
  5. Enumera los fallos que hoy suben como excepciones hasta la pantalla y clasifícalos en esperados e inesperados. Modela los primeros en el contrato y colapsa los segundos en un único caso.