Data layer in Flutter V2 | Use the Repository Pattern to keep a local copy of your API data

Data layer in Flutter V2 | Use the Repository Pattern to keep a local copy of your API data

A couple of months ago I wrote an article in which I explained how to keep a local copy of data downloaded from an API using the Repository Pattern.

In this article I am going to do the same implementation but applying some improvements, making use of more packages and plugins to reduce the amount of code necessary to achieve my goal.

📽 Video version available on YouTube and Odysee

Basically what I want to achieve is to be able to keep a database copy of the data I get from a remote API, that way I can access it faster. Also, this allows the application to be used without an Internet connection.

Things I am going to explain in this article:

  • Explanation and application of the Repository Pattern in Flutter
  • How to make API requests using the dio package
  • How to interact with an SQLite database using sqflite
  • How to write models faster using json_serializable

Things that I am going to leave out of the scope of this article:

  • The UI that I am going to use is going to be minimal, this article is not focused on creating a user interface
  • I am not going to implement any data synchronization system, the reason is that there are several ways to do it, and it would be more appropriate to create a whole new article to talk about it

Repository Pattern

This development pattern is usually found in the scope of the Clean Architecture. In it we find two basic elements:

  • Data source: this component performs raw operations against the data, an example would be a class dedicated to making calls against an API, or a Data Access Object (DAO) that performs queries against a database.
  • Repository: this component articulates one or more data sources, and decides where the data should flow to or from.

In this particular example, we are going to consume this Recipe API in which we will get a list of recipes. For this I will create a class dedicated to making these API requests, this class constitutes the first data source. In order to persist this data in the database, I will create a DAO (Data Access Object) that would be the second data source.

These two data sources are going to be articulated by an object of type repository, which will decide which data source to use based on several parameters.

This would be a simplified form of the typical Repository Pattern. I won't implement all of its classic abstractions, the reason is that in my experience this way of doing it is perfect for small-medium size applications. If you have to develop a very complex application, which is going to manage a large volume of data and on which several developers are going to work, I advise you to study the classic Clean Architecture after this article and learn more about it in depth.

Project setup

Create a Flutter project and add the following dependencies to pubspec.yaml:

dependencies:
  dio: 4.0.6
  flutter:
    sdk: flutter
  json_annotation: 4.4.0
  logger: 1.1.0
  path: 1.8.0
  path_provider: 2.0.9
  provider: 6.0.2
  sqflite: 2.0.2

dev_dependencies:
  build_runner: 2.1.8
  flutter_test:
    sdk: flutter
  json_serializable: 6.1.5
  lint: 1.8.2

Now we are going to define our business layer, create a folder called domain, and in it a recipe.dart file where we will have our Recipe:

// lib/domain/recipe.dart

class Recipe {
  final int id;
  final String name;
  final String thumbnailUrl;
  final String description;

  const Recipe({
    required this.id,
    required this.name,
    required this.thumbnailUrl,
    required this.description,
  });
}

Creating the network layer

The next step is to create the necessary classes to be able to execute the network calls. First of all we are going to create in data/network/entity/ the file recipe_entity.dart where we will have the RecipeEntity network model. This model is a copy of the data that we will receive from the remote API:

// lib/data/network/entity/recipe_entity.dart

import 'package:json_annotation/json_annotation.dart';

part 'recipe_entity.g.dart';

@JsonSerializable()
class RecipeListResponse {
  final int count;
  final List<RecipeEntity> results;

  RecipeListResponse({required this.count, required this.results});

  factory RecipeListResponse.fromJson(Map<String, dynamic> json) =>
      _$RecipeListResponseFromJson(json);
}

@JsonSerializable()
class RecipeEntity {
  final int id;
  final String name;
  @JsonKey(name: 'thumbnail_url')
  final String thumbnailUrl;
  final String description;

  RecipeEntity({
    required this.id,
    required this.name,
    required this.thumbnailUrl,
    required this.description,
  });

  factory RecipeEntity.fromJson(Map<String, dynamic> json) =>
      _$RecipeEntityFromJson(json);
}

If you've followed the tutorial this far, you'll see that the IDE highlights an error in part 'recipe_entity.g.dart';. That's because the fromJson() method hasn't been generated yet. Run the following command to auto-generate it:

flutter pub run build_runner build

Now we can create the class in charge of executing the calls. We are going to use dio to achieve this while minimizing the amount of code to write:

// lib/data/network/client/api_client.dart

import 'package:dio/dio.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/network/entity/recipe_entity.dart';

class KoException implements Exception {
  final int statusCode;
  final String? message;

  const KoException({required this.statusCode, this.message});

  @override
  String toString() {
    return 'KoException: statusCode: $statusCode, message: ${message ?? 'No message specified'}';
  }
}

class ApiClient {
  final String baseUrl;
  final String apiKey;

  ApiClient({
    required this.baseUrl,
    required this.apiKey,
  });

  Future<RecipeListResponse> getRecipes() async {
    try {
      final response = await Dio().get(
        'https://$baseUrl/recipes/list',
        queryParameters: {
          'from': 0,
          'size': 20,
        },
        options: Options(
          headers: {
            'X-RapidAPI-Host': baseUrl,
            'X-RapidAPI-Key': apiKey,
          },
        ),
      );

      if (response.data != null) {
        final data = response.data;

        return RecipeListResponse.fromJson(data as Map<String, dynamic>);
      } else {
        throw Exception('Could not parse response.');
      }
    } on DioError catch (e) {
      if (e.response != null && e.response!.statusCode != null) {
        throw KoException(
          statusCode: e.response!.statusCode!,
          message: e.response!.data.toString(),
        );
      } else {
        throw Exception(e.message);
      }
    }
  }
}

Database

Now we are going to implement the database part. The first thing is to create the database model in lib/data/database/entity:

// lib/data/database/entity/recipe_db_entity.dart

class RecipeDbEntity {
  static const fieldId = 'id';
  static const fieldName = 'name';
  static const fieldThumbnailUrl = 'thumbnail_url';
  static const fieldDescription = 'description';

  final int id;
  final String name;
  final String thumbnailUrl;
  final String description;

  const RecipeDbEntity({
    required this.id,
    required this.name,
    required this.thumbnailUrl,
    required this.description,
  });

  RecipeDbEntity.fromMap(Map<String, dynamic> map)
      : id = map[fieldId] as int,
        name = map[fieldName] as String,
        thumbnailUrl = map[fieldThumbnailUrl] as String,
        description = map[fieldDescription] as String;

  Map<String, dynamic> toMap() => {
        fieldId: id,
        fieldName: name,
        fieldThumbnailUrl: thumbnailUrl,
        fieldDescription: description,
      };
}

Note on the models in this example: As you may have noticed, I am creating up to three different models that contain identical data. There are those who would put it all together in a single class, and it would not necessarily be bad. However, I prefer to have it separate because in most cases each model of each layer responds to different needs, with which they have different evolutions. Creating several models, even if they are the same at first, assures us that they will be able to have different evolutions and transformations as new requirements arise. In this way we are reducing the coupling of layers.

The next thing is to create the DAO to work with the database. First of all we create an abstract class that will serve as the base in data/database/dao/base_dao.dart:

// lib/data/database/dao/base_dao.dart

import 'package:flutter/widgets.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

abstract class BaseDao {
  static const databaseName = 'data-layer-sample-v2.db';

  static const recipeTableName = 'recipe';

  @protected
  Future<Database> getDatabase() async {
    return openDatabase(
      join(await getDatabasesPath(), databaseName),
      onCreate: (db, version) async {
        final batch = db.batch();
        _createRecipeTable(batch);
        await batch.commit();
      },
      version: 1,
    );
  }

  void _createRecipeTable(Batch batch) {
    batch.execute(
      '''
      CREATE TABLE $recipeTableName(
      ${RecipeDbEntity.fieldId} INTEGER PRIMARY KEY NOT NULL,
      ${RecipeDbEntity.fieldName} TEXT NOT NULL,
      ${RecipeDbEntity.fieldThumbnailUrl} TEXT NOT NULL,
      ${RecipeDbEntity.fieldDescription} TEXT NOT NULL
      );
      ''',
    );
  }
}

Now we create the recipes DAO:

// lib/data/database/dao/recipe_dao.dart

import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/base_dao.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';

class RecipeDao extends BaseDao {
  Future<List<RecipeDbEntity>> selectAll() async {
    final db = await getDatabase();
    final List<Map<String, dynamic>> maps =
        await db.query(BaseDao.recipeTableName);
    return List.generate(maps.length, (i) => RecipeDbEntity.fromMap(maps[i]));
  }

  Future<void> insertAll(List<RecipeDbEntity> assets) async {
    final db = await getDatabase();
    final batch = db.batch();

    for (final asset in assets) {
      batch.insert(BaseDao.recipeTableName, asset.toMap());
    }

    await batch.commit();
  }
}

Data mapping

Now that we have exclusive models of each layer we have to create a mechanism to be able to convert between them, we are going to create a Mapper class in data:

// lib/data/mapper.dart

import 'package:flutter_data_layer_repository_pattern_v2/data/database/entity/recipe_db_entity.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/network/entity/recipe_entity.dart';
import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';

class MapperException<From, To> implements Exception {
  final String message;

  const MapperException(this.message);

  @override
  String toString() {
    return 'Error when mapping class $From to $To: $message}';
  }
}

class Mapper {
  Recipe toRecipe(RecipeEntity entity) {
    try {
      return Recipe(
        id: entity.id,
        name: entity.name,
        thumbnailUrl: entity.thumbnailUrl,
        description: entity.description,
      );
    } catch (e) {
      throw MapperException<RecipeEntity, Recipe>(e.toString());
    }
  }

  List<Recipe> toRecipes(List<RecipeEntity> entities) {
    final List<Recipe> recipes = [];

    for (final entity in entities) {
      recipes.add(toRecipe(entity));
    }

    return recipes;
  }

  Recipe toRecipeFromDb(RecipeDbEntity entity) {
    try {
      return Recipe(
        id: entity.id,
        name: entity.name,
        thumbnailUrl: entity.thumbnailUrl,
        description: entity.description,
      );
    } catch (e) {
      throw MapperException<RecipeDbEntity, Recipe>(e.toString());
    }
  }

  List<Recipe> toRecipesFromDb(List<RecipeDbEntity> entities) {
    final List<Recipe> recipes = [];

    for (final entity in entities) {
      recipes.add(toRecipeFromDb(entity));
    }

    return recipes;
  }

  RecipeDbEntity toRecipeDbEntity(Recipe recipe) {
    try {
      return RecipeDbEntity(
        id: recipe.id,
        name: recipe.name,
        thumbnailUrl: recipe.thumbnailUrl,
        description: recipe.description,
      );
    } catch (e) {
      throw MapperException<Recipe, RecipeDbEntity>(e.toString());
    }
  }

  List<RecipeDbEntity> toRecipesDbEntity(List<Recipe> entities) {
    final List<RecipeDbEntity> list = [];

    for (final entity in entities) {
      list.add(toRecipeDbEntity(entity));
    }

    return list;
  }
}

Articulating the data through the repository

Now we are going to create our repository centered around Recipe which will be located in data/repository. Its function will be to provide data from the database if any, or from the remote API:

// lib/data/repository/recipe_repository.dart

import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/recipe_dao.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/mapper.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/network/client/api_client.dart';
import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';

class RecipeRepository {
  final ApiClient apiClient;
  final Mapper mapper;
  final RecipeDao recipeDao;

  RecipeRepository({
    required this.apiClient,
    required this.mapper,
    required this.recipeDao,
  });

  Future<List<Recipe>> getRecipes() async {
    // First, try to fetch the recipes from database
    final dbEntities = await recipeDao.selectAll();

    if (dbEntities.isNotEmpty) {
      return mapper.toRecipesFromDb(dbEntities);
    }

    // If the database is empty, fetch from the API, saved it to database,
    // and return it to the caller
    final response = await apiClient.getRecipes();
    final recipes = mapper.toRecipes(response.results);

    await recipeDao.insertAll(mapper.toRecipesDbEntity(recipes));

    return recipes;
  }
}

Presentation layer

In order to see the result of all of the above, we are going to create a presentation folder where we will put our display layer. The first thing is to create the widget that will support our MaterialApp:

// lib/presentation/app.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/database/dao/recipe_dao.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/mapper.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/network/client/api_client.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/repository/recipe_repository.dart';
import 'package:flutter_data_layer_repository_pattern_v2/presentation/main_screen.dart';
import 'package:logger/logger.dart';
import 'package:provider/provider.dart';

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<Logger>(
          create: (_) => Logger(
            printer: PrettyPrinter(),
            level: kDebugMode ? Level.verbose : Level.nothing,
          ),
        ),
        Provider<RecipeRepository>(
          create: (_) => RecipeRepository(
            apiClient:
                ApiClient(baseUrl: 'tasty.p.rapidapi.com', apiKey: apiKey),
            mapper: Mapper(),
            recipeDao: RecipeDao(),
          ),
        ),
      ],
      child: MaterialApp(
        home: MainScreen(),
      ),
    );
  }
}

In my case, since I have these examples published on GitHub, I have also created the lib/data.dart file where I have my API key. I do it this way to keep my password secret. In your case you can write your key directly in that class if you are not going to distribute your code.

Modify lib/main.dart as follows:

// lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_data_layer_repository_pattern_v2/presentation/app.dart';
import 'package:sqflite/sqflite.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // ignore: deprecated_member_use, avoid_redundant_argument_values
  Sqflite.devSetDebugModeOn(kDebugMode);

  runApp(App());
}

To finish, I am going to create a MainScreen widget where I will get the list of recipes, and another called RecipeDetail that will contain the specific detail of a recipe:

// lib/presentation/main_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_data_layer_repository_pattern_v2/data/repository/recipe_repository.dart';
import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';
import 'package:flutter_data_layer_repository_pattern_v2/presentation/recipe_details.dart';
import 'package:logger/logger.dart';
import 'package:provider/provider.dart';

class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Data layer sample')),
      body: FutureBuilder<List<Recipe>>(
        future: Provider.of<RecipeRepository>(context).getRecipes(),
        builder: (BuildContext context, AsyncSnapshot<List<Recipe>> snapshot) {
          if (snapshot.hasData) {
            return ListView.separated(
              itemBuilder: (context, index) {
                final recipe = snapshot.data![index];

                return ListTile(
                  leading: SizedBox(
                    width: 48.0,
                    height: 48.0,
                    child: ClipOval(
                      child: Image.network(recipe.thumbnailUrl),
                    ),
                  ),
                  title: Text(recipe.name),
                  onTap: () => Navigator.push(
                    context,
                    MaterialPageRoute(
                      builder: (context) => RecipeDetails(recipe: recipe),
                    ),
                  ),
                );
              },
              separatorBuilder: (context, index) => const Divider(),
              itemCount: snapshot.data!.length,
            );
          } else if (snapshot.hasError) {
            Provider.of<Logger>(context)
                .e('Error while fetching data: ${snapshot.error.toString()}');
            return const Center(
              child: Text('An error occurred while fetching data.'),
            );
          } else {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_data_layer_repository_pattern_v2/domain/recipe.dart';

class RecipeDetails extends StatelessWidget {
  final Recipe recipe;

  const RecipeDetails({required this.recipe});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(recipe.name),
      ),
      body: SafeArea(
        child: Container(
          padding: const EdgeInsets.only(
            left: 16.0,
            top: 24.0,
            right: 16.0,
            bottom: 24.0,
          ),
          child: Text(recipe.description),
        ),
      ),
    );
  }
}

Conclusion

This is where our little example would end. In this case we have implemented two data sources, but it could be the case that we have to implement more, for example another remote API, the device cache, etc.

Some improvements that could be made to this code would be better error handling, adding a more sophisticated presentation layer, or adding a synchronization mechanism to ensure that the data in the database is always in sync with the data in the API.

You can see the source code of the concrete example here.

Thanks for reading this far and happy coding!

Did you find this article valuable?

Support David Serrano by becoming a sponsor. Any amount is appreciated!