๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 2

๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 2

Setting up models, repositories, data sources, failures and exceptions as a separate dart package.

ยท

9 min read

In the first part of this article, we set up melos as a tool for managing monorepo projects and installed dart_frog as a web framework for building server-side apps with Dart. We also created a new Dart Frog project, which included adding the dart_frog dependency on the project's pubspec.yaml file and creating the necessary files and directories.

We will create models, data sources, repositories, and failure classes as a separate Dart package in part 2.

To do this, we will be using very_good_cli.

Setting up Models, Repositories, and Data Services ๐Ÿ’ป

Installing very_good_cli

very_good_cli, a Command-Line Interface that provides helpful commands and templates for generating various types of projects, including Flutter apps, Flame games, Flutter packages, Dart packages, federated plugins, and Dart CLI projects.

Very Good CLI makes it easy to create and set up these types of projects with a single command, so let's get started!

To install Very Good CLI, open a terminal and enter the following command:

dart pub global activate very_good_cli

To confirm that Very Good CLI has been installed correctly, you can enter the following command in a terminal very_good --version ๐Ÿš€

Creating typedefs

We will have our typedefs, which will be shared between the backend and frontend of our project. To create a new Dart package for these typedefs, we can use the following command:

very_good create -t dart_pkg typedefs

This will generate a new dart package typedefs. Here -t dart_pkg means we are creating a new dart package.

Once the typedefs package has been created and the necessary files and directories have been generated, you can navigate to typedefs/lib/src/typedefs.dart to create a new typedef.

In this file, you can create a new typedef by adding the following code:

/// Primary key type for a Todo.
typedef TodoId = int;

This will create a new typedef called TodoId, an alias for the type int. This typedef can then be used throughout the backend and frontend of your project to represent an identifier for a to-do item. Once you are done, make sure to export the file from lib/typedefs.dart

library typedefs;
export 'src/typedefs.dart';

We will add additional shared typedefs to this file as needed. GitHub Link

Creating models

Similarly, we will have a separate package to house shared data models.

  1. CreateTodoDto This will be used to send and receive payload for creating a new todo.

  2. UpdateTodoDto This will be used to send and receive payload for updating an existing todo.

  3. Todo This is the representation of the Todo entity.

To create a separate package for models you can use the very_good CLI:

very_good create -t dart_pkg models

Once the package has been created, we will install freezed for JSON serialization and value equality, as this library provides helpful tools for these tasks. We will use json_serializable for JSON serialization. To install freezed and freezed_annotation, open your terminal inside the models package and use the command:

dart pub add freezed json_serializable build_runner -d
dart pub add freezed_annotation json_annotation

Inside models/lib we will create files in the given order. We have created a parent folder for each model to house the generated codes so that it will look cleaner.

.
โ”œโ”€โ”€ models.dart
โ””โ”€โ”€ src
    โ”œโ”€โ”€ create_todo_dto
    โ”‚   โ””โ”€โ”€ create_todo_dto.dart
    โ”œโ”€โ”€ todo
    โ”‚   โ””โ”€โ”€ todo.dart
    โ””โ”€โ”€ update_todo_dto
        โ””โ”€โ”€ update_todo_dto.dart

Now, in todo.dart we will use freezed package to create a data class.

freezed package is a code generation tool that allows you to create immutable classes with concise, boilerplate-free syntax. It uses the @freezed annotation to generate a number of useful methods and operators for your class, such as ==, hashCode, and copyWith.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:typedefs/typedefs.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';

@freezed
class Todo with _$Todo {
  factory Todo({
    required TodoId id,
    required String title,
    @Default('') String description,
    @Default(false) bool? completed,
    required DateTime createdAt,
    DateTime? updatedAt,
  }) = _Todo;

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

Since TodoId is in a separate package, we will have to add it in the models/pubspec.yaml file as a dependency. After adding the pubspec.yaml should look like this.

name: models
description: A Very Good Project created by Very Good CLI.
version: 0.1.0+1
publish_to: none

environment:
  sdk: ">=2.18.0 <3.0.0"

dependencies:
  freezed_annotation: ^2.2.0
  json_annotation: ^4.7.0
  typedefs:
    path: ../typedefs

dev_dependencies:
  build_runner: ^2.3.3
  freezed: ^2.3.2
  json_serializable: ^6.5.4
  lints: ^2.0.0
  mocktail: ^0.3.0
  test: ^1.19.2
  very_good_analysis: ^3.1.0

Similarly, we will create create_todo_dto.dart for CreateDotoDto class.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'create_todo_dto.freezed.dart';
part 'create_todo_dto.g.dart';

@freezed
class CreateTodoDto with _$CreateTodoDto {
  factory CreateTodoDto({
    required String title,
    required String description,
  }) = _CreateTodoDto;

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

Similarly, we will create update_todo_dto.dart for UpdateTodoDto class.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'update_todo_dto.freezed.dart';
part 'update_todo_dto.g.dart';

@freezed
class UpdateTodoDto with _$UpdateTodoDto {
  factory UpdateTodoDto({
    String? title,
    String? description,
    bool? completed,
  }) = _UpdateTodoDto;

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

Once this is done, we run build_runner and generate the necessary code. To generate the missing code, you can run the following command:

dart pub run build_runner build --delete-conflicting-outputs

This will generate *.g.dart and *.freezed.dart files. Once this is done, you will have files inside models/lib in the given order.

.
โ”œโ”€โ”€ models.dart
โ””โ”€โ”€ src
    โ”œโ”€โ”€ create_todo_dto
    โ”‚   โ”œโ”€โ”€ create_todo_dto.dart
    โ”‚   โ”œโ”€โ”€ create_todo_dto.freezed.dart
    โ”‚   โ””โ”€โ”€ create_todo_dto.g.dart
    โ”œโ”€โ”€ todo
    โ”‚   โ”œโ”€โ”€ todo.dart
    โ”‚   โ”œโ”€โ”€ todo.freezed.dart
    โ”‚   โ””โ”€โ”€ todo.g.dart
    โ””โ”€โ”€ update_todo_dto
        โ”œโ”€โ”€ update_todo_dto.dart
        โ”œโ”€โ”€ update_todo_dto.freezed.dart
        โ””โ”€โ”€ update_todo_dto.g.dart

Once you are done creating the models, be sure to export them from lib/models.dart

library models;

export 'src/create_todo_dto/create_todo_dto.dart';
export 'src/todo/todo.dart';
export 'src/update_todo_dto/update_todo_dto.dart';

You can find the GitHub link for models here.

Creating failures

In the next step, we will create a separate package to handle failures in our application. This package will contain all the failures that may occur, such as NetworkFailure or ServerFailure. Whenever we need to handle an error, we will encapsulate the value or error in a union type and return it from a repository. For example, when we make an API call to retrieve data, we may receive either the requested data or an error. By encapsulating this in an Either type, we can effectively handle and manage failures in our app.

This is inspired by ReSo Coder's Clean Architecture tutorial.

To create the failures package:

very_good create -t dart_pkg failures

This will create a new package in failures directory. We will use freezed and json_serializable libraries for data serialization here as well.

dart pub add freezed json_serializable build_runner -d
dart pub add freezed_annotation json_annotation

To begin, we will create a Failure class inside the lib/src/failure.dart directory. This class will be the base for all failure types and will handle and manage our app's failures.

abstract class Failure {
  String get message;
}

We can then create specific failure classes, such as NetworkFailure, to return to the caller whenever there is a network exception. For instance, if we have a TodosRemoteDataSource that makes a call to a backend and encounters a HTTP exception, the data source can throw a NetworkException which will be caught by a repository TodoRepository. The repository will then encapsulate the error in a union type and return either the requested data or the failure to the caller. This allows us to effectively handle and manage failures in our app.

To create a NetworkFailure class, we will create a new file in the src directory called network_failure/network_failure.dart.

import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'network_failure.freezed.dart';
part 'network_failure.g.dart';

@freezed
class NetworkFailure extends Failure with _$NetworkFailure {
  const factory NetworkFailure({
    required String message,
    required int code,
    @Default([]) List<String> errors,
  }) = _NetworkFailure;

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

Once you have finished creating the NetworkFailure class, you can run the build_runner command to generate the necessary files for your project.

Make sure to export the failure class from failures/lib/failures.dart

library failures;

export 'src/failure.dart';
export 'src/network_failure/network_failure.dart';

You can view the code from here.

Creating data_source

We will create a new package with very_good_cli for our data source.

very_good create -t dart_pkg data_source

Once the package is created, we can create an abstract data source contract.

import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

abstract class TodoDataSource {
  Future<List<Todo>> getAllTodo();

  Future<Todo> getTodoById(TodoId id);

  Future<Todo> createTodo(CreateTodoDto todo);

  Future<Todo> updateTodo({
    required TodoId id,
    required UpdateTodoDto todo,
  });

  Future<void> deleteTodoById(TodoId id);
}

You can import the missing packages in pubspec.yaml. After importing, it should look like this.

name: data_source
description: A Very Good Project created by Very Good CLI.
version: 0.1.0+1
publish_to: none

environment:
  sdk: ">=2.18.0 <3.0.0"
dependencies:
  models:
    path: ../models
  typedefs:
    path: ../typedefs

dev_dependencies:
  mocktail: ^0.3.0
  test: ^1.19.2
  very_good_analysis: ^3.1.0

The TodoDataSource class will be used by both the frontend and backend of our app to access and manage data related to to-dos. This class will throw known exceptions that can be handled in the TodoRepository. To manage these exceptions, we will create a separate exceptions package where we can register all the exceptions used in our app. The TodoRepository will then be responsible for handling these exceptions, allowing us to effectively manage and handle errors in our application.

Once you are done adding the data source, make sure to export it in data_sources/data_sources.dart

library data_source;

export 'src/todo_data_source.dart';

You can view the code for the data_source from GitHub.

Creating repository

We will make use of the TodoDataSource class to access and manage data related to to-dos. We will also catch any exceptions thrown by the TodoDataSource and serialize them as failures. TodoRepository will then return the failure or the requested data to the caller.

To create the TodoRepository class, we will first navigate to the root directory of our project and create a new package using the very_good CLI:

very_good create -t dart_pkg repository

Once we are done, we will add all the dependencies in pubspec.yaml and run dart pub get to get all the packages.

dependencies:
  either_dart: ^0.3.0
  models:
    path: ../models
  failures:
    path: ../failures
  typedefs:
    path: ../typedefs
  data_source:
    path: ../data_source

To create an abstract TodoRepository class, we will navigate to our project's lib/src directory and create a new file called todo_repository.dart. Inside this file, we will make the TodoRepository class and add the necessary abstract methods that will be used to manage and access data related to to-dos.

import 'package:either_dart/either.dart';
import 'package:failures/failures.dart';
import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

abstract class TodoRepository {
  Future<Either<Failure, List<Todo>>> getTodos();

  Future<Either<Failure, Todo>> getTodoById(TodoId id);

  Future<Either<Failure, Todo>> createTodo(CreateTodoDto createTodoDto);

  Future<Either<Failure, Todo>> updateTodo({
    required TodoId id,
    required UpdateTodoDto updateTodoDto,
  });

  Future<Either<Failure, void>> deleteTodo(TodoId id);
}

We are using either_dart to encapsulate the failure and data in the same place.

either_dart is the error handling and railway-oriented programming library is a Dart library that supports async "map" and async "then" functions for working with asynchronous computations and handling errors with Future<Either> values.

After you are done, make sure to export it in src/repository.dart

library repository;

export 'src/todo_repository.dart';

You can find the code for this on GitHub.

Damn! that was a lot of code and a lot of writing ๐Ÿ˜ฎโ€๐Ÿ’จ

๐ŸŽ‰ In Part 3, we're going to get started on the implementation of our repository! ๐Ÿ’ป We'll begin by setting up the backend and establishing database connections. ๐Ÿ”Œ It's going to be a lot of fun and a great opportunity to get hands-on with the code. So stay tuned! ๐Ÿ˜Ž

ย