Project objectives and configuration | Fpdart and Riverpod Functional Programming in Flutter

Sandro Maglione

Sandro Maglione

Mobile development

This is the first 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.

The 3 objectives we are aiming to achieve for the app are:

  • Safe
  • Maintainable
  • Testable

As always, you can find the final Open Source project on Github:

In this first article we are going to define these 3 objectives. We are also going to create and setup the app by installing all the dependencies and enabling linting.

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

How to structure a Flutter app: safe, maintainable, testable

Before jumping into the project configuration it is important to define the objectives that we are aiming to achieve in our app.

Our app aims to be flexible (easy to expand and maintain) while at the same time safe and resilient (avoid runtime errors and properly tested)

Let's define more into the details these 3 criteria we listed in the introduction (safe, maintainable, testable), and how we are going to achieve them.

Safe: compile-time errors

A "safe" app aims to reduce the possibility of runtime errors, and instead implement a solid architecture that defines and handles known errors at compile time.

The key term here is compile-time errors:

Compile-time errors are reported by the compiler (or directly by your IDE) and they prevent the app to build

In practice this means that we are required to fix all these issues before being able to release the app.

int fun(int value) => value * 2;
 
/// Static typing makes common issues compile-time errors 
///
/// In this example, the app will not even build until this error is fixed πŸ‘‡
/// 
/// "The argument type 'String' can't be assigned to the parameter type 'int'."
fun("string");

This is in contrast with runtime errors:

Runtime errors are issues that crash the app while the user is using it

/// Way less safe (never use `dynamic` πŸ™…β€β™‚οΈ)
dynamic fun(dynamic value) => value.length;
 
/// The app build correctly, but crashes at runtime ⚠️
fun(2);

A safe app aims to move as many errors as possible to become compile-time, in order to avoid bugs and crashes in the production release.

Note: In practice not all errors can be compile-time errors. Some unexpected problems may still happen that crashes the app at runtime (these are called unrecoverable errors)

Recoverable VS Unrecoverable errors πŸ€” Here is a good analogy πŸ˜‚πŸ‘‡ "Nurse, if there's a patient in room 5, can you ask him to wait?" β˜‘οΈ Recoverable error: "Doctor, there is no patient in room 5." ⛔️Unrecoverable error: "Doctor, there is no room 5!"

Sandro Maglione
Sandro Maglione
@SandroMaglione

What if your #flutter app fails? Should you throw? Error? Exception? #dart gives you both Error and Exception 🎯 But how do they work? Which one should you choose? πŸ€” Here is the answer πŸ‘‡πŸ§΅

3
Reply

Safe app using pattern matching

An example of good practice is properly using the new pattern matching feature in Dart 3:

sealed class State {}
class Loading extends State {}
class Error extends State {}
class Success extends State {}

It is generally better to avoid the catch all _, because the point of pattern matching is to get a compile-time error when you forget to handle a new case:

/// It works, but unsafe πŸ˜•
final match = switch (state) {
    Success() => 'Done!',
    _ => '...'
};

By using _, when we add a new class that extends State, we will get no error from switch.

If instead we properly match all cases without _, we will be required to handle any new case, otherwise getting a compile-time error:

/// Safe πŸ”’
final match1 = switch (state) {
    Success() => 'Done!',
    Loading() || Error() => '...'
};

Maintainable

A maintainable app achieves 2 objectives:

  1. Easy to refactor and remove outdated code as the requirements change
  2. Easy to add new features without breaking the current ones

A safe app helps with maintainability. If all the errors are surfaced before the release, we are more confident that nothing will break at runtime.

Maintainability is about implementing the right abstractions such that new features are easy to integrate and old requirements are easy to refactor without having to rewrite too much code.

Using abstract classes improves maintainability

An example of pattern that makes the code more maintainable is using abstract classes.

If your architecture depends on concrete implementations, it becomes difficult to refactor the code to migrate to a new service.

The suggestion instead is to define an abstract class:

abstract class StorageService {
  Future<List<EventEntity>> get getAll;
  Future<EventEntity> put(String title);
}
 
/// Real example using `ReaderTask` from `fpdart`
ReaderTask<StorageService, GetAllEventState> getAllEvent = ...

Then you can provide a concrete implementation that extends StorageService:

class LocalStorageService implements StorageService { ... }
 
@riverpod
Future<StorageService> storageService(StorageServiceRef ref) async {
  /// Return concrete instance of [StorageService]
  return LocalStorageService();
}

Testable

The final objective is making the app easy to test.

Since we know that not all errors can be shifted to compile-time, we want to make sure that the app behaves as expected when released.

Nonetheless, not all apps are easy to test. Once again, testability depends a lot from setting up the correct abstractions.

If we use the abstract class as the example in the previous section, the code become also way easier to test:

/// [StorageService] used specifically for testing πŸ§ͺ
///
/// Use this class instance when running tests instead of [LocalStorageService]
class TestingStorageService implements StorageService { ... }

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Project setup: pubspec.yaml

We are now going to install the core dependencies that will help us achieve the objectives defined above.

This is the pubspec.yaml file:

pubspec.yaml
name: fpdart_riverpod
description: A new Flutter project.
publish_to: "none"
version: 1.0.0+1
 
environment:
  sdk: ">=3.0.0 <4.0.0"
 
dependencies:
  equatable: ^2.0.5
  flutter:
    sdk: flutter
  fpdart: ^1.0.0-beta.1
  hooks_riverpod: ^2.3.6
  riverpod_annotation: ^2.1.1
 
dev_dependencies:
  flutter_test:
    sdk: flutter
 
  flutter_lints: ^2.0.0
  build_runner: ^2.4.4
  custom_lint: ^0.4.0
  riverpod_lint: ^1.3.2
  riverpod_generator: ^2.2.3
 
flutter:
  uses-material-design: true

Dart 3: Records and Pattern matching

We are going to use the latest versions of the dart sdk: Dart 3.

pubspec.yaml
environment:
  sdk: ">=3.0.0 <4.0.0"

Dart 3 introduces Records and Pattern matching into the language. These 2 feature will play a major role in making the app safer and therefore more maintainable.

fpdart: functional programming

The main package used in the app is fpdart.

fpdart brings functional programming in dart. By following the principles of functional programming we aim to make the app safe in all its aspects.

The goal of fpdart is to prevent runtime issues by making error handling and dependency management easier

Another great advantage of fpdart (and functional programming in general) is that the app becomes way easier to test and maintain.

In this series we are going to exploit the full potential of fpdart and its API (I am the creator and maintainer of the package after all πŸ‘‹)

riverpod: state management

riverpod will allow us to easily connect the business logic layer of the app with the UI.

In this series we will use riverpod_generator to auto-generate the providers, which makes the code shorter, easier to read and maintain, while also reducing the possibility of errors.

riverpod_generator is the recommended way to use riverpod ☝️

We are going to install 4 dependencies:

  1. hooks_riverpod: The core of riverpod (using hooks)
  2. riverpod_annotation: Provides the @riverpod annotation for code generation
  3. riverpod_generator: Generator for riverpod's providers
  4. build_runner: Package required to run code generation in dart

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Linting: analysis_options.yaml

Another crucial aspect at the beginning of a project is setting up linting.

Linting is an automated way to enforce code conventions and avoid and fix the most common issues

We are going to use the standard flutter_lints (preinstalled by flutter create) and riverpod_lint.

riverpod_lint requires to install also the custom_lint package and update the analysis_options.yaml file by enabling the plugin:

analysis_options.yaml
include: package:flutter_lints/flutter.yaml
 
analyzer:
  plugins:
    - custom_lint

equatable

The last core package is equatable. equatable allows to easily implement equality in our classes.

This will also allow riverpod to correctly cache each request, therefore increasing the performance of the app.


We are now ready to dive into the code!

In the next part of this series we will start by defining the requirements of the app and implement the core classes. We will cover immutability, class modifiers, and equality.

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?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.