Cleaner Flutter Vol. 7: Where the data comes from

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).

Data Layer in CleanScope

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.

DataSources folder structure

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.

https://cdn.hashnode.com/res/hashnode/image/upload/v1644423849930/rFyvqPMrx.gif

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.

https://cdn.hashnode.com/res/hashnode/image/upload/v1644423851664/uevnjlUhO.gif