Architecture
Couches, repository, DTO, injection de dépendances, erreurs typées, flavors, monorepo et conventions d'équipe. « Décrivez l'architecture de votre dernière app » est posée dans presque tous les entretiens seniors : ce chapitre vous donne le vocabulaire officiel, les compromis et une réponse structurée.
Objectifs
- Présenter en 3 minutes l'architecture d'une app, en reliant le vocabulaire officiel Flutter et celui de la Clean Architecture.
- Justifier un repository, un DTO séparé et un type d'erreur scellé, et dire quand c'est superflu.
- Écrire sans IA un repository avec source distante et locale, et la conversion des exceptions Dio en
Failure. - Configurer des flavors dev / prod sur Android et iOS, et expliquer
--dart-define-from-file. - Parler monorepo (pub workspaces, Melos), lints et ADR avec les bons arguments pour une équipe en régie.
Pourquoi une architecture
Une app de démonstration n'a pas besoin d'architecture. Une app bancaire, si : elle vivra huit à dix ans, verra passer plusieurs équipes et prestataires, subira des audits de sécurité, et devra rester testable alors que les règles métier changent chaque trimestre. L'architecture sert quatre objectifs concrets :
- Travail en parallèle : des frontières claires (features, couches) limitent les conflits entre développeurs.
- Tests : chaque couche se teste isolément avec des fakes.
- Remplaçabilité : changer de client HTTP, de base locale ou de state management sans tout réécrire.
- Lisibilité : un nouvel arrivant sait où chercher et où ajouter du code.
Le coût est réel : plus de fichiers, plus d'indirection. Une bonne architecture est proportionnée au projet. Savoir dire « ici, cette couche est inutile » est un signe de séniorité, pas de paresse.
Les recommandations officielles Flutter
Depuis fin 2024, docs.flutter.dev publie un guide d'architecture (section App architecture) avec une étude de cas complète. Le principe central est la séparation des responsabilités (separation of concerns). Le guide se présente comme un ensemble de lignes directrices à adapter, pas de règles absolues.
- View : composition de widgets, sans logique métier. Seuls sont admis : afficher ou masquer selon un drapeau, animation, mise en page selon l'appareil, routage simple.
- ViewModel : transforme les données des repositories en état d'UI et expose des commands (fonctions appelées par la vue). Relation un pour un avec la vue.
- Repository : source de vérité d'un type de données. Interroge les services, met en cache, gère erreurs et retry, produit des modèles de domaine. Les repositories ne se connaissent pas entre eux.
- Service : enveloppe un point d'accès (endpoint REST, API de plateforme), expose des
Future/Stream, sans état. - Domain layer (optionnelle) : des use cases, seulement si la logique fusionne plusieurs repositories, est très complexe ou est réutilisée par plusieurs view models.
La page Recommendations classe les conseils par force. Extrait, tel que lu le 22 octobre 2026 :
| Recommandation | Force |
|---|---|
| Couches data et UI clairement définies | Strongly recommend |
| Repository pattern dans la couche data, classes de repository abstraites | Strongly recommend |
| MVVM (views et view models) dans la couche UI | Strongly recommend |
| Pas de logique dans les widgets | Strongly recommend |
| Flux de données unidirectionnel, modèles immuables | Strongly recommend |
| Injection de dépendances | Strongly recommend |
| Fakes pour les tests, tester les composants séparément et ensemble | Strongly recommend |
| Commands, freezed ou built_value, go_router, conventions de nommage | Recommend |
Domain layer, modèles d'API séparés des modèles de domaine, ChangeNotifier | Conditional |
L'étude de cas officielle utilise ChangeNotifier et provider, mais l'architecture est indépendante de l'outil. Avec Riverpod, le view model est un Notifier / AsyncNotifier, les commandes sont ses méthodes publiques, et les repositories et services sont des providers.
Clean Architecture : correspondances et limites
La Clean Architecture (Robert C. Martin) organise le code en cercles concentriques, avec une règle : les dépendances pointent vers l'intérieur. Le domaine (entités, use cases, interfaces de repositories) ne dépend de rien, la couche data implémente ces interfaces, la présentation consomme les use cases.
| Clean Architecture | Guide Flutter | Dans le Wallet avec Riverpod |
|---|---|---|
| Entities | Domain models | Transaction, Wallet (freezed, sans JSON) |
| Use cases / interactors | Domain layer optionnelle | SendTransfer, seulement si la logique le justifie |
| Repository (interface dans le domaine) | Abstract repository | abstract interface class TransactionRepository |
| Data sources | Services | TransactionApi (Dio), TransactionLocalSource (drift) |
| Presentation (controllers, presenters) | View models + views | Notifiers + ConsumerWidget |
Quand la Clean Architecture est trop lourde
Appliquée à la lettre, elle produit pour chaque appel un use case qui délègue une ligne au repository, un DTO, une entité, un mapper, une interface et une implémentation : six fichiers pour lire un solde. C'est ce que le guide Flutter appelle le coût de la domain layer : plus de classes, plus de mocks, plus de charge cognitive.
- Gardez : interface de repository, séparation DTO / domaine quand l'API est instable ou mal nommée, erreurs typées.
- Rendez optionnel : les use cases, à créer quand une logique combine plusieurs repositories (un transfert qui vérifie le solde, le bénéficiaire et un plafond) ou est partagée entre écrans.
- Évitez : les use cases « passe-plat », les interfaces sur des classes qui n'auront jamais deux implémentations ni de fake.
« Je pars des recommandations officielles Flutter : une couche UI en MVVM, une couche data avec repositories et services, et une domain layer seulement là où elle apporte quelque chose. J'ai pratiqué une Clean Architecture complète sur une app de paiement ; c'était justifié par la complexité métier et la taille de l'équipe, mais j'ai aussi vu le coût des use cases qui ne font que déléguer. Aujourd'hui, je crée un use case quand une logique combine plusieurs repositories ou sert à plusieurs écrans. »
Feature-first ou layer-first
| Layer-first | Feature-first | |
|---|---|---|
| Structure | data/, domain/, presentation/ à la racine, toutes features mélangées | features/transfer/ contient ses propres data/, domain/, presentation/ |
| Avantage | Simple sur une petite app | Une feature se lit, se teste, se supprime ou s'extrait en package d'un bloc |
| Limite | Une modification touche trois dossiers éloignés ; mauvais passage à l'échelle | Discipline nécessaire sur ce qui est partagé (core/) ; risque de dépendances croisées entre features |
Pour une app d'équipe, feature-first est le choix dominant, et c'est celui du projet Wallet. Règle complémentaire : une feature n'importe pas les détails internes d'une autre. Si deux features partagent un modèle, il remonte dans core/ ou dans un package dédié.
L'arborescence du Wallet
lib/
main_dev.dart # point d'entrée dev : surcharge envProvider
main_prod.dart
app/
app.dart # MaterialApp.router
router.dart # go_router, garde d'authentification
theme.dart
env.dart # classe Env + envProvider
core/
network/ # dioProvider, intercepteurs, failureFromDio
storage/ # secure storage, base drift
errors/ # failure.dart, result.dart
utils/ # Money, formatage
features/
auth/
data/ # AuthApi, AuthRepositoryImpl, DTO
domain/ # Session, AuthRepository (interface)
presentation/ # LoginScreen, LoginNotifier
wallet/
transactions/
data/ # TransactionApi, TransactionLocalSource, TransactionDto, mapper
domain/ # Transaction, TransactionRepository
presentation/ # HistoryScreen, HistoryNotifier, TransactionDetailScreen
transfer/
domain/ # SendTransfer (use case : solde + bénéficiaire + plafond)
beneficiaries/
test/ # même arborescence que lib/
integration_test/
Le repository pattern
Le repository est la seule porte d'entrée vers un type de données. Il cache d'où viennent les données (réseau, cache, base locale), convertit les formats et les erreurs, et expose des modèles de domaine. Les view models ne savent pas qu'il existe un Dio ou un drift.
abstract interface class TransactionRepository {
Future<Result<Transaction>> fetchById(String id);
}
class TransactionApi {
TransactionApi(this._dio);
final Dio _dio;
Future<TransactionDto> fetchById(String id) async {
final res = await _dio.get<Map<String, dynamic>>('/transactions/$id');
return TransactionDto.fromJson(res.data!);
}
}
abstract interface class TransactionLocalSource {
Future<TransactionDto?> findById(String id);
Future<void> save(TransactionDto dto);
}
class TransactionRepositoryImpl implements TransactionRepository {
TransactionRepositoryImpl(this._api, this._local);
final TransactionApi _api;
final TransactionLocalSource _local;
@override
Future<Result<Transaction>> fetchById(String id) async {
try {
final dto = await _api.fetchById(id);
await _local.save(dto);
return Ok(dto.toDomain());
} on DioException catch (e) {
final failure = failureFromDio(e);
if (failure is NetworkFailure) {
// Hors ligne : on sert la dernière version connue.
final cached = await _local.findById(id);
if (cached != null) return Ok(cached.toDomain());
}
return Err(failure);
} on FormatException catch (e) {
return Err(UnexpectedFailure(e));
}
}
}
Le mot-clé interface (class modifier de Dart 3) interdit l'héritage hors de la bibliothèque et documente l'intention : on implémente, on n'étend pas. Un fake de test implémente la même interface.
« Pourquoi un repository alors que Dio est déjà une abstraction ? » Parce que Dio abstrait le transport HTTP, pas la donnée. Le repository décide de la source (cache, réseau, base), du format (DTO vers domaine), de la stratégie d'erreur et de la politique de cache. Sans lui, ces décisions se dispersent dans les view models, et un changement de backend touche l'UI. Réponse courte : « Dio sait faire une requête ; le repository sait où trouver une transaction. »
DTO et modèle de domaine
Un DTO (Data Transfer Object) reflète exactement le JSON de l'API : noms, types bruts, chaînes pour les énumérations. Le modèle de domaine reflète le métier : types forts, noms parlants, aucune annotation JSON. Le mapping se fait dans la couche data.
@freezed
abstract class TransactionDto with _$TransactionDto {
const factory TransactionDto({
required String id,
required String type, // "transfer_in", "payment"...
required int amount, // centimes
required String direction, // "credit" | "debit"
required String counterparty,
String? note,
required String status,
required DateTime createdAt,
}) = _TransactionDto;
factory TransactionDto.fromJson(Map<String, dynamic> json) =>
_$TransactionDtoFromJson(json);
}
@freezed
abstract class Transaction with _$Transaction {
const factory Transaction({
required String id,
required TxType type,
required int amountMinor,
required bool isCredit,
required String counterparty,
String? note,
required DateTime createdAt,
}) = _Transaction;
}
extension TransactionDtoMapper on TransactionDto {
Transaction toDomain() => Transaction(
id: id,
type: switch (type) {
'transfer_in' => TxType.transferIn,
'transfer_out' => TxType.transferOut,
'payment' => TxType.payment,
'cash_in' => TxType.cashIn,
'cash_out' => TxType.cashOut,
'bill' => TxType.bill,
_ => throw FormatException('Type inconnu : $type'),
},
amountMinor: amount,
isCredit: direction == 'credit',
counterparty: counterparty,
note: note,
createdAt: createdAt,
);
}
Quand séparer : l'API est générée (OpenAPI) ou contrôlée par une autre équipe, ses noms sont mauvais, ou elle évolue indépendamment de l'app. C'est le cas typique d'un back-end en microservices Spring Boot derrière une API gateway. Quand ne pas séparer : une API stable que vous maîtrisez, avec des modèles déjà propres ; un seul modèle annoté suffit. Le guide Flutter classe cette séparation en Conditional.
Choix à défendre sur le cas inconnu : ici une valeur de type inconnue lève une FormatException, convertie en Failure par le repository. Pour un historique, il est souvent préférable de prévoir un TxType.unknown et d'afficher la ligne quand même : un nouveau type ajouté côté serveur ne doit pas casser l'écran des anciennes versions de l'app.
Injection de dépendances
Injecter une dépendance, c'est la recevoir au lieu de la créer. Le repository reçoit son TransactionApi, qui reçoit son Dio. On peut alors remplacer n'importe quel maillon en test ou par environnement.
final envProvider = Provider<Env>((ref) => Env.fromDefines);
final dioProvider = Provider<Dio>((ref) {
final env = ref.watch(envProvider);
final dio = Dio(BaseOptions(baseUrl: env.apiBaseUrl));
ref.onDispose(dio.close);
return dio;
});
final transactionLocalSourceProvider = Provider<TransactionLocalSource>((ref) {
throw UnimplementedError('Fourni par la couche drift (chapitre 11)');
});
final transactionRepositoryProvider = Provider<TransactionRepository>((ref) {
return TransactionRepositoryImpl(
TransactionApi(ref.watch(dioProvider)),
ref.watch(transactionLocalSourceProvider),
);
});
final getIt = GetIt.instance;
void setupDependencies(Env env) {
getIt
..registerLazySingleton<Dio>(() => Dio(BaseOptions(baseUrl: env.apiBaseUrl)))
..registerLazySingleton<TransactionApi>(() => TransactionApi(getIt()))
..registerLazySingleton<TransactionRepository>(
() => TransactionRepositoryImpl(getIt(), getIt()),
);
}
| Providers Riverpod | get_it (+ injectable) | |
|---|---|---|
| Résolution | Déclarative, lazy, vérifiée à la compilation | Service locator : un oubli d'enregistrement échoue à l'exécution |
| Ordre d'initialisation | Déduit du graphe | À gérer à la main ou via injectable (génération) |
| Tests | Overrides par container, isolés | getIt.reset(), allowReassignment, état global |
| Réactivité | Un changement de dépendance recalcule les dépendants | Aucune : DI pure |
| Usage hors Flutter | ProviderContainer en Dart pur | Natif |
Avec Riverpod, ajouter get_it fait doublon. En BLoC, le couple get_it / injectable, ou RepositoryProvider de flutter_bloc, est courant. Vous noterez que Get.find de GetX est aussi un service locator : les reproches faits à get_it (résolution à l'exécution, état global) s'appliquent, atténués chez get_it par l'absence de couplage avec la navigation.
Gestion d'erreurs typée
Une exception non typée qui remonte jusqu'à l'UI produit un « Erreur : DioException [bad response]... » à l'écran, ou un crash. Une architecture sérieuse convertit les erreurs techniques en erreurs métier à une frontière précise.
sealed class Failure implements Exception {
const Failure(this.message);
final String message;
}
final class NetworkFailure extends Failure {
const NetworkFailure() : super('Connexion impossible');
}
final class UnauthorizedFailure extends Failure {
const UnauthorizedFailure() : super('Session expirée');
}
final class InsufficientFundsFailure extends Failure {
const InsufficientFundsFailure(super.message);
}
final class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
final class ServerFailure extends Failure {
const ServerFailure(this.statusCode) : super('Erreur serveur');
final int? statusCode;
}
final class UnexpectedFailure extends Failure {
const UnexpectedFailure(this.cause) : super('Erreur inattendue');
final Object cause;
}
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
Le guide Flutter propose un Result très proche (Ok / Error portant une Exception). Nommer la variante d'erreur Error masque la classe Error de dart:core dans les fichiers qui l'importent ; Err évite cette ambiguïté. Comme Failure est sealed, un switch sur l'erreur est exhaustif : ajouter un cas oblige à traiter tous les écrans concernés.
Où convertir les exceptions Dio
Dans la couche data, jamais au-dessus. Le guide Flutter capture les exceptions dans les services et renvoie un Result ; dans le Wallet, la conversion a lieu dans le repository parce qu'il décide aussi du repli sur le cache. Dans les deux cas, aucune DioException ne franchit la frontière du repository.
Failure failureFromDio(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError =>
const NetworkFailure(),
DioExceptionType.badResponse => _fromResponse(e.response),
_ => UnexpectedFailure(e),
};
}
Failure _fromResponse(Response<dynamic>? response) {
// Corps d'erreur du contrat : { "error": { "code": "...", "message": "..." } }
final data = response?.data;
final error = data is Map<String, dynamic> ? data['error'] : null;
final code = error is Map<String, dynamic> ? error['code'] : null;
final message = error is Map<String, dynamic> ? error['message'] as String? : null;
return switch (code) {
'TOKEN_EXPIRED' || 'REFRESH_INVALID' || 'INVALID_CREDENTIALS' =>
const UnauthorizedFailure(),
'INSUFFICIENT_FUNDS' => InsufficientFundsFailure(message ?? 'Solde insuffisant'),
'VALIDATION_ERROR' => ValidationFailure(message ?? 'Données invalides'),
_ => ServerFailure(response?.statusCode),
};
}
Result ou exceptions : un vrai débat
Result<T> | Exceptions typées (throw Failure) | |
|---|---|---|
| Visibilité | L'échec est dans la signature, impossible à ignorer | Invisible dans la signature |
| Verbosité | Un switch à chaque appel | Un try/catch là où c'est utile |
| Avec Riverpod | Il faut déballer pour alimenter AsyncValue | S'intègre directement : AsyncError porte le Failure |
| Erreurs de programmation | Restent des exceptions | Idem |
Une combinaison pragmatique avec Riverpod : Result jusqu'au repository (contrat explicite, testable), puis le provider déballe et relance le Failure, qui arrive typé dans l'AsyncError.
final transactionDetailProvider =
FutureProvider.autoDispose.family<Transaction, String>((ref, id) async {
final result = await ref.watch(transactionRepositoryProvider).fetchById(id);
return switch (result) {
Ok(:final value) => value,
Err(:final failure) => throw failure, // AsyncError porte un Failure typé
};
});
Lien avec Riverpod 3 : Failure implémente Exception, donc le retry automatique le réessaiera par défaut, y compris un solde insuffisant. Configurez la fonction retry pour ne réessayer que NetworkFailure (et éventuellement ServerFailure 5xx). Savoir relier deux sujets ainsi impressionne plus que réciter l'un ou l'autre.
Immutabilité et freezed
Un état immuable rend les changements explicites, l'égalité fiable (indispensable au filtrage == de Riverpod 3 et de BLoC) et les bugs de partage impossibles. Écrire à la main ==, hashCode, copyWith et toString est fastidieux et source d'erreurs : freezed les génère.
- Classe de données :
@freezed abstract class X with _$X. Depuis freezed 3, la classe doit êtreabstract(une seule variante) ousealed(union). copyWith:tx.copyWith(note: 'Loyer'), y compris pour remettre un champ nullable ànull.- Unions : plusieurs constructeurs factory sur une classe
sealed, consommés par unswitchDart 3 exhaustif. Les méthodeswhen/mapsont encore générées (sous forme d'extension en freezed 4), mais leswitchnatif est à privilégier.
@freezed
sealed class TransferState with _$TransferState {
const factory TransferState.editing() = TransferEditing;
const factory TransferState.sending() = TransferSending;
const factory TransferState.done(Transaction tx) = TransferDone;
const factory TransferState.failed(String message) = TransferFailed;
}
String describe(TransferState s) => switch (s) {
TransferEditing() => 'saisie',
TransferSending() => 'envoi',
TransferDone(:final tx) => 'ok ${tx.id}',
TransferFailed(:final message) => message,
};
Alternative sans génération : Dart 3 permet d'écrire des sealed classes à la main (comme Failure ci-dessus). Pour des unions simples sans copyWith, c'est suffisant. Depuis Dart 3.13, les primary constructors stables réduisent encore le code à écrire pour ces petites classes.
Flavors et environnements
Une app grand compte a au moins trois environnements (dev, recette ou staging, prod), souvent installés côte à côte sur le même téléphone. Deux mécanismes complémentaires :
- Flavors natifs (
--flavor) : identifiant d'application, nom affiché, icône, fichiers Firebase différents. Ils agissent sur le build Android / iOS. - Configuration Dart : URL d'API, clés publiques, drapeaux, via un point d'entrée par environnement et/ou
--dart-define-from-file.
android {
// ...
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "Wallet DEV")
}
create("prod") {
dimension = "env"
resValue("string", "app_name", "Wallet")
}
}
}
Côté iOS : un scheme Xcode partagé par flavor (dev, prod), des build configurations dupliquées nommées Debug-dev, Release-dev, Profile-dev (suffixe en minuscules pour que la CLI les reconnaisse), un Product Bundle Identifier et un APP_DISPLAY_NAME par configuration. Le nom passé à --flavor doit correspondre au scheme. Avec CocoaPods, les nouvelles configurations sont aussi à déclarer dans le Podfile ; depuis Flutter 3.44, Swift Package Manager est le gestionnaire par défaut.
// lib/app/env.dart
class Env {
const Env({required this.apiBaseUrl, required this.name});
final String apiBaseUrl;
final String name;
static const fromDefines = Env(
apiBaseUrl: String.fromEnvironment('API_BASE_URL'),
name: String.fromEnvironment('ENV_NAME', defaultValue: 'dev'),
);
}
// lib/main_common.dart
void mainCommon(Env env) {
runApp(
ProviderScope(
overrides: [envProvider.overrideWithValue(env)],
child: const WalletApp(),
),
);
}
// lib/main_dev.dart
void main() => mainCommon(
const Env(apiBaseUrl: 'http://10.0.2.2:8080/api/v1', name: 'dev'),
);
# point d'entrée par flavor
flutter run --flavor dev -t lib/main_dev.dart
flutter build appbundle --flavor prod -t lib/main_prod.dart
# ou un seul main.dart avec un fichier de configuration (.json ou .env)
flutter run --flavor dev --dart-define-from-file=config/dev.json
{
"API_BASE_URL": "http://10.0.2.2:8080/api/v1",
"ENV_NAME": "dev"
}
Côté Dart, la constante appFlavor de package:flutter/services.dart renvoie le nom du flavor (ou null sans flavor). Le champ default-flavor de la section flutter du pubspec fixe un flavor par défaut, et les assets peuvent être limités à certains flavors (sous-champ flavors d'un asset).
« Mettez-vous la clé d'API dans --dart-define ? » Les valeurs de --dart-define sont compilées dans le binaire et récupérables par rétro-ingénierie. Elles conviennent à une URL ou à un drapeau, jamais à un secret. Un secret reste côté serveur ; l'app obtient des jetons à durée courte après authentification. L'obfuscation (--obfuscate --split-debug-info) ralentit l'analyse, elle ne protège pas un secret.
Monorepo et packages
Quand l'app grossit, on extrait des packages Dart locaux : core_network, design_system, une feature par package. Les frontières deviennent vérifiées par le compilateur : un package n'accède qu'à ce qu'il déclare en dépendance et à ce que l'autre exporte.
Pub workspaces (natif depuis Dart 3.6)
Dart 3.6 a introduit les pub workspaces : une résolution de dépendances unique pour tous les packages du dépôt, un seul pubspec.lock et un seul .dart_tool/package_config.json à la racine. Depuis Dart 3.11, la liste accepte des globs (packages/*).
name: wallet_workspace
publish_to: none
environment:
sdk: ^3.13.0
workspace:
- app
- packages/* # glob accepté depuis Dart 3.11
name: core_network
environment:
sdk: ^3.13.0
resolution: workspace
dependencies:
dio: ^5.11.0
Contraintes : une seule version de chaque package pour tout le dépôt (les conflits se règlent ensemble), et les dependency_overrides à placer dans le pubspec racine.
Melos
Melos ajoute ce que pub ne fait pas : scripts sur tous les packages, exécution filtrée (seulement les packages modifiés), versionnement et changelogs. Depuis sa version 7, Melos s'appuie sur les pub workspaces : il n'y a plus de melos.yaml, la configuration se place sous une clé melos: du pubspec racine. La version actuelle est la 8.x.
melos:
scripts:
analyze:
exec:
command: dart analyze --fatal-infos
concurrency: 4
test:
exec:
command: flutter test
concurrency: 2
packageFilters:
dirExists: test
Syntaxe de Melos 8 : la commande d'un script exécuté dans chaque package se place sous exec.command. Avant la 8.0, elle se plaçait dans run avec les options sous exec, forme qui n'est plus acceptée. Vérifiez la version utilisée par le projet avant de recopier un exemple trouvé en ligne.
Modulariser pour une grande équipe
- Un package par feature, plus des packages transverses (
core,design_system,l10n). Les features ne dépendent pas les unes des autres ; l'app les assemble (routes, providers). - API publique explicite : chaque package exporte un fichier
lib/feature_x.dart; le reste vit danslib/src/. - Propriété : un fichier
CODEOWNERSassocie chaque package à une équipe pour les revues. - CI sélective : n'analyser et tester que les packages touchés et leurs dépendants.
- Ne pas sur-découper : 40 packages pour 5 développeurs, c'est surtout du temps de configuration.
Conventions et lints
Les conventions évitent les débats en revue de code. L'analyzer les rend automatiques.
flutter_lints(6.x) : le socle officiel, inclus par défaut dansflutter create.very_good_analysis(11.x) : beaucoup plus strict (documentation publique, types explicites...). Bien adapté à une équipe nombreuse, pénible sur un prototype.riverpod_lint: désormais un plugin natif de l'analyzer.
include: package:very_good_analysis/analysis_options.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
language:
strict-casts: true
strict-raw-types: true
linter:
rules:
public_member_api_docs: false # assoupli pour le code applicatif
plugins:
riverpod_lint: ^3.1.9
Complétez par dart format vérifié en CI (dart format --set-exit-if-changed .), une convention de nommage de fichiers (snake_case, suffixes _screen, _notifier, _repository) et des commits conventionnels si le client les impose.
ADR : Architecture Decision Records
Un ADR est un court document versionné dans le dépôt qui consigne une décision d'architecture : le contexte, la décision, les alternatives écartées et les conséquences. En régie, c'est particulièrement apprécié : les consultants tournent, les décisions restent, et le client garde la trace de pourquoi Riverpod a été choisi plutôt que BLoC, ou pourquoi les DTO sont séparés.
# ADR 0003 : Riverpod comme state management
Statut : accepté (2026-10-15)
## Contexte
Application wallet, 6 développeurs, durée de vie visée 8 ans.
Base existante en GetX sur 2 écrans.
## Décision
Riverpod 3 avec riverpod_generator. Notifier / AsyncNotifier uniquement.
Migration des écrans GetX au fil des évolutions fonctionnelles.
## Alternatives écartées
- BLoC : plus verbeux, aucune expérience dans l'équipe actuelle.
- Rester sur GetX : résolution des dépendances à l'exécution, tests plus coûteux.
## Conséquences
- build_runner en CI (déjà présent pour freezed).
- Formation de 2 jours pour l'équipe.
- Revue : aucun nouvel import de package:get.
Règles : un ADR par décision, numéroté, jamais réécrit (on en crée un nouveau qui remplace l'ancien), une page au plus.
« C'était une app de paiement d'une centaine d'endpoints. Organisation feature-first dans un monorepo, avec des packages partagés pour le réseau et le design system. Par feature : une couche data avec services et repositories qui convertissent DTO et erreurs, une couche domaine avec entités et use cases quand la logique le justifiait, une couche présentation. Les erreurs étaient typées et converties à la frontière du repository. Trois environnements avec flavors. Avec le recul, je simplifierais les use cases passe-plat et je remplacerais le service locator par l'injection de Riverpod, plus sûre et plus simple à tester. »
Questions d'entretien
Décrivez l'architecture de votre dernière application.
Structure en quatre temps : organisation (feature-first, packages), couches (UI, domaine optionnel, data) et leur rôle, transverses (DI, erreurs, environnements, tests), puis un regard critique (ce que vous changeriez). Restez à 3 minutes, et proposez de détailler un point au choix de l'interlocuteur.
Pourquoi un repository ?
Pour avoir une source de vérité par type de données qui cache l'origine (réseau, cache, base), convertit DTO et erreurs, et applique la politique de cache et de retry. Les view models restent indépendants du transport, et le repository se remplace par un fake en test. Le guide Flutter le classe en Strongly recommend.
Où mettez-vous la logique métier ?
Jamais dans les widgets. La logique de présentation (formatage, état d'écran) dans le view model ou Notifier. Les règles métier pures (validation d'un montant, plafond) dans des classes Dart sans dépendance, appelées par le Notifier ou par un use case. Un use case quand la règle combine plusieurs repositories ou est partagée. Le serveur reste la référence pour toute règle de sécurité.
Clean Architecture : est-ce toujours pertinent en Flutter ?
Les principes oui (dépendances vers l'intérieur, domaine indépendant), l'application dogmatique non. Les use cases qui délèguent une ligne et les interfaces sans second implémenteur coûtent sans rapporter. Le guide Flutter rend la domain layer optionnelle ; je l'ajoute quand la complexité l'exige.
Pourquoi séparer DTO et modèle de domaine ?
Pour isoler l'app des changements et des conventions de l'API : noms, formats, énumérations en chaînes, champs techniques. Le mapping est un point unique d'adaptation et de validation. Je ne sépare pas si l'API est stable, maîtrisée et déjà propre : c'est une recommandation conditionnelle.
Comment gérez-vous les erreurs de bout en bout ?
Les exceptions techniques (Dio, parsing) sont converties en Failure scellé dans la couche data. Les repositories renvoient un Result ou lèvent un Failure. L'UI fait un switch exhaustif pour afficher un message adapté. Les erreurs inattendues sont journalisées (Crashlytics ou Sentry) sans données sensibles.
Riverpod ou get_it pour l'injection de dépendances ?
Avec Riverpod, les providers suffisent : résolution vérifiée à la compilation, ordre déduit du graphe, overrides isolés par test. get_it est un service locator : simple et indépendant de Flutter, mais un oubli d'enregistrement n'apparaît qu'à l'exécution et les tests manipulent un état global. En BLoC, get_it / injectable reste un choix courant et défendable.
Comment gérez-vous plusieurs environnements ?
Flavors natifs pour l'identifiant, le nom et l'icône (productFlavors Android, schemes et build configurations iOS), un point d'entrée par environnement ou --dart-define-from-file pour la configuration Dart, et un envProvider surchargé. Aucun secret dans le binaire.
Melos, pub workspaces : qu'apportent-ils ?
Les pub workspaces (Dart 3.6) donnent une résolution unique et un seul lockfile pour tous les packages : fini les versions divergentes. Melos s'appuie dessus depuis sa version 7 et ajoute les scripts multi-packages, le filtrage des packages modifiés pour la CI et le versionnement.
Qu'est-ce qu'un ADR et pourquoi en écrire ?
Un document court et versionné qui consigne une décision d'architecture, son contexte, les alternatives et les conséquences. En régie, il préserve le raisonnement quand les personnes changent et évite de rouvrir les mêmes débats. Je propose d'en écrire dès les premières semaines d'une mission.
Exercices
Sur une feuille, sans ordinateur : dessinez l'architecture complète d'une app e-wallet (connexion, solde, historique paginé, transfert, bénéficiaires, hors ligne). Faites figurer les features, les couches de chacune, les providers principaux, le flux d'un transfert de l'appui sur « Confirmer » jusqu'à la mise à jour du solde, et les endroits où les erreurs sont converties.
Critères : aucune flèche de la couche data vers l'UI ; un seul endroit où DioException est attrapée ; l'Idempotency-Key placée au bon niveau ; vous savez présenter le schéma en 3 minutes.
Écrivez Failure (sealed), Result, failureFromDio pour les codes d'erreur du contrat Wallet, et un test unitaire par code (construisez des DioException avec un Response factice).
Critères : switch exhaustifs ; REFRESH_INVALID et TOKEN_EXPIRED traités ; un corps de réponse non JSON ne fait pas planter la conversion.
Rédigez un ADR d'une page : « Séparer DTO et modèles de domaine dans le Wallet : oui ou non ». Critères : contexte factuel, au moins deux alternatives, conséquences négatives assumées.
Dans l'app Wallet (voir le projet) :
- Réorganisez
lib/selon l'arborescence feature-first ci-dessus. core/errors/:FailureetResult;core/network/:failureFromDio.- Pour
transactionsetwallet: DTO freezed, modèle de domaine, mapper, interface de repository et implémentation (la source locale reste un fake jusqu'au chapitre 11). - Flavors
devetprod:applicationIdSuffixet nom d'app Android, schemes iOS,main_dev.dartetmain_prod.dartqui surchargentenvProvider. - Fonction
retryglobale qui ne réessaie queNetworkFailure. analysis_options.yamlavecvery_good_analysisouflutter_lintsetriverpod_lint, et un premier ADR dansdocs/adr/.
Critère : dart analyze sans avertissement, et aucun import de package:dio en dehors de core/network/ et des dossiers data/.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.
Pour aller plus loin
- Flutter : App architecture
- Flutter : Guide to app architecture
- Flutter : Architecture recommendations
- Flutter : Error handling with Result objects
- Flutter : flavors Android et flavors iOS et macOS
- Dart : pub workspaces
- Dart : personnaliser l'analyse statique
- Melos, freezed, very_good_analysis, get_it