Back to Blog
DevelopmentJuly 7, 20266 min read

Repository Pattern: Your UI Shouldn't Know Where Data Comes From

An interface that hides whether data comes from the network, cache, or disk. How to swap the source without touching a single screen.

IM
Ignacio MelendezFull-Stack & Game Developer
Repository Pattern: Your UI Shouldn't Know Where Data Comes From

There is a question we almost never ask ourselves when building a screen: where does this data come from? Most of the time the answer is baked into the component itself. A fetch to a URL, an await on the HTTP client, a .json(), and straight to rendering. It works, until one day you have to add caching, or support offline mode, or move from a REST API to GraphQL, and you discover that decision is repeated across fifteen different screens.

The Repository pattern exists so that question stops mattering. The screen asks for data. Who brings it, from where, and with which strategy, is another layer's problem.

1. The problem: the UI knows too much

For example, let's look at this component. It's the kind of code we've all written, and there's nothing odd about it at first glance.

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}

The component knows the server URL. It knows that we talk over HTTP. It knows the response is JSON and that the field is called name. It knows there is no cache. Each of those decisions is a leash that ties the UI to a specific piece of infrastructure.

The day the backend changes /users/:id to /v2/profiles/:id, you'll have to search and replace across the whole codebase. The day you want to show the last cached profile while the network one arrives, there's no easy way to slot it in without polluting the useEffect. The screen accumulates responsibilities that aren't its own.

Coupling to the data source rarely hurts on day one. It hurts in month six, when the same fetch line is copied across twenty places and every API change becomes a hunt.

2. A repository is an interface, not a magic class

It's worth dismantling the mysticism: a repository is not a library nor a framework. It's an interface that describes what data the application can provide, without saying how it obtains it.

1interface UserRepository {
2  getUser(id: string): Promise<User>
3  saveUser(user: User): Promise<void>
4}

That's all. The contract says "you can ask me for a user by id and save me one". It doesn't mention HTTP, nor SQL, nor localStorage, nor JSON. That absence is exactly the point. The UI will depend on this interface, and on nothing else.

The mental rule is simple: the repository speaks the language of the domain (users, orders, matches), not the language of the infrastructure (endpoints, tables, cache keys). If the word url or query shows up in a method signature, a detail has leaked out that should have stayed on the other side.

3. The interface first

Order matters. If you start building from the HTTP client, you'll end up modeling the interface around the API. If you start from the interface, you model around what the application needs, and the API adapts afterward.

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}

In this case, User is a domain type, not the raw shape the server returns. If the API responds with { user_name, user_email, created_at }, that translation happens inside the implementation, not on the screen. The domain stays clean.

Define the domain type thinking about what the UI needs to render, not about what the backend happens to return today. The shape of the API is a volatile detail, whereas the shape of the domain should be stable.

4. Implementation 1: the remote repository

Now, finally, the first concrete implementation. It's the one that talks over the network, and it's the only part of the system that knows the URL and the server format.

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}

The toDomain method is the one that translates the server DTO (user_name, user_email) into the domain type (name, email). All the ugliness of the external contract stays locked in here. If the API changes the field names, this is the only file you touch.

5. Implementation 2: cache and disk behind the same interface

This is where the pattern starts to pay off. You want an in-memory cache, and maybe disk persistence for offline mode. None of those needs forces you to touch the screen, because they all implement the same interface.

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}

This is a decorator: a repository that wraps another repository. CachedUserRepository neither knows nor cares whether the remote it receives talks over HTTP, over GraphQL, or reads from a file. It only knows it satisfies the UserRepository contract. This lets you stack layers like onions: cache over disk over network, each one oblivious to the others.

The trick of having the cache receive another UserRepository through the constructor (and not a concrete HTTP client) is what makes them composable. The cache could wrap the remote implementation, a disk one, or a fake in tests, without ever noticing the difference.

A version with disk persistence follows exactly the same shape:

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}

Network-first strategy with a disk fallback, and the screen has no idea. The component keeps calling getUser and receiving a User.

6. Swapping the source without touching a screen

The piece that closes the loop is dependency injection. The screen doesn't build its repository, it receives it. That way, deciding which implementation to use is a single line in a central place, not an edit scattered across the whole UI.

1// composition root: the only place that knows the implementations
2const remote = new RemoteUserRepository('https://api.example.com')
3const cached = new CachedUserRepository(remote)
4const userRepository: UserRepository = cached

And the screen, now, mentions neither URL nor cache:

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}

Comparing this version to the one at the start, what stands out the most is that the URL disappeared. The .json() disappeared. The knowledge that a network exists disappeared. To add caching, offline mode, or migrate to GraphQL, all you need is to change the composition root line. The screen stays untouched.

Changing where the data comes from shouldn't be a refactor, it should be one line. The Repository pattern turns an infrastructure decision into a wiring choice.

7. The hidden gift: tests without the network

The most underrated benefit of the pattern is what it does for tests. If the screen depends on an interface, in a test you can hand it a fake implementation that returns in-memory data, with no network, no fragile fetch mocks, no pretend servers.

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// in the test
17const repo = new FakeUserRepository({
18  '1': { id: '1', name: 'Ada', email: 'ada@example.com' },
19})
20render(<UserProfile userId="1" repo={repo} />)

There's no jest.mock, no intercepting the network module, no waiting on timeouts. The fake satisfies the contract and that's it. The tests run fast and are deterministic, because they don't depend on anything external. This fake is also living proof that the abstraction is well made: if it's easy to write, the interface is clean, and if writing it requires reimplementing half a network stack, it's a sign that infrastructure details leaked into the contract.

8. When NOT to use it

The pattern has a cost: an interface, one or several implementations, and a composition point. In a three-screen app that fires four fetch calls at an API you control and that won't change, wrapping everything in repositories is ceremony that buys nothing.

The honest question is: do you expect the data source to change? Or that you'll need more than one source? Or that the tests will suffer from depending on the network? If the answer to all three is no, a direct fetch is fine and the pattern would be premature abstraction.

It's advisable to introduce the repository when the second data source shows up (cache, offline, another API) or when the tests start fighting the network. Before that, it's usually too early.

The value of the pattern isn't in architectural purity, it's in the concrete moment when someone asks you to "show the cached data while the network one loads" and it turns out you solve it in a new file without opening a single screen. That day, the interface you wrote months earlier pays for itself.

Conclusion

The whole pattern rests on a single idea: separate what data the application needs from how and from where it's obtained. The interface captures the what in the language of the domain; the implementations hide the how, each one oblivious to the others.

Everything else follows from there, not as independent tricks but as consequences of that single separation:

  • The UI stops knowing about infrastructure. No URLs, no .json(), no cache keys on the screens. Just a dependency on an interface.
  • Sources compose like onions. Cache over disk over network, each layer wrapping another UserRepository without knowing what's underneath.
  • Swapping the source is one line. The decision lives in the composition root, not scattered across twenty components.
  • Tests run without the network. A fake that satisfies the contract is enough, and its simplicity is proof that the abstraction is clean.

And the trade-off, worth not forgetting: the pattern costs an interface, its implementations, and a composition point. If the data source won't change, there's no second source in sight, and the tests don't suffer, a direct fetch is the right answer. The repository is introduced when the second source appears or when the network starts getting in the way of the tests, not before.

Deep down, the Repository pattern isn't about data, it's about boundaries. You draw a line between the domain and the outside world, and from then on the question "where does this data come from?" stops mattering to whoever renders it.

Related Articles

View all articles
Atomic Design in React Native: From Atoms to Screens

Atomic Design in React Native: From Atoms to Screens

Atomic Design splits the UI into atoms, molecules, and organisms. In large projects it works well. In agile ones, it can become premature abstraction. When each approach makes sense, and when feature-based is the better call.