Hay una pregunta que casi nunca nos hacemos cuando desarrollamos una pantalla: ¿de dónde viene este dato? La mayoría de las veces la respuesta está incrustada en el propio componente. Un fetch a una URL, un await sobre el cliente HTTP, un .json() y a pintar. Funciona, hasta que un día se tiene que añadir caché, o soportar modo offline, o cambiar de API REST a GraphQL, y se descubre que esa decisión está repetida en quince pantallas distintas.
El patrón Repository existe para que esa pregunta deje de importar. La pantalla pide datos. Quién los trae, desde dónde y con qué estrategia, es problema de otra capa.
1. El problema: la UI sabe demasiado
Por ejemplo, miremos este componente. Es el tipo de código que todos hemos escrito, y no tiene nada de raro a primera vista.
1function UserProfile({ userId }: { userId: string }) {
2 const [user, setUser] = useState<User | null>(null)
3
4 useEffect(() => {
5 fetch(`https://api.example.com/users/${userId}`)
6 .then((res) => res.json())
7 .then((data) => setUser(data))
8 }, [userId])
9
10 if (!user) return <Spinner />
11 return <Text>{user.name}</Text>
12}El componente sabe la URL del servidor. Sabe que se habla por HTTP. Sabe que la respuesta es JSON y que el campo se llama name. Sabe que no hay caché. Cada una de esas decisiones es una correa que ata la UI a una infraestructura concreta.
El día que el backend cambie /users/:id por /v2/profiles/:id, se va a tener que buscar y reemplazar por todo el código. El día que se quiera mostrar el último perfil cacheado mientras llega el de red, no existe una forma fácil de meterlo sin ensuciar el useEffect. La pantalla acumula responsabilidades que no le tocan.
El acoplamiento al origen de datos rara vez duele el primer día. Duele en el sexto mes, cuando la misma línea de fetch está copiada en veinte sitios y cada cambio de API es una cacería.
2. Un repositorio es una interfaz, no una clase mágica
Conviene desmontar el misticismo: un repositorio no es una librería ni un framework. Es una interfaz que describe qué datos puede dar la aplicación, sin decir cómo los obtiene.
1interface UserRepository {
2 getUser(id: string): Promise<User>
3 saveUser(user: User): Promise<void>
4}Eso es todo. El contrato dice "puedes pedirme un usuario por id y guardarme uno". No menciona HTTP, ni SQL, ni localStorage, ni JSON. Esa ausencia es justo el punto. La UI va a depender de esta interfaz, y de nada más.
La regla mental es sencilla: el repositorio habla el idioma del dominio (usuarios, pedidos, partidas), no el idioma de la infraestructura (endpoints, tablas, claves de caché). Si en la firma de un método aparece la palabra url o query, se ha escapado un detalle que debería quedarse del otro lado.
3. La interfaz primero
El orden importa. Si se empieza a desarrollar por el cliente HTTP, se acabará modelando la interfaz alrededor de la API. Si se empieza por la interfaz, se modela alrededor de lo que la aplicación necesita, y la API se adapta después.
1// domain/User.ts
2export interface User {
3 id: string
4 name: string
5 email: string
6}
7
8// domain/UserRepository.ts
9export interface UserRepository {
10 getUser(id: string): Promise<User>
11 listUsers(): Promise<User[]>
12}En este caso, User es un tipo del dominio, no la forma cruda que devuelve el servidor. Si la API responde con { user_name, user_email, created_at }, esa traducción ocurre dentro de la implementación, no en la pantalla. El dominio se mantiene limpio.
Definir el tipo del dominio pensando en lo que la UI necesita pintar, no en lo que el backend resulta que devuelve hoy. La forma de la API es un detalle volátil, mientras que la forma del dominio debería ser estable.
4. Implementaci ón 1: el repositorio remoto
Ahora sí, la primera implementación concreta. Es la que habla por red, y es la única parte del sistema que conoce la URL y el formato del servidor.
1// data/RemoteUserRepository.ts
2export class RemoteUserRepository implements UserRepository {
3 constructor(private readonly baseUrl: string) {}
4
5 async getUser(id: string): Promise<User> {
6 const res = await fetch(`${this.baseUrl}/users/${id}`)
7 if (!res.ok) throw new Error(`getUser failed: ${res.status}`)
8 const dto = await res.json()
9 return this.toDomain(dto)
10 }
11
12 async listUsers(): Promise<User[]> {
13 const res = await fetch(`${this.baseUrl}/users`)
14 if (!res.ok) throw new Error(`listUsers failed: ${res.status}`)
15 const list = await res.json()
16 return list.map((dto: unknown) => this.toDomain(dto))
17 }
18
19 private toDomain(dto: any): User {
20 return { id: dto.id, name: dto.user_name, email: dto.user_email }
21 }
22}El método toDomain es el que traduce el DTO del servidor (user_name, user_email) al tipo de dominio (name, email). Toda la fealdad del contrato externo queda encerrada aquí dentro. Si la API cambia los nombres de los campos, este es el único archivo que tocas.
5. Implementación 2: caché y disco tras la misma interfaz
Aquí es donde el patrón empieza a pagar. Se quiere te caché en memoria, y quizá persistencia en disco para modo offline. Ninguna de esas necesidades obliga a tocar la pantalla, porque todas implementan la misma interfaz.
1// data/CachedUserRepository.ts
2export class CachedUserRepository implements UserRepository {
3 private cache = new Map<string, User>()
4
5 constructor(private readonly remote: UserRepository) {}
6
7 async getUser(id: string): Promise<User> {
8 const cached = this.cache.get(id)
9 if (cached) return cached
10
11 const user = await this.remote.getUser(id)
12 this.cache.set(id, user)
13 return user
14 }
15
16 async listUsers(): Promise<User[]> {
17 return this.remote.listUsers()
18 }
19}Esto es un decorador: un repositorio que envuelve a otro repositorio. CachedUserRepository no sabe ni le importa si el remote que recibe habla por HTTP, por GraphQL o lee de un fichero. Solo sabe que cumple el contrato UserRepository. Esto permite apilar capas como cebollas: caché sobre disco sobre red, cada una ajena a las demás.
El truco de que la caché reciba otro UserRepository por el constructor (y no un cliente HTTP concreto) es lo que permite componerlas. La caché podría envolver a la implementación remota, a una de disco, o a un fake en tests, sin enterarse de la diferencia.
Una versión con persistencia en disco sigue exactamente la misma forma:
1// data/PersistentUserRepository.ts
2export class PersistentUserRepository implements UserRepository {
3 constructor(
4 private readonly remote: UserRepository,
5 private readonly storage: Storage,
6 ) {}
7
8 async getUser(id: string): Promise<User> {
9 try {
10 const fresh = await this.remote.getUser(id)
11 this.storage.setItem(`user:${id}`, JSON.stringify(fresh))
12 return fresh
13 } catch {
14 const offline = this.storage.getItem(`user:${id}`)
15 if (offline) return JSON.parse(offline) as User
16 throw new Error(`user ${id} unavailable offline`)
17 }
18 }
19
20 async listUsers(): Promise<User[]> {
21 return this.remote.listUsers()
22 }
23}Estrategia red-primero con respaldo en disco, y la pantalla ni se entera. El componente sigue llamando a getUser y recibiendo un User.
6. Cambiar la fuente sin tocar una pantalla
La pieza que cierra el círculo es la inyección de dependencias. La pantalla no construye su repositorio, lo recibe. Así, decidir qué implementación usar es una sola línea en un punto central, no una edición repartida por toda la UI.
1// composition root: el único sitio que conoce las implementaciones
2const remote = new RemoteUserRepository('https://api.example.com')
3const cached = new CachedUserRepository(remote)
4const userRepository: UserRepository = cachedY la pantalla, ahora, no menciona ni URL ni caché:
1function UserProfile({ userId, repo }: { userId: string; repo: UserRepository }) {
2 const [user, setUser] = useState<User | null>(null)
3
4 useEffect(() => {
5 repo.getUser(userId).then(setUser)
6 }, [userId, repo])
7
8 if (!user) return <Spinner />
9 return <Text>{user.name}</Text>
10}Comparando esta versión con la del principio, lo que más destaca es que desapareció la URL. Desapareció el .json(). Desapareció el conocimiento de que existe una red. Para añadir caché, modo offline, o migrar a GraphQL, basta con cambia la línea del composition root. La pantalla queda intacta.
Cambiar de dónde vienen los datos no debería ser un refactor, debería ser una línea. El patrón Repository convierte una decisión de infraestructura en una elección de cableado.
7. El regalo oculto: tests sin red
El beneficio que más se subestima del patrón es lo que hace por los tests. Si la pantalla depende de una interfaz, en un test se le puede pasar una implementación falsa que devuelve datos en memoria, sin red, sin mocks frágiles de fetch, sin servidores de mentira.
1// test/FakeUserRepository.ts
2export class FakeUserRepository implements UserRepository {
3 constructor(private readonly users: Record<string, User>) {}
4
5 async getUser(id: string): Promise<User> {
6 const user = this.users[id]
7 if (!user) throw new Error(`no user ${id}`)
8 return user
9 }
10
11 async listUsers(): Promise<User[]> {
12 return Object.values(this.users)
13 }
14}
15
16// en el test
17const repo = new FakeUserRepository({
18 '1': { id: '1', name: 'Ada', email: 'ada@example.com' },
19})
20render(<UserProfile userId="1" repo={repo} />)No hay jest.mock, no hay que interceptar el módulo de red, no hay que esperar timeouts. El fake cumple el contrato y se acabó. Los tests corren rápido y son deterministas, porque no dependen de nada externo. Este fake es además la prueba viviente de que la abstracción está bien hecha: si fuera fácil escribirlo, la interfaz está limpia, y si para escribirlo es necesario reimplementar media red, es señal de que se escaparon detalles de infraestructura al contrato.
8. Cuándo NO usarlo
El patrón tiene un coste: una interfaz, una o varias implementaciones, y un punto de composición. En una app de tres pantallas que pega cuatro fetch a una API que controlas tú y que no va a cambiar, envolver todo en repositorios es ceremonia que no compra nada.
La pregunta honesta es: ¿esperas que el origen del dato cambie? ¿O que necesites más de una fuente? ¿O que los tests sufran por depender de la red? Si la respuesta a las tres es no, un fetch directo está bien y el patrón sería abstracción prematura.
Es aconsejable introducir el repositorio cuando aparezca la segunda fuente de datos (caché, offline, otra API) o cuando los tests empiecen a pelearse con la red. Antes de eso, suele ser pronto.
El valor del patrón no está en la pureza arquitectónica, está en el momento concreto en que te piden "muestra el dato cacheado mientras carga el de red" y resulta que lo resuelves en un archivo nuevo sin abrir una sola pantalla. Ese día, la interfaz que escribiste meses atrás se paga sola.
Conclusión
Todo el patrón se sostiene sobre una sola idea: separar qué datos necesita la aplicación de cómo y desde dónde se obtienen. La interfaz captura el qué en el idioma del dominio; las implementaciones esconden el cómo, cada una ajena a las demás.
De ahí salen el resto de propiedades, que no son trucos independientes sino consecuencias de esa única separación:
- La UI deja de saber de infraestructura. No hay URLs, ni
.json(), ni claves de caché en las pantallas. Solo una dependencia hacia una interfaz. - Las fuentes se componen como cebollas. Caché sobre disco sobre red, cada capa envolviendo a otro
UserRepositorysin enterarse de qué hay debajo. - Cambiar el origen es una línea. La decisión vive en el
composition root, no repartida por veinte componentes. - Los tests corren sin red. Un fake que cumple el contrato basta, y su sencillez es la prueba de que la abstracción está limpia.
Y la contrapartida, que conviene no olvidar: el patrón cuesta una interfaz, sus implementaciones y un punto de composición. Si el origen del dato no va a cambiar, no hay segunda fuente a la vista y los tests no sufren, un fetch directo es la respuesta correcta. El repositorio se introduce cuando aparece la segunda fuente o cuando la red empieza a estorbar en los tests, no antes.
En el fondo, el patrón Repository no va de datos, va de fronteras. Trazas una línea entre el dominio y el mundo exterior, y a partir de ahí la pregunta "¿de dónde viene este dato?" deja de importarle a quien lo pinta.

