Structurez une app Flutter en couches étanches avec le repository pattern et des résultats typés sealed.
Ouvrir cette leçon dans KodokonUne app Flutter qui tient dans la durée sépare trois responsabilités : la présentation (widgets, BuildContext), la logique (état, cas d'utilisation) et les données (API, base locale). La règle cardinale est le sens des dépendances : la présentation dépend de la logique, la logique dépend d'abstractions de données - jamais l'inverse. Concrètement, aucun BuildContext, aucun import de material.dart ne doit apparaître sous la couche présentation : un contrôleur qui affiche lui-même un SnackBar est un contrôleur intestable. Autre point souvent négligé : les erreurs font partie du contrat entre couches. Plutôt que de laisser fuiter des exceptions non typées, modélisez le résultat avec une classe sealed - le compilateur vous forcera à traiter chaque cas.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}Le repository est la frontière entre le domaine et le monde extérieur. Son rôle exact : exposer des entités du domaine (jamais les DTO bruts de l'API), masquer la stratégie d'accès (réseau, cache, base locale) et centraliser la politique de fraîcheur des données. En Dart 3, déclarez le contrat avec abstract interface class : ce modificateur n'autorise que implements depuis l'extérieur de la bibliothèque - impossible d'hériter d'un bout d'implémentation par accident, le contrat reste pur. Le mapping JSON vers entité vit dans l'implémentation : si l'API renomme un champ demain, seule la couche données change.
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}Côté présentation, un ChangeNotifier injecté par constructeur suffit très souvent - inutile de dégainer un framework. Évitez les singletons globaux : ils figent le graphe de dépendances et rendent la substitution en test pénible. ListenableBuilder ne reconstruit que son builder, et le switch exhaustif sur le Result verrouille l'UI : ajoutez un troisième état au type scellé et la compilation échoue partout où il n'est pas traité. C'est toute la valeur du pattern - l'oubli devient une erreur de compilation, pas un bug en production.
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Erreur de chargement'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}