Architecture d'application Flutter qui va au-delà du tutoriel
Patterns d'architecture Flutter pour la production — de la gestion d'état à la navigation, l'injection de dépendances et la structure de projet feature-first.

Les applications Flutter des tutoriels fonctionnent très bien jusqu'à environ vingt écrans. Puis le fichier unique main.dart avec toutes les routes devient ingérable, les appels setState créent des conditions de course, l'approche "passez-le dans le constructeur" produit des arbres de widgets avec dix paramètres, et chaque nouvelle fonctionnalité touche cinq fichiers existants. J'ai vu cela se produire sur plusieurs projets, y compris les miens, et le schéma est toujours le même — l'équipe atteint un mur vers le troisième mois et doit soit restructurer, soit accepter une perte croissante de vélocité de développement.
L'architecture que je décris ici est celle que j'utilise pour les applications de production. Ce n'est pas l'approche la plus simple possible, et c'est le but. L'approche la plus simple est ce que les tutoriels enseignent, et elle cesse de fonctionner dès que votre application a de vraies exigences.
Pourquoi l'architecture des tutoriels échoue
La plupart des tutoriels et cours Flutter enseignent une structure de projet plate :
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
Cela fonctionne pour une application de todo. Cela échoue pour une vraie application pour des raisons prévisibles :
Les fichiers grossissent sans limites naturelles. Quand home_screen.dart contient la mise en page de l'écran, la gestion d'état, les appels API et la logique métier, il atteint rapidement 500+ lignes. Il n'y a aucune guidance structurelle sur ce qui devrait être extrait et où cela devrait aller.
Les dépendances deviennent implicites. Quand home_screen.dart instancie directement ApiService(), il n'y a aucun moyen de le remplacer par un mock dans les tests, aucun moyen de le configurer différemment par environnement, et aucun moyen de partager une seule instance entre les écrans qui ont besoin des mêmes données.
La navigation devient un réseau de routes en dur. Les deep links, les gardes d'authentification et les flux conditionnels sont tous boulonnés sur une liste de routes plate. Le temps que vous ayez trente routes avec des gardes et des redirections, le code de navigation est la partie la plus fragile de l'application.
Les tests deviennent impraticables. Sans injection de dépendances, les tests de widgets nécessitent de mocker au niveau HTTP plutôt qu'au niveau service. Sans séparation claire de la logique métier et de l'UI, vous finissez par tester des détails d'implémentation plutôt que du comportement.
Structure de projet feature-first
La décision architecturale la plus impactante est la structure des dossiers. J'utilise une approche feature-first où chaque feature possède ses écrans, widgets, état et modèles :
lib/
├── app/
│ ├── app.dart
│ ├── router.dart
│ └── theme.dart
├── core/
│ ├── constants/
│ ├── errors/
│ ├── network/
│ └── utils/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ ├── widgets/
│ │ │ └── providers/
│ │ └── auth.dart
│ ├── home/
│ └── settings/
└── shared/
├── widgets/
├── models/
└── providers/
Les règles
Les features n'importent jamais depuis d'autres features. Si la feature A a besoin de données de la feature B, ces données vivent dans un modèle partagé ou un service partagé, pas dans le dossier de la feature B.
Le répertoire core/ contient l'infrastructure. Réseau, gestion d'erreurs, constantes, utilitaires — des choses dont chaque feature pourrait avoir besoin mais qui n'appartiennent à aucune feature spécifique.
Le répertoire shared/ contient l'UI réutilisable et les préoccupations transversales. Widgets partagés, modèles partagés et providers dont dépendent plusieurs features.
Les barrel files exposent l'API publique de chaque feature. Les autres parties de l'application importent depuis features/auth/auth.dart, jamais depuis les fichiers internes.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
Gestion d'état : Riverpod
J'ai utilisé Provider, Bloc et Riverpod sur des projets de production. Chacun a ses compromis, et le choix dépend plus de la familiarité de l'équipe que de la supériorité technique. Cela dit, je prends Riverpod sur les nouveaux projets pour des raisons spécifiques.
Pourquoi Riverpod plutôt que Bloc
Bloc est excellent pour les équipes venant d'un background enterprise. Le pattern événement/état est familier aux développeurs qui ont travaillé avec Redux ou MVI. Il impose un flux de données unidirectionnel strict et génère des machines à états prévisibles et testables. L'inconvénient est la verbosité.
Riverpod résout les limitations fondamentales de Provider. Les providers sont globaux (pas dépendants de l'arbre de widgets), sûrs à la compilation, et supportent des patterns avancés comme l'auto-disposal, les family providers et l'état asynchrone.
Riverpod en pratique
// features/auth/domain/auth_state.dart
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
}
Navigation avec GoRouter
GoRouter est devenu le standard pour la navigation Flutter. Il supporte le routing déclaratif, les deep links, les redirections et la navigation imbriquée nativement.
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
Le pattern Repository
Les repositories se situent entre votre logique métier et vos sources de données. Ils abstraient le fait que les données viennent d'une API REST, d'une base de données locale, d'un cache ou d'une combinaison des trois.
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
Future<User?> getCurrentUser() async {
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
}
Pourquoi ne pas appeler l'API directement
Les appels API directs depuis le code de gestion d'état créent plusieurs problèmes :
- La logique de cache s'infiltre dans la logique métier. Vérifiez-vous le cache avant l'appel API ? Combien de temps le cache est-il valide ? C'est une préoccupation de la couche données, pas de la gestion d'état.
- Le support hors ligne devient une réécriture. Si votre Bloc appelle directement
http.get, ajouter le support hors ligne signifie modifier chaque Bloc. Avec un repository, vous ajoutez le cache et la logique de fallback à un seul endroit. - Les tests nécessitent du mocking HTTP. Mocker un repository c'est un mock par test. Mocker HTTP nécessite de configurer des corps de réponse, des codes de statut et des headers pour chaque cas de test.
Gestion des erreurs
Une gestion cohérente des erreurs est l'un des aspects les plus négligés de l'architecture Flutter. J'utilise une combinaison d'exceptions typées et d'un type résultat.
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
Architecture de test
Une bonne architecture rend les tests simples. Chaque couche a sa propre stratégie de test :
// Provider Tests with Riverpod
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
Le mécanisme ProviderContainer et d'override de Riverpod rend les tests propres — vous surchargez les dépendances au niveau du conteneur plutôt qu'au niveau de l'arbre de widgets.
Compromis pratiques
Cette architecture n'est pas gratuite. Il y a des coûts réels :
Plus de fichiers. Une feature qui serait un fichier dans une application de tutoriel peut être cinq à huit fichiers dans cette structure. Pour les petites applications (moins de dix écrans), cela peut sembler excessif.
Courbe d'apprentissage. Un développeur nouveau sur le projet doit comprendre les limites entre couches et où mettre les choses.
Génération de code. Le système d'annotations de Riverpod, Freezed pour les modèles immuables, et la sérialisation JSON nécessitent tous build_runner.
Tentation de sur-abstraire. Quand vous avez une architecture propre, il est facile d'ajouter des abstractions "au cas où". Une interface de repository pour une source de données qui n'aura jamais qu'une seule implémentation ajoute de la complexité sans valeur.
L'architecture en vaut la peine quand votre application a plus d'une dizaine d'écrans, plus d'un développeur, ou une durée de vie au-delà de quelques mois. Pour un projet de hackathon ou une preuve de concept, utilisez la chose la plus simple qui fonctionne. Mais quand vous savez que l'application doit grandir, investir dans la structure tôt économise des multiples du coût plus tard.
Danil Ulmashev
Full Stack Developer
Intéressé par une collaboration ?