DEV Community

loading...

The Repository pattern with Typescript

aouahib profile image Abdou Ouahib ・8 min read

Hi 👋
In this article, I'm going to talk about the Repository pattern, and show you how I implemented a Repository to handle authentication, for the app I'm currently working on, in a way that made it very easy for me to write tests for it. Then I'll show you how to write those tests. I will also briefly go through other topics such as dependency injection, abstraction...etc.

The Repository

If you don't know what a Repository is, here's a fancy definition:

Repositories are classes or components that encapsulate the logic required to access data sources. They centralize common data access functionality, providing better maintainability and decoupling the infrastructure or technology used to access databases from the domain model layer.

Now I'll try to give a more human readable version, and I'll use our authentication feature as an example.
I want users to be able to use Google to sign in to my app, the flow would be something like this:

  1. The user signs in with google.
  2. We retrieve the token returned from google, and send it to the backend.
  3. The backend sends an access token back, which we can use for all future requests.
  4. We persist the token locally, to keep the user signed in.

From this, we can see that we need 3 data sources:

  1. GoogleAuth
  2. AuthApi: that communicates with the backend
  3. LocalStorage: where we persist the logged in user

And then we'll have an AuthRepository that internally uses these 3 data sources, and exposes a simple API that we can use to handle authentication. We'll see how to implement this in a moment, but this is how a repository works in my simple point of view.

Abstraction is your friend

The AuthRepository is a very good example of abstraction. It hides implementation details from all other parties that use it. For example, a react component can use this repository, and sign the user in by just calling authRepo.signInWithGoogle(). The component doesn't care or know how the sign in is done, it doesn't know that we use 3 data sources to do the work. This abstraction is very useful, for instance, if we want to test the component, we can just mock the repository, and verify that signInWithGoogle has been called. We will be sure that the sign in will work, because we would have already written tests for AuthRepository. Let's write this abstraction:

interface IAuthRepository {
  signInWithGoogle(): Promise<User>;
}
Enter fullscreen mode Exit fullscreen mode

NOTE1: The 'I' in 'IAuthRepository' stands for Interface (duh), and It's just a naming convention.

NOTE2: There should be more function declarations in the interface, like signOut() ...etc, but I wrote only one for sake of simplicity.

And just how the repository abstracts the implementation details, the data sources also should do the same thing. Let's wite their interfaces too:

interface IGoogleAuth {
    // returns a token
    signIn(): Promise<string>
}

interface IAuthApi {
  signInWihGoogle(token: string): Promise<User>;
}

interface ILocalStorage {
    write(key: string, value: string): Promise<null>;
    read(key: string): Promise<string | null>;
}
Enter fullscreen mode Exit fullscreen mode

You can define the User type any way you want. To keep it simple, I'll just use the following:

type User = {
    accessToken: string
}
Enter fullscreen mode Exit fullscreen mode

Now we have everything we need to start implementing the AuthRepository

Implementations

We will create a class that implements IAuthRepository, and give concrete implementations for all its function declarations (in this case we only have one)

class AuthRepository implements IAuthRepository {
    signInWithGoogle(): Promise<User> {
        throw new Error("Method not implemented.");
    }
}
Enter fullscreen mode Exit fullscreen mode

We need access to our data sources inside the class, so let's make it depend on them.

class AuthRepository implements IAuthRepository {
    private _authApi: IAuthApi;
    private _googleAuth: IGoogleAuth;
    private _localStorage: ILocalStorage;

    constructor(
        authApi: IAuthApi,
        googleAuth: IGoogleAuth,
        localStorage: ILocalStorage,
    ) {
        this._authApi = authApi;
        this._googleAuth = googleAuth;
        this._localStorage = localStorage;
    }

    signInWithGoogle(): Promise<User> {
        throw new Error("Method not implemented.");
    }
}
Enter fullscreen mode Exit fullscreen mode

We simply added some private fields, and initialized them inside the constructor.
The fields should be private because it doesn't make sense to be able to access a data source from the repository.

Also, It's important to pass the dependencies in the constructor. First, we don't have to write implementations for the data sources right away as you can see. Also, injecting the dependencies like this enables us to pass-in mock implementations of the data sources, which will make testing a breeze.

Now we can write the actual implementation of signInWithGoogle():

async signInWithGoogle(): Promise<User> {
      const token = await this._googleAuth.signIn();
      const user = await this._authApi.signInWihGoogle(token);
      await this._localStorage.write('accessToken', user.accessToken);
      return user;
}
Enter fullscreen mode Exit fullscreen mode

That's it, our repository is now implemented. We can immediately start testing it, which we'll do in a moment. But before that...

ERROR HANDLING

We haven't done any error handling so far. Our repository looks very unsafe at the moment, anything can go wrong. For instance, the user's network may be unstable, and some request will timeout. Or something can go wrong with google auth, you never know. It's a very messy and unpredictable world out there in the data layer.

Yes you can wrap all calls to the repository in a try catch block but... do you really want to do that? First of all, you won't be obligated to do so, no linter rules will remind you, there won't be any compile time errors, you can easily forget it. You don't want your app to crash because you forgot to catch an error. Second of all, try catch is just ugly.

There is another benefit of using the repository pattern: You can catch all errors inside the repository, and you'll never need to write a try catch again outside of it. First, we need to specify all types of errors that can occur during authentication, to keep it simple, let's say that 2 types of errors can happen:

  1. Network error: If the user is offline or the connection is unstable
  2. General error: Something else happened

We can represent these errors in many ways, I prefer to use an enum:

enum AuthError {
  general,
  network,
}
Enter fullscreen mode Exit fullscreen mode

Then, we need to make our function return an AuthError instead of a User if some exception was raised. At first, I tried to make it return a union AuthError | User, but I didn't find a good way to check exact type of the return value. I could use a type guard, but I think there is a nicer way. The fp-ts library provides us with many functional programming goodies, including the Either type:

type Either<E, A> = Left<E> | Right<A>
Enter fullscreen mode Exit fullscreen mode

As you can see, an instance of Either is either an instance of Left or Right. Convention dictates that Left is used for failure and Right is used for success (see the docs).

Let's change the return type of our signInWithGoogle function, to return Either<AuthError, User>, we should do it in both the interface and the implemented class, here's how it'll look like:

 interface IAuthRepository {
   signInWithGoogle(): Promise<Either<AuthError, User>>;
 }
Enter fullscreen mode Exit fullscreen mode

In the implementation, we return right(user) instead of user if everything went well, if not, we return left(error):

async signInWithGoogle(): Promise<Either<AuthError,User>> {
  try {
    const token = await this._googleAuth.signIn();
    const user = await this._authApi.signInWihGoogle(token);
    await this._localStorage.write('accessToken', user.accessToken);
    return right(user);
  } catch (e) {
    // Check for the type of error here
    // and return the corresponding value
    // if (e is a network error)
    //   return left(AuthError.network);
    return left(AuthError.general);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, whenever we call signInWithGoogle, we must check if the returned value is of type left or right, and then proceed:

const result = await authRepo.signInWithGoogle();
if (isRight(result)) {
  // The sign in succeeded
  const user = result.right;
  // do something with the user
} else {
  // The sign in failed
  const error = result.left;
  // do something with the error
}
Enter fullscreen mode Exit fullscreen mode

Using this method, all exceptions stop propagating when they arrive to the repository, and they're converted to regular objects that we can deal with later.

Testing

Here comes the fun part, since we implemented our repository the way we did, it's very easy to test it now. Since all of its dependencies are injected in the constructor, we can simply create mock dependencies, and instanciate a repository with them. You can get away with this using jest mocking, but I think there's a better way.

Enter Mockito

There's a very cool library called ts-mockito, it works very well when you use dependency injection in your code (as we're doing now). It allows you to create mocks, stub function calls, and many other cool features, definitely check it out! I'm going to use it to test our AuthRepository.

Take a look at this long test file, I will walk you through it using comments:

import { left, right } from "fp-ts/lib/Either";
import { anything, instance, mock, reset, verify, when } from "ts-mockito";
/// Also import the interfaces and other things...

/// Create mock dependencies
const MockGoogleAuth = mock<IGoogleAuth>();
const MockAuthApi = mock<IAuthApi>();
const MockStorage = mock<ILocalStorage>();

/// Instatiate AuthRepository with the mocks
const authRepo: IAuthRepository = new AuthRepository(
  instance(MockAuthApi),
  instance(MockGoogleAuth),
  instance(MockStorage)
);

// Reset the mocks before each test
// So tests won't be dependent of each other
beforeEach(() => {
  reset(MockAuthApi);
  reset(MockGoogleAuth);
  reset(MockStorage);
});

// Testing `signInWithGoogle`
describe("signInWithGoogle", () => {
  // Test case 1
  test("should persist and return the user if all goes well", async () => {
    // arrange
    const googleToken = "googleToken";
    const user: User = { accessToken: "accessToken" };
    // When signIn is called on MockGoogleAuth, resolve with `googleToken`
    when(MockGoogleAuth.signIn()).thenResolve(googleToken);
    // When signInWithGoogle is called on MockAuthApi, resolve with `user`
    when(MockAuthApi.signInWihGoogle(googleToken)).thenResolve(user);
    // act
    const result = await authRepo.signInWithGoogle();
    // assert
    // the result should be `right(user)`
    expect(result).toStrictEqual(right(user));
    // MockGoogleAuth.signIn should be called once
    verify(MockGoogleAuth.signIn()).once();
    // MockAuthApi.signInWithGoogle should be called once, with `googleToken`
    verify(MockAuthApi.signInWihGoogle(googleToken)).once();
    // The access token should be persisted
    verify(MockStorage.write("accessToken", user.accessToken)).once();
  });

  test("should return an auth error if something wrong happened", async () => {
    // arrange
    // Make it so GoogleAuth throws an exception
    when(MockGoogleAuth.signIn()).thenReject(new Error("some error"));
    // act
    const result = await authRepo.signInWithGoogle();
    // assert
    // the result should be `left(AuthError.general)`
    expect(result).toStrictEqual(left(AuthError.general));
    // MockGoogleAuth.signIn should be called once
    verify(MockGoogleAuth.signIn()).once();
    /// MockAuthApi.signInWithGoogle should never be called
    verify(MockAuthApi.signInWihGoogle(anything())).never();
    // No access token should be persisted
    verify(MockStorage.write("accessToken", anything())).never();
  });
});

Enter fullscreen mode Exit fullscreen mode

Hopefully, this testing strategy was clear, and it gave you an idea of how to test your code in the future. I think it's pretty simple.

Conclusion

Here are the main points we learned throughout this article:

  • The repository: A component/class that groups multiple data sources, and exposes an API, that hides all its implementation details. It also serves as an "error buster", it catches all those wild exceptions, and returns regular objects in case of a failure.
  • Abstraction (using interfaces): Using interfaces gives you the ability to have multiple implementations for the same API (mock and real implementations for example). It also makes it easy to change AuthApi for example, from a REST implementation to GraphQL... etc.. It speeds up the development process, especially if you're working as a team. You just setup an interface, and start implementing other things that depend on it. As you saw in this article, we implemented the whole repository without having any implementations of AuthApi, nor any of its other dependencies.
  • Dependency injection: We didn't get into much details of DI in this article, but we saw how injecting dependencies into a class constructor, makes it very easy to test it. Also, without doing this, we wouldn't have the ability to implement the repository, until we implement all its dependencies. You can say that abstraction and DI complement each other.
  • Some useful libraries:
    1. ts-mockito: A very convenient library that helps you with mocking, and testing in general.
    2. fp-ts: A library that brings all of functional programming goodies to typescript. We only used the Either type in this article, but it offers a lot more.

I hope you had a good read, bye bye.
bye bye

Discussion (0)

pic
Editor guide