LogoCésar Alberca
- 7 minutes

How I've Been Abstracting Data Access for 5 Years

Design Better Frontend Architecture
Complex Frontend Architectural Patterns made easy. Timely and directly to your inbox. Become a better Frontend Architect.
I respect your privacy. Unsubscribe at any time.

The repository pattern abstracts how a piece of code that interacts with data. Are you making API calls? Accessing the database? Reading from localStorage? Well, that could be a repository!

The Repository Pattern abstracts how we interact with data that comes from external systems.

In the frontend ecosystem, we typically see API calls done directly in the components. To me, this makes you sacrifice a lot of flexibility, resulting in systems that are harder to scale. Why? Because data access couples your system with third parties.

Even if you have complete control over your backend, I would still consider it as a third party. If you consider your backend a third party, you can make your systems less coupled.

If your application has to do with intergalactic trips, even though how you access the data is prone to change (the API might change the , change the object signature, etc...) the concept that you need to obtain a list of trips to show the user is not going to change as much.

And that's what repositories are for.

#Defining the Repository Contract

Following the intergalactic trip application, let's check how we could start defining a repository. Let's create the DestinationRepository:

export interface DestinationRepository { findAll(): Promise<Destination[]> }

An interface? Yes! It helps us make our code more robust, given that when we deal with abstractions rather than concretions, our code tends to be more flexible.

When we create an interface, that means that we need to implement it somewhere.

#Implementating the Repository

To implement the repository, I do something different. I create the repository interface in a layer (that basically means a folder) and the repository implementation in a different layer (another folder). Why would I complicate myself so much?

Well, that's what we call a Port and Adapter ArchitectureOpen in a new tab, which makes our code more robust. This type of architecture is not the main point of this post, so we'll talk more in detail about it some other new Date().day.

Let's now implement DestinationApiRepository:.

export class DestinationApiRepository implements DestinationRepository { async findAll(): Promise<Destination[]> { const response = await fetch('/api/destinations') if (!response.ok) { throw new GetDestinationsError() } const data = (await response.json()) as Destination[] return data } }

For now, it might seem like it's overengineering, right? Here is a list of several questions you might have:

  • Why shouldn't I directly call the API from my React component?
  • What's the difference between a repository and a service?
  • Why have an interface when I could just rename DestinationApiRepository to DestinationRepository?

Let's answer these questions one by one.

#Questions

Why shouldn't I directly call the API from my React component? Because of S.O.L.I.D.Open in a new tab of course! Components are in charge of rendering, so they should have rendering logic. Repositories are in charge of accessing data. If we mix them both, we are going to have a harder time changing the code. This separation makes our code easier to reason and easier to change.

What's the difference between a repository and a service? Services are... well, they tend to be everything! And that's a problem, we call everything a service. I prefer finding names that reflect better the responsibility of each part of my code. I even go further and introduce the concept of use cases, which I've talked about it in past issues of my Frontend Architecture Newsletter.

Why have an interface when I could just rename DestinationApiRepository to DestinationRepository? Well, we can have multiple implementations of a repository! To me, that's extremely useful. Have you ever needed to work with data from an API Rest which was not finished yet? Well, you can implement a DestinationInMemoryRepository:

export class DestinationInMemoryRepository implements DestinationRepository { data = [DestinationMother.europe()] async findAll(): Promise<Destination[]> { return this.data } async create(createDestination: CreateDestination): Promise<void> { this.data.push({ ...createDestination, id: (Math.random() * 1000).toString() }) } }

Wonder what is DestinationMother? It's a design pattern by Martin FowlerOpen in a new tab where you handle your fake data using Mothers, giving meaningful names to data you might use in tests. You can read more about the Mother design pattern in this blogpost.

Now, we can change the dependencies at compile time:

// Before // export const destinationApiRepository = new DestinationApiRepository() // After export const destinationInMemoryRepository = new DestinationInMemoryRepository() export const getDestinationsQry = new GetDestinationsQry(destinationInMemoryRepository, destinationOrderer)

Or during runtime:

export const destinationApiRepository = new DestinationApiRepository() export const destinationInMemoryRepository = new DestinationInMemoryRepository() export const getDestinationsQry = new GetDestinationsQry(process.env.NODE_ENV === 'development' ? destinationInMemoryRepository : destinationApiRepository, destinationOrderer)

Which makes this a very powerful pattern for dealing with scenarios where you might need to dynamically change your source of data. For example, if you are using a paid API during development, you don't want to make requests to said API, you can just add an if statement to use the in memory repository.

#Insights

Having reached almost the end of the post, let me share an insightful tip with you. You can create reusable interfaces to have a unified way of calling your repositories:

export interface FindableAll<Result> { findAll(): Promise<Result[]> } export interface Creatable<Params, Result = void> { create(params: Params): Promise<Result> } export interface DestinationRepository extends FindableAll<Destination>, Creatable<CreateDestination> {}

Forget about remembering if you call things create, upsert, save or other different names for dealing with entity creation! Enforcing things with the compilation will always beat enforcing things because they might be documented. The code is the documentation.

#Conclusion

The Repository Pattern deals with data. More specifically, what we can do with data (Repository Interface) and how and where do we obtain it (Repository Implementation).

It enables swapping your repositories during compile and runtime, encapsulates your code and provides consistency.

Thank you for reading this post
Do you want to support ideas worth sharing? Supporting this blog buys me time to write.
More time means more value for you.