Flutter TDD Clean Architecture Course [4] – Data Layer Overview & Models

While the domain layer is the safe center of an app which is independent of other layers, the data layer is a place where the app meets with the harsh outside world of APIs and 3rd party libraries. It consists of low-level Data SourcesRepositories which are the single source of truth for the data, and finally Models.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

Going Outwards

You may have noticed that we always start working from the inner parts of the app and work our way to the outskirts. After all, we started with the completely independent  domain and even there we first created the Entity. Now, we'll start with the Model and only then go ahead and implement the Repository and the low-level Data Sources. Why?

The entire Clean Architecture is predicated on one basic principle - outer layers depend on inner layers, as signified by those vertical arrows ---> at the famous picture below.

Dependencies flow inward

Therefore, it makes sense to start at the center, since otherwise we'd have a hard time implementing the Use Cases, for example, if we didn't have any Entities which can be returned first.

All of this is possible only through the power of contracts, which in the case of Dart are implemented as abstract classes. Think about it, we've fully implemented the domain layer using TDD and yet, all that the Use Cases depend on is just an abstract NumberTriviaRepository class which we've mocked with fake implementation all along.

Data Layer Overview

We know how the repository implementation's public-facing interface is going to look like (it's defined by the following contract from the domain layer).

number_trivia_repository.dart

abstract class NumberTriviaRepository {
  Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number);
  Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia();
}

Of course, this data cannot be pulled out of thin air, so before creating the Repository implementation, we will have to make the remote and local Data Sources first.

Again, we won't need to create their implementations straight away. It will be enough to make their contracts, which they must fulfill, using abstract classes. We will then mock these data sources while writing tests for the repository in the later parts of this course. But first...

Data layer operates with Models

The Need for Models

Method return types of the data sources will be very similar to the ones in the repository but with two HUGE differences. They're not going to return the errors "inline" by using Failures, instead they will throw exceptions. Also, rather than returning NumberTrivia entities, they're going to return NumberTriviaModel objects.

All of this happens because data sources are at the absolute boundary between the nice and cozy world of our own code and the scary outside world of  APIs and 3rd party libraries.

Models are entities with some additional functionality added on top. In our case, that will be the ability to be serialized and deserialized to/from JSON. The API will respond with data in a JSON format, so we need to have a way to convert it to Dart objects.

Some purists say that models should contain only some extra fields (ID from a database...) and not any conversion logic, which should be put into separate Mapper classes instead.

It all depends on circumstances - we don't have any extra fields to add and I feel that conversion code right inside a model is simpler to use.

You may have noticed that the NumberTriviaModel will contain some JSON conversion logic. This word should start flaring red lights in your head because it means employing test-driven development again.

Implementing Model with TDD

The file for the model itself will live under the models folder for the number_trivia feature. As always, its accompanying test will be at the same location, only relative to the test folder.

The "production" model file

Since the relation between the Model and the Entity is very important, we will test it to be able to have a good night's sleep.

number_trivia_model_test.dart

import 'package:clean_architecture_tdd_prep/features/number_trivia/data/models/number_trivia_model.dart';
import 'package:clean_architecture_tdd_prep/features/number_trivia/domain/entities/number_trivia.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  final tNumberTriviaModel = NumberTriviaModel(number: 1, text: 'Test Text');

  test(
    'should be a subclass of NumberTrivia entity',
    () async {
      // assert
      expect(tNumberTriviaModel, isA<NumberTrivia>());
    },
  );
}

To make the above test compile and pass, the NumberTriviaModel will extend NumberTrivia and simply pass all the constructor parameters to the super class.

number_trivia_model.dart

import 'package:meta/meta.dart';

import '../../domain/entities/number_trivia.dart';

class NumberTriviaModel extends NumberTrivia {
  NumberTriviaModel({
    @required String text,
    @required int number,
  }) : super(
          text: text,
          number: number,
        );
}

Let's first conversion logic we'll implement will be the fromJson method which should return a NumberTriviaModel instance with the same data as is present inside the JSON string.

We aren't going to get the JSON string from the "live" Numbers API. Instead, we will create a fixture which is just a regular JSON file used for testing. That's because we want to have a predictable JSON string to test with - for example, what if the Numbers API is under maintenance? We don't want any outside forces to mess with the results of our tests.

"A test fixture is something used to consistently test some item, device, or piece of software."
Wikipedia

Creating Fixtures

The content of the fixture will mimic the JSON response from the API. Let's see how the response from the random endpoint (http://numbersapi.com/random/trivia?json) looks like.

response.json

{
 "text": "418 is the error code for \"I'm a teapot\" in the Hyper Text Coffee Pot Control Protocol.",
 "number": 418,
 "found": true,
 "type": "trivia"
}

We have to make sure to know about all the different edge cases for a response. For example, the number doesn't always have to be a nice integer. Sometimes, it can be something which Dart would regard as a double, even though it's really just an integer.

response.json

{
 "text": "4e+185 is the number of planck volumes in the observable universe.",
 "number": 4e+185,
 "found": true,
 "type": "trivia"
}

We will now take these responses and "freeze them in-place" by creating two fixtures - trivia.json and trivia_double.json. They will all go into a folder called fixtures which is nested right inside the test folder.

Fixtures inside a folder

While these JSON fixture files have to have the same fields as the actual responses, we'll make their values simpler to make it easier for us to write tests with them.

trivia.json

{
  "text": "Test Text",
  "number": 1,
  "found": true,
  "type": "trivia"
}

While the API responded a number 4e+185 (which is a double in Dart's eyes), we can achieve the same with a number 1.0 - it's still practically an integer, but Dart will handle it as a double.

trivia_double.json

{
  "text": "Test Text",
  "number": 1.0,
  "found": true,
  "type": "trivia"
}

Reading Fixture Files

We've now put fake JSON responses into files. To use the JSON contained inside of them, we have to have a way to get the content of these files as a String. For that, we're going to create a top-level function called fixture inside a file fixture_reader.dart (it goes into the fixture folder).

fixture_reader.dart

import 'dart:io';

String fixture(String name) => File('test/fixtures/$name').readAsStringSync();

fromJson

With all the fixtures in place, we can finally start with the fromJson method. In the spirit of TDD, we'll write the tests first. As is a custom in the Dart, fromJson always takes a Map<String, dynamic> as an argument and outputs a type, in this case the NumberTriviaModel.

number_trivia_model_test.dart

void main() {
  final tNumberTriviaModel = NumberTriviaModel(number: 1, text: 'Test Text');
  ...
  group('fromJson', () {
    test(
      'should return a valid model when the JSON number is an integer',
      () async {
        // arrange
        final Map<String, dynamic> jsonMap =
            json.decode(fixture('trivia.json'));
        // act
        final result = NumberTriviaModel.fromJson(jsonMap);
        // assert
        expect(result, tNumberTriviaModel);
      },
    );
  });
}

As you can see, we've gotten the JSON Map from the trivia.json fixture file. This test will fail, in fact, it won't compile! Let's implement the fromJson method.

number_trivia_model.dart

class NumberTriviaModel extends NumberTrivia {
  ...
  factory NumberTriviaModel.fromJson(Map<String, dynamic> json) {
    return NumberTriviaModel(
      text: json['text'],
      number: json['number'],
    );
  }
}

After running the test, it passes! Sooo, we're cool, right? Not yet. We also have to test for all the edge cases, like when the number inside the JSON will be regarded as a double (when the value is 4e+185 or 1.0) Let's make a test for that.

number_trivia_model_test.dart

group('fromJson', () {
  ...
  test(
    'should return a valid model when the JSON number is regarded as a double',
    () async {
      // arrange
      final Map<String, dynamic> jsonMap =
          json.decode(fixture('trivia_double.json'));
      // act
      final result = NumberTriviaModel.fromJson(jsonMap);
      // assert
      expect(result, tNumberTriviaModel);
    },
  );
});

Oh no, now the test fails saying that "type 'double' is not a subtype of type 'int'". Hmm, could it be that it's because the field number is of type int? What if we explicitly cast the double into an int? Will it work then?

number_trivia_model.dart

...
factory NumberTriviaModel.fromJson(Map<String, dynamic> json) {
  return NumberTriviaModel(
    text: json['text'],
    // The 'num' type can be both a 'double' and an 'int'
    number: (json['number'] as num).toInt(),
  );
}
...

Sure, this fixed the implicit casting error and now the test passes!

Ain't nothing better than a test which passes...

toJson

Let's now head for the second conversion method - toJson. By convention, this is an instance method returning a Map<String, dynamic>. Writing the test first...

number_trivia_model_test.dart

...
final tNumberTriviaModel = NumberTriviaModel(number: 1, text: 'Test Text');
...
group('toJson', () {
  test(
    'should return a JSON map containing the proper data',
    () async {
      // act
      final result = tNumberTriviaModel.toJson();
      // assert
      final expectedJsonMap = {
        "text": "Test Text",
        "number": 1,
      };
      expect(result, expectedJsonMap);
    },
  );
});
...

Before even running it, we're again going to implement the toJson method to get rid of "method not present" errors.

number_trivia_model.dart

class NumberTriviaModel extends NumberTrivia {
  ...
  Map<String, dynamic> toJson() {
    return {
      'text': text,
      'number': number,
    };
  }
}

And of course, the test passes. Since there aren't any edge cases when converting to JSON (we're controlling the data types here, after all), this single test is sufficient the toJson method.

What's Next

Now that we have the Model which can be converted to/from JSON in place, we are going to start working on the Repository implementation and Data Source contracts. Subscribe below to become a member of growth-oriented Flutter developers and receive emails when a new tutorial comes out and more!

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

>