β€’

tech

Data model and Storage interface | Fpdart and Riverpod Functional Programming in Flutter

Learn how to define a complete immutable data class in dart and flutter, why using immutable classes, how immutability makes your code better, and how to define a abstraction for your API.


Sandro Maglione

Sandro Maglione

Software development

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:

fpdart_riverpod

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 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

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 title and date). It then should allow to visualize all the created events.

The API therefore consists in:

  • Create Events given a title (use the current date and time as date)
  • 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:

event_entity.dart
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:

  1. Add final class modifier: This prevents the class from being extended of implemented
  2. Mark all fields as final: This prevents any field from being changed
  3. Add the @immutable annotation (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.

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);
  }
}

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, and hashCode (equality)
  • Implements a copyWith method to clone the object
  • Handles de/serialization (toJson and fromJson)
event_entity.dart
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);
}

freezed also supports mutable objects by using the @unfreezed annotation

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:

  1. EventEntity extends the Equatable class
  2. Override the props method
event_entity.dart
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 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

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 all EventEntity in storage
  • put: Create and store a new EventEntity given its title, and return the new instance of EventEntity
storage_service.dart
abstract class StorageService {
  Future<List<EventEntity>> get getAll;
  Future<EventEntity> put(String title);
}

Note: The id of EventEntity will be auto-generate, while date will 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.

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 StorageService implementation
/// 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 StorageService specifically 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.

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.