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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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!"
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 ππ§΅
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() => '...'
};
Dart 3 is nearly here, brining Patterns and Records with it π― This will radically change how you write @dart_lang (and @FlutterDev) apps Learn all about them right now π§΅π
Maintainable
A maintainable app achieves 2 objectives:
- Easy to refactor and remove outdated code as the requirements change
- 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 = ...
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 ππ§΅
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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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:
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.
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, functional programming for @dart_lang and @FlutterDev is now available on pub Why you should check it out π𧡠#flutter #dart #functional #functionalprogramming
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 useriverpod
βοΈ
We are going to install 4 dependencies:
hooks_riverpod
: The core of riverpod (using hooks)riverpod_annotation
: Provides the@riverpod
annotation for code generationriverpod_generator
: Generator for riverpod's providersbuild_runner
: Package required to run code generation in dart
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.
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:
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.