Cleaner Flutter Vol. 8: Implementing contracts

Cleaner Flutter Vol. 8: Implementing contracts

Paying some debts

We have already talked about repositories, I would say quite a bit. But we still have one more part to cover about them, their implementation. At this point in the volumes of the series, we have established actions that we want to take through DataSources.

In general, implementing a repository is straightforward. Since they are only a class that manages the data sources and, through them, creates a complete data flow.

In the previous volume, we saw about DataSources or DataProviders, so we are going to include them as dependencies of our repository.

class SomeRepository implements ISomeRepository {
    SomeRepository({
      required ILocalDataSource localDataSource,
      required IRemoteDataSource remoteDataSource,
    }) :  _localDataSource = localDataSource,
      _remoteDataSource = remoteDataSource;

    final ILocalDataSource _localDataSource;
    final IRemoteDataSource _remoteDataSource;
}

Our repository also implements the interface (or abstract class) repository that we made from the domain layer, overriding its methods later on.

We can also see the dependency injection pattern that I like to follow, keeping variables private and only assigning it the value of a variable declared at the constructor scope.

This way of using private variables with dependency injection limits us to using this dependency indirectly and only through the object's methods, avoiding its direct use as a property (bypassing).

Implementing methods

We are going to override the getModel method that sets our abstract repository to use our data sources and define a specific data flow.

The getModel method is in charge of fetching specific data in a list based on an ID. This method uses the remoteDataSource to query the data to a REST API and from here the flow has two possible scenarios:

  1. If the API responds correctly, then the data is stored locally by the localDataSource.
  2. If the API had an error, then the last locally saved value is returned. If there is no saved value, a cache error is thrown.

The code looks like this...

class SomeRepository implements ISomeRepository {
    SomeRepository({
      required ILocalDataSource localDataSource,
      required IRemoteDataSource remoteDataSource,
    }) :  _localDataSource = localDataSource,
      _remoteDataSource = remoteDataSource;

    final ILocalDataSource _localDataSource;
    final IRemoteDataSource _remoteDataSource;

    @override
    Future<SomeModel> getModel(int modelId) async {
      try {
        final model = await _remoteDataSource.getModel(modelId);
        await _localDataSource.saveModel(model);
        return model;
      } catch (e) {
        // if the API call fails...
        try {
          final model = _localDataSource.getSavedModel();
          return model;
        } catch (e) {
          throw CacheError();
        }
      }
    }
}

So we can see that our repository was an intermediary for concrete action. We are not interested in whether the data comes via HTTP or some other protocol, a data source takes care of that. Nor if it is saved in user preferences or some secure storage.

We are interested in the specific action of bringing an element, from which come different more steps of the process where several possible ways of interacting with this data converge.

Therefore, we leave interfaces or abstract classes of the DataSource so that they can be easily replaced by other implementations. That allows us to change a data source that uses the package:shared_preferences package to one that uses package:hive and does the same thing. Thus the Liskov Substitution Principle is followed.

https://cdn.hashnode.com/res/hashnode/image/upload/v1644426974021/KjOhJGx00.gif