Architecture Clean dans Flutter : une structure simple, testable et durable
Un guide progressif pour organiser une application Flutter avec la Clean Architecture, le BLoC, les repositories et une séparation claire des responsabilités.
Pourquoi la Clean Architecture ?
Quand une application Flutter commence à grandir, le premier problème n'est pas le manque de fonctionnalités. C'est la difficulté à faire évoluer le code sans casser l'existant. Une logique métier dispersée dans les widgets, des appels réseau mélangés à l'affichage et des dépendances directes partout rendent vite le projet fragile.
La Clean Architecture résout ce problème en imposant une règle simple : les dépendances doivent toujours pointer vers le cœur du domaine. Autrement dit, la logique métier ne dépend pas de Flutter, de l'API, de la base de données ou de la façon dont l'écran est construit.
Le résultat est beaucoup plus sain :
- le code devient plus facile à tester
- les changements techniques sont mieux isolés
- l'interface reste séparée de la logique métier
- le projet est plus simple à comprendre pour une nouvelle personne qui arrive dessus
L'idée à retenir avant tout
La Clean Architecture n'est pas là pour compliquer votre projet. Elle sert à protéger la logique importante.
Si vous changez de backend, de base locale ou même de gestionnaire d'état, vous ne devriez pas avoir à réécrire tout le cœur métier. C'est exactement ce que cette architecture permet.
Les trois couches
Domain
Le domaine est le cœur de l'application. C'est ici que vivent les règles métier, les entités et les cas d'utilisation.
- Entities : les objets métier purs
- Use cases : les actions que l'application sait faire
- Repository interfaces : les contrats que la couche data devra respecter
Le domaine ne doit pas connaître Flutter, le réseau, Firebase ou un package de base de données. Il décrit le problème métier, pas la technique.
class User {
final String id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
}
class GetUserUseCase {
final UserRepository repository;
GetUserUseCase(this.repository);
Future<Either<Failure, User>> call(String id) =>
repository.getUser(id);
}
Dans cet exemple, l'entité User ne connaît rien au format JSON, et le cas d'utilisation ne connaît rien à l'origine des données. Il demande simplement un utilisateur à un repository.
On peut aussi définir le contrat du repository dans le domaine :
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String id);
}
Cela permet au domaine de rester totalement indépendant de l'implémentation réelle.
Data
La couche data se charge de récupérer les données concrètes. Elle peut parler à une API, à une base locale ou à un cache.
Son rôle est d'implémenter les contrats définis dans le domaine et de transformer les données techniques en objets compréhensibles par l'application.
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, User>> getUser(String id) async {
try {
final user = await remoteDataSource.getUser(id);
await localDataSource.cacheUser(user);
return Right(user);
} on ServerException {
return Left(ServerFailure());
} on CacheException {
return Left(CacheFailure());
}
}
}
La logique importante ici est la suivante :
- on essaie d'abord la source distante
- si la récupération fonctionne, on peut mettre en cache le résultat
- si une erreur technique survient, on la convertit en échec métier lisible
La couche data doit aussi s'occuper du mapping entre modèle technique et entité métier :
class UserModel {
final String id;
final String name;
final String email;
const UserModel({required this.id, required this.name, required this.email});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
User toEntity() => User(id: id, name: name, email: email);
}
Ce passage est important : les modèles servent à parler à l'extérieur, les entités servent à parler au métier.
Presentation
La couche presentation affiche les données et gère les interactions utilisateur. Avec BLoC, elle reste réactive sans prendre toute la logique métier sur ses épaules.
Le BLoC reçoit les événements de l'interface, appelle les cas d'utilisation, puis émet de nouveaux états.
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserUseCase getUser;
UserBloc({required this.getUser}) : super(UserInitial()) {
on<FetchUser>((event, emit) async {
emit(UserLoading());
final result = await getUser(event.id);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
});
}
}
Cette structure garde les widgets simples. Un écran Flutter devrait surtout dire :
- quel événement envoyer
- quel état afficher
- comment présenter les données
Il ne devrait pas décider comment parler à l'API ou comment construire un repository.
Le chemin complet d'une donnée
Pour bien comprendre, voici le trajet classique d'une information :
- l'utilisateur appuie sur un bouton
- le widget envoie un événement au BLoC
- le BLoC appelle un cas d'utilisation
- le cas d'utilisation demande la donnée au repository
- le repository récupère la donnée via la couche data
- la réponse remonte dans l'autre sens jusqu'à l'interface
Ce flux peut sembler plus long qu'une approche directe, mais il devient très robuste dès que le projet grossit.
Injection de dépendances avec GetIt
L'injection de dépendances évite de créer les objets n'importe où dans le code. Elle rend les dépendances explicites et facilite les tests.
final sl = GetIt.instance;
void setupLocator() {
// Data sources
sl.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSourceImpl(client: sl()),
);
// Repository
sl.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => GetUserUseCase(sl()));
// BLoC
sl.registerFactory(() => UserBloc(getUser: sl()));
}
Avec cette approche, chaque couche reçoit exactement ce dont elle a besoin, sans connaître la façon dont les autres objets ont été construits.
Structure de dossiers recommandée
Une arborescence claire aide énormément à retrouver les fichiers. Une structure fréquente ressemble à ceci :
lib/
core/
error/
usecase/
utils/
features/
user/
data/
datasources/
models/
repositories/
domain/
entities/
repositories/
usecases/
presentation/
bloc/
pages/
widgets/
Dans cette organisation :
corecontient le code partagé à l'échelle de l'applicationfeaturesisole chaque domaine fonctionnel- chaque fonctionnalité possède ses propres couches
data,domainetpresentation
Ce découpage évite que tout le projet soit mélangé dans un seul dossier gigantesque.
Quand utiliser la Clean Architecture
La Clean Architecture est particulièrement pertinente si :
- l'application doit durer plusieurs mois ou plusieurs années
- plusieurs développeurs travaillent dessus
- la logique métier est importante
- vous avez besoin de tests fiables
- la source de données peut changer avec le temps
En revanche, pour un prototype très simple ou une application jetable, une architecture plus légère peut suffire.
Les erreurs fréquentes
Beaucoup de projets disent utiliser la Clean Architecture, mais la cassent sans s'en rendre compte. Voici les pièges les plus courants :
- mettre des appels HTTP directement dans les widgets
- faire dépendre le domaine de classes de la couche data
- mélanger transformation JSON et logique métier
- créer des fichiers trop génériques qui ne veulent plus rien dire
- multiplier les abstractions sans besoin réel
Le bon réflexe est simple : gardez chaque fichier responsable d'une seule chose.
Conclusion
Adopter la Clean Architecture dans Flutter demande un peu plus de discipline au départ. En échange, vous obtenez une application plus facile à faire évoluer, à tester et à comprendre.
Le vrai bénéfice apparaît surtout quand le projet grandit. Tant que les responsabilités restent séparées, les changements sont plus sûrs et le code reste lisible longtemps.