Cleaner Flutter Vol. 7: Where the data comes from
Seriously, where the heck does it come from?
Where we left
The previous volume began with our data layer, a layer where we implement all the actions described in the domain layer to be able to interact with an external service, or a local service (some database on the device or contact with the hardware where we run).
As the title says, let's talk about where the data comes from. We already have our models where we establish what data we are going to use, now we have to go and bring them.
We call classes DataSource
and they bring us the "raw" data, convert it into the respective models and pass it to their repository. But there are also other nomenclatures, as in the Bloc library, they are called DataProvider
.
You can call it whatever you want, it seems to me that the name DataProvider
would be better if Providers
of these classes were created.
Since we don't create Providers
for DataSources
in riverpod
, the name is enough for me.
More of this is in the article on business logic.
The source of the data
To define what a DataSource
is, I'm going to borrow the concept of DataProvider
from bloclibrary.dev...
They provide raw data, they should be generic and versatile. They will usually expose simple APIs to perform CRUD operations.
When I use DataSources
I separate them into two folders: local and remote. This is because DataSources
are a way to bring information, which as I mentioned before can be on the same device or to an external API where we have to use HTTP or some other internet protocol.
A common question is why we don't use repositories to do everything the DataSource
does. Mainly because many possible actions are carried out with the data that we want to bring.
A case that happens to me quite often is that I want to save some data in a local database --my preferred option is hive
-- and I bring the data from a REST API. The save-to-device action and the API call action are two different functions. In addition to being two actions that are executed in two different data sources.
Given the above, my repository is in charge of establishing this flow with two different data sources and is in charge of handling the data returned by various data sources and subsequently communicating that result to the business logic.
Implementing a DataSource
LocalDataSource
import 'dart:convert';
import 'package:hive/hive.dart' show Box;
abstract class ILocalDataSource {
SomeModel getSomeModel();
Future<void> saveSomeModel(SomeModel model);
}
class LocalDataSource implements ILocalDataSource {
LocalDataSource({required Box box}) : _box = box;
final Box _box;
@override
SomeModel getSomeModel() {
try {
final encodedJson = _box.get('model_key');
final model = SomeModel.fromJson(json.decode(encodedJson));
return model;
} catch (_) {
throw CacheException();
}
}
@override
Future<void> saveSomeModel(SomeModel model) async {
try {
await _box.put('model_key', json.encode(model.toJson()));
} catch (_) {
throw CacheException();
}
}
}
This is what a DataSource
looks like locally. I reuse this structure to store unique values in a Box
, which is the equivalent of a table in SQL.
I always recommend that every class that you are going to test make an abstract class, so it is easier for them to make their mocks with packages like mocktail
.
In the same way, define the dependencies that you are going to use as properties of the class to only provide you with an object with which you can perform the actions you need. We will see this last point in more detail in the RemoteDataSource
.
RemoteDataSource
import 'package:dio/dio.dart';
abstract class IRemoteDataSource {
Future<SomeModel> getModel(int modelId);
}
class RemoteDataSource implements IRemoteDataSource {
RemoteDataSource({
required Dio client
}) : _client = client;
final Dio _client;
@override
Future<SomeModel> getModel(int modelId) async {
try {
final response = await _client.get('/models/$modelId/');
if (response.statusCode != 200) throw ServerException();
return SomeModel.fromJson(response.data);
} catch (e) {
throw ServerException();
}
}
}
Some packages make it much easier for us to structure our code and package:dio
is one of them. If you want to learn more about dio
, check here.
It is a very similar structure to the LocalDataSource
. We have a dependency injection pattern where we still define dependencies as properties and pass an instance to our private property. All equal.
The difference lies in the implementation of how we get the data, one accesses the database with hive
and the other goes to a remote API to take this data through dio
(HTTP).
Other implementations
For both types of DataSources
, we can have different implementations so we make an interface that we can implement and define the body of the methods that we are going to use.
import 'package:http/http.dart' show Client;
class AnotherRemoteDataSource implements IRemoteDataSource {
AnotherRemoteDataSource({
required Uri url,
required Client client,
}) : _url = url,
_client = client;
final Uri _url;
final Client _client;
@override
Future<SomeModel> getModel() async {
try {
final result = await _client.get(_url);
if (response.statusCode != 200) throw ServerException();
final decode = json.decode(result.body);
return SomeModel.fromJson(decode);
} catch (e) {
throw ServerException();
}
}
}
This other implementation uses Dart's official http
package and we can see that the result will be the same, only with a different way of doing it with another package.
There are 2 great advantages that package:dio
offers us compared to HTTP. One is decoding our response from a pure JSON string to a Dart Map <String, dynamic>
. We'll look at the other one in the next section.
Reusing code
As you have seen from previous articles, CleanScope focuses on developing a project based on specific features. By abstracting our data logic into DataSources
and Repositories
we can arrive at a structure that, combined with dio
, is very organized.
The following example is following how the creation of our data layer to be used as a dependency for our project would look like using flutter_bloc
:
void main() {
final localDataSource = LocalDataSource(box: Box());
final remoteDataSource = RemoteDataSource(
client: Dio(BaseOptions(AppConfig.apiUrl)),
);
final someRepository = SomeRepository(
localDataSource: localDataSource,
remoteDataSource: remoteDataSource,
);
runApp(App(someRepository: someRepository));
}
class App extends StatelessWidget {
const App({required this.someRepository});
final SomeRepository someRepository;
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: someRepository,
child: const AppView(),
);
}
}
In the following article, we are going to go into detail about what the repository already implemented looks like, but for the moment we know that it receives its 2 DataSources
as properties.
dio
allows us to reuse instances of its clients with specific configurations so that we make direct calls to endpoints without specifying a base URL. This operation can be seen in the GET
of the RemoteDataSource
that I built previously with dio
:
final response = await _client.get('/models/$modelId/');
That's one of the ways our DataSources
are best used.