Chapitre 08 · Semaine 2

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.

Durée 3 séances de 2 h Niveau avancé Prérequis chapitres 06 et 07

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.

┌──────────────────────── UI layer ────────────────────────┐ │ View (widgets) ◀── état ── ViewModel ── commandes ──▶ │ └──────────────────────────────────┬────────────────────────┘ │ (domain layer optionnelle : use cases) ┌──────────────────────── Data layer ──────────────────────┐ │ Repository (source de vérité, cache, retry, modèles de │ │ domaine) ──▶ Services (API REST, plateforme, fichiers) │ └──────────────────────────────────────────────────────────┘
  • 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 :

RecommandationForce
Couches data et UI clairement définiesStrongly recommend
Repository pattern dans la couche data, classes de repository abstraitesStrongly recommend
MVVM (views et view models) dans la couche UIStrongly recommend
Pas de logique dans les widgetsStrongly recommend
Flux de données unidirectionnel, modèles immuablesStrongly recommend
Injection de dépendancesStrongly recommend
Fakes pour les tests, tester les composants séparément et ensembleStrongly recommend
Commands, freezed ou built_value, go_router, conventions de nommageRecommend
Domain layer, modèles d'API séparés des modèles de domaine, ChangeNotifierConditional
Avec Riverpod

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 ArchitectureGuide FlutterDans le Wallet avec Riverpod
EntitiesDomain modelsTransaction, Wallet (freezed, sans JSON)
Use cases / interactorsDomain layer optionnelleSendTransfer, seulement si la logique le justifie
Repository (interface dans le domaine)Abstract repositoryabstract interface class TransactionRepository
Data sourcesServicesTransactionApi (Dio), TransactionLocalSource (drift)
Presentation (controllers, presenters)View models + viewsNotifiers + 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.
À dire en entretien

« 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-firstFeature-first
Structuredata/, domain/, presentation/ à la racine, toutes features mélangéesfeatures/transfer/ contient ses propres data/, domain/, presentation/
AvantageSimple sur une petite appUne feature se lit, se teste, se supprime ou s'extrait en package d'un bloc
LimiteUne modification touche trois dossiers éloignés ; mauvais passage à l'échelleDiscipline 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.

Piège d'entretien

« 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 Riverpodget_it (+ injectable)
RésolutionDéclarative, lazy, vérifiée à la compilationService locator : un oubli d'enregistrement échoue à l'exécution
Ordre d'initialisationDéduit du grapheÀ gérer à la main ou via injectable (génération)
TestsOverrides par container, isolésgetIt.reset(), allowReassignment, état global
RéactivitéUn changement de dépendance recalcule les dépendantsAucune : DI pure
Usage hors FlutterProviderContainer en Dart purNatif

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 à ignorerInvisible dans la signature
VerbositéUn switch à chaque appelUn try/catch là où c'est utile
Avec RiverpodIl faut déballer pour alimenter AsyncValueS'intègre directement : AsyncError porte le Failure
Erreurs de programmationRestent des exceptionsIdem

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é
  };
});
Piège d'entretien

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 être abstract (une seule variante) ou sealed (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 un switch Dart 3 exhaustif. Les méthodes when / map sont encore générées (sous forme d'extension en freezed 4), mais le switch natif 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).

Piège d'entretien

« 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
À vérifier sur votre version

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 dans lib/src/.
  • Propriété : un fichier CODEOWNERS associe 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 dans flutter 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.

À dire en entretien : « décrivez l'architecture de votre dernière app »

« 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

Exercice 1 · Sans IA · Papier · 20 min

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.

Exercice 2 · Sans IA · 35 min

É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.

Exercice 3 · Écrit · 20 min

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.

Projet fil rouge · étape 08

Dans l'app Wallet (voir le projet) :

  • Réorganisez lib/ selon l'arborescence feature-first ci-dessus.
  • core/errors/ : Failure et Result ; core/network/ : failureFromDio.
  • Pour transactions et wallet : DTO freezed, modèle de domaine, mapper, interface de repository et implémentation (la source locale reste un fake jusqu'au chapitre 11).
  • Flavors dev et prod : applicationIdSuffix et nom d'app Android, schemes iOS, main_dev.dart et main_prod.dart qui surchargent envProvider.
  • Fonction retry globale qui ne réessaie que NetworkFailure.
  • analysis_options.yaml avec very_good_analysis ou flutter_lints et riverpod_lint, et un premier ADR dans docs/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