This is the second part of a new series in which we are going to learn how to build a safe, maintainable, and testable app in Flutter using fpdart and riverpod.
We will focus less on the implementation details, and more on good practices and abstractions that will helps us to build a flexible yet resilient app in Flutter.
Read part 1: Project Objectives And Configuration
As always, you can find the final Open Source project on Github:
In this article we are going to setup the underlining data model for the application:
- Use the new Dart 3 class modifiers (
final) and how to make a class immutable - Add equality checks using
Equatable - Define the API interface using an
abstract class
There is more 🤩
Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
Requirements: reading and writing in storage
Since this series is focused more on fpdart and riverpod, the requirements for the app are simple:
The app should allow to store events (with
titleanddate). It then should allow to visualize all the created events.
The API therefore consists in:
- Create
Events given atitle(use the current date and time asdate) - Read and display the full list of created
Events
With these requirements in mind, it's time to jump into code!
Data class definition
The first step is defining a class for an Event. We are going to call this class EventEntity:
import 'package:flutter/material.dart';
@immutable
final class EventEntity {
final int id;
final String title;
final DateTime createdAt;
const EventEntity(this.id, this.title, this.createdAt);
}Immutability
As you can see, this class is defined as immutable. In dart we can make an immutable class by:
- Add
finalclass modifier: This prevents the class from being extended of implemented - Mark all fields as
final: This prevents any field from being changed - Add the
@immutableannotation (nice to have, not required)
Making EventEntity immutable allows for better encapsulation.
Immutability allows to pass and share objects without worrying about changes to the object's state. This allows better reasoning and makes the codebase more readable and maintainable.
Immutability 🧱 Every variable is constant, it can never change Instead of reassigning, create a new copy
It is also possible to add mutable fields to the class. In that case if you want to keep the class immutable you must:
- Make all mutable fields private, so that direct access is not allowed
- Create defensive copies of all mutable fields passed to the constructor, so that they cannot be changed from the outside
- Do not provide any methods that give external access to internal mutable fields
- Do not provide any methods that change any fields (except if you make absolutely sure those changes have no external effects, this may be useful for lazy initialization, caching and improving performance)
@immutable
final class EventEntityMutableFields {
final int id;
/// Mutable fields: Make them private ⚠️
String _title;
DateTime _createdAt;
EventEntityMutableFields._(this.id, this._title, this._createdAt);
factory EventEntityMutableFields(int id, String title, DateTime createdAt) {
/// Defensive copy: Avoid changes from the outside 🔐
final createdAtCopy = createdAt.copyWith();
return EventEntityMutableFields._(id, title, createdAtCopy);
}
}Immutability 👀👇 sandromaglione.com/immutability-p…
freezed: Immutability and more
Another common solution to create immutable objects in dart is freezed.
freezed leverages code generation to automatically define immutable objects.
On top of that, freezed also generates:
- Overrides
toString,==operator, andhashCode(equality) - Implements a
copyWithmethod to clone the object - Handles de/serialization (
toJsonandfromJson)
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
part 'event_entity.freezed.dart';
part 'event_entity.g.dart';
@freezed
class EventEntity with _$EventEntity {
const factory EventEntity({
required int id,
required String title,
required DateTime createdAt,
}) = _EventEntity;
factory EventEntity.fromJson(Map<String, Object?> json)
=> _$EventEntityFromJson(json);
}
freezedalso supports mutable objects by using the@unfreezedannotation
Class equality using Equatable
We are also going to implement equality for EventEntity.
Instead of manually overriding the == operator and the hashCode method, we are going to use the equatable package:
EventEntityextends theEquatableclass- Override the
propsmethod
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
@immutable
final class EventEntity extends Equatable {
final int id;
final String title;
final DateTime createdAt;
const EventEntity(this.id, this.title, this.createdAt);
@override
List<Object?> get props => [id];
}Now every time we compare two EventEntity they will be considered equal if their id is the same:
final event1 = EventEntity(1, "event1", DateTime(2023));
final event2 = EventEntity(1, "event2", DateTime(2024));
final equal = event1 == event2; /// True ✅There is more 🤩
Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
API definition: StorageService
We can now define the API for our application.
As we saw previously, the API consists in reading and writing EventEntity in storage. Therefore, we create a new StorageService class with 2 methods:
getAll: Returns the list of allEventEntityin storageput: Create and store a newEventEntitygiven itstitle, and return the new instance ofEventEntity
abstract class StorageService {
Future<List<EventEntity>> get getAll;
Future<EventEntity> put(String title);
}Note: The
idofEventEntitywill be auto-generate, whiledatewill be initialized to the current date and time.
Abstract the concrete implementation: abstract class
StorageService is defined as an abstract class.
Making StorageService abstract allows to define the API methods without providing a concrete implementation.
The advantage is that we can use any storage (shared preferences, local database, remote database) without making the whole codebase dependent on a specific implementation.
Do you know what an *abstract* class is in #dart? 🤔 Why not a simple class? Why making it abstract? 💭 The distinction is actually important and practical ⚙️ Let's see 👇🧵
In practice this allows for better maintainability and testing:
- Maintainability: the app is not tight to any specific storage source, it is possible to update the storage just by changing the concrete
StorageServiceimplementation
/// All of the classes below are valid `StorageService`
///
/// You can use any of them to store the data ✅
class SharedPreferencesStorage implements StorageService { ... }
class LocalStorage implements StorageService { ... }
class RemoteDatabaseStorage implements StorageService { ... }- Testing: you can use a custom
StorageServicespecifically for testing
/// Mock `StorageService` for testing with ease 🤝
class TestStorageService extends Mock implements StorageService {}This is it for part 2!
We are now ready to jump into fpdart to implement our API. We are also going to learn how to connect fpdart with riverpod to provide the data to the UI.
If you want to stay up to date with the latest releases, you can subscribe to my newsletter here below 👇
Thanks for reading.
