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:
- If the API responds correctly, then the data is stored locally by the
localDataSource
. - 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.