Découvrons des design patterns dans Flutter

Qui n’a jamais ouvert un fichier et rencontré un fichier de 3000 lignes, un constructeur à rallonge, une logique métier mélangée à l’UI, et des if/else partout ? Et si on se posait une question simple : combien de temps perdez-vous à comprendre un widget « monstre » avant de le modifier sans crainte ?

Dans cet article, nous allons partir d’un cas concret transposé au domaine des véhicules (voiture, vélo, camion) pour montrer comment des design patterns classiques - Builder, Factory, Strategy, Template Method - transforment un code fragile en architecture claire, testable et extensible. Le code produit sera disponible sur un lien github à la fin de l’article ! Bonne lecture.

L’exemple d’aujourd’hui

Une fiche véhicule doit :

  • Afficher les informations clés d'un véhicule (nom, type).
  • Inclure une icône de profil correspondant au type de véhicule.
  • Afficher des spécifications globales (nombre de roues, type de moteur thermique/électrique).
  • Afficher des spécifications spécifiques au véhicule
    • Voiture : la marque, le modèle et l'immatriculation
    • Vélo : le type
  • Déclencher une action contextuelle spécifique (ex: affichage d'un message) lors d'un appui, sans boutons supplémentaires.
  • Avoir des états différents selon le contexte (chargement, erreur, stable)

Quand un widget devient un monstre

Des constructeurs à rallonge : le cauchemar du développeur

Problème réel : 15+ paramètres par constructeur, répétitions, et risques d’erreurs.

Exemple (simplifié) d’un widget de fiche véhicule :

// AVANT - constructeur « voiture » verbeux
const VehicleWidgetCar({
  required this.typeLabel,
  required this.brand,
  required this.model,
  required this.imageUrl,
  required this.onTap,
  required this.title,
  required this.subtitle,
  required this.wheelCount,
  required this.fuelType,
  required this.doorCount,
  required this.hasSunroof,
  required this.registration,
  required this.tryAgainButtonLabel,
  required this.isLoading,
  required this.isActionLoading,
  super.key,
}) : vehicleType = VehicleType.car,
     // … 8 autres champs initialisés à null pour d’autres types
     bikeSpecificData = null,
     truckSpecificData = null;

Code verbeux de départ pour un widget fiche de voiture

Cette approche nuit gravement à la lisibilité du code, rendant la maintenance extrêmement complexe, et elle crée un couplage fort et indésirable entre les données, la logique métier et le rendu, ce qui complique les évolutions futures et l'ajout de nouvelles fonctionnalités. Ça devient un processus risqué, augmentant le risque d'oublis ou d'incohérences potentielles.

Un widget, toutes les responsabilités : violation du SRP

Le SRP (Single Responsibility Principle) dit qu’une classe ou un module doit avoir une seule raison de changer, c’est-à-dire une seule responsabilité.

Ce widget était responsable de la configuration des différents types (voiture, vélo, camion).

Il décidait également de l’affichage, intégrant la logique métier et il construisait l’UI en assemblant les différentes sections PUIS gérait l’état (chargement, erreurs).

alt_text
Diagramme d'utilisation du widget de base

Manque d’extensibilité et de testabilité

L'ajout d'un nouveau type (par exemple, vélo électrique) nécessite de modifier les constructeurs, la méthode build(), l'énumération, et d'implémenter des conditions à de TOUS les endroits.

L'écriture de tests unitaires impliquait l'instanciation de widgets complexes avec des dizaines de paramètres à chacun des tests

Une lisibilité impossible

Le widget présentait à lui-même plus de 3000 lignes indentées, on ne se retrouvait plus du tout.

// Pseudo code du widget
build() {
  return Column(
    if titre 
Text(titre)
    if vehicule.type == car 
Text(model)
    else if vehicule.type == bicycle 
Text('bicycle')
    if vehicule.showSpec && vehicule.spec != null 
Text(vehicule.spec)
    Bouton(
	onTap
	  if(vehicule.type == car and vehicule.type != camion)
           showVehiculeMoteur()
}

Pseudo code pour la construction du widget de départ


Première étape avec un premier patron de conception

Utiliser le pattern Factory pour des constructeurs intelligents

On masque la complexité d’initialisation derrière une API simple. La fabrique est un patron de conception de création qui définit une interface pour créer des objets dans une classe mère, mais délègue le choix des types d’objets à créer aux sous-classes.

💡
Lien pour la documentation du pattern : Fabrique / Factory Method
alt_text
Diagramme de classe pour la première version de résolution du widget de fiche de véhicule
class VehicleWidget extends StatelessWidget {
  const VehicleWidget._({required this.configuration, super.key});

  final VehicleConfiguration configuration;

  factory VehicleWidget.car({
    required String typeLabel,
    required String title,
    required String subtitle,
    required VoidCallback onTap,
    required String buttonLabel,
    required bool isActionLoading,
    required VehicleSpecsData specs,
    required EngineData engine,
    Key? key,
  }) => VehicleWidget._(
    key: key,
    configuration: CarConfiguration(
      typeLabel: typeLabel,
      title: title,
      subtitle: subtitle,
      onTap: onTap,
      buttonLabel: buttonLabel,
      isActionLoading: isActionLoading,
      specs: specs,
      engine: engine,
    ),
  );

  factory VehicleWidget.bicycle({
    required String typeLabel,
    required String title,
    required String subtitle,
    required VoidCallback onTap,
    required String buttonLabel,
    required bool isActionLoading,
    required VehicleSpecsData specs,
    bool isElectric = false,
    Key? key,
  }) => VehicleWidget._(
    key: key,
    configuration: BicycleConfiguration(
      typeLabel: typeLabel,
      title: title,
      subtitle: subtitle,
      onTap: onTap,
      buttonLabel: buttonLabel,
      isActionLoading: isActionLoading,
      specs: specs,
      isElectric: isElectric,
    ),
  );

  @override
  Widget build(BuildContext context) => VehiculeCard(
    header: VehiculeCardHeader(
      vehicleType: configuration.typeLabel,
      title: configuration.title,
      subtitle: configuration.subtitle,
      icon: configuration.buildIcon(),
    ),
    specs: configuration.buildEngineSection(),
    engine: configuration.buildSpecsSection(),
    button: configuration.buildButton(),
  );
}

Code principal pour la première version de résolution du widget de fiche de véhicule

Le design de l'API se veut propre, garantissant une initialisation centralisée et sécurisée, ce qui facilite grandement la consommation par le code appelant. On utilise aussi une nouvelle classe en plus de la Factory qui va centraliser les informations des différents véhicules.

Cette classe de configuration utilise aussi un patron de conception qui est la strategy. La stratégie est un patron de conception comportemental qui permet de définir une famille d’algorithmes, de les mettre dans des classes séparées et de rendre leurs objets interchangeables. On encapsule la logique spécifique à chaque type via une sealed class pour la sûreté et l’exhaustivité.

💡
Lien pour la documentation Stratégie / Strategy
abstract class VehicleConfiguration {
  const VehicleConfiguration({
    required this.typeLabel,
    required this.title,
    required this.subtitle,
    required this.onTap,
    required this.buttonLabel,
    required this.isActionLoading,
  });

  final String typeLabel;
  final String title;
  final String subtitle;
  final VoidCallback? onTap;
  final String? buttonLabel;
  final bool isActionLoading;

  VehicleType get vehicleType;

  Widget buildIcon();
  Widget? buildSpecsSection();
  Widget? buildEngineSection();
  Widget? buildButton();
}

class CarConfiguration extends VehicleConfiguration {
  @override
  VehicleType get vehicleType => VehicleType.car;
}

class BicycleConfiguration extends VehicleConfiguration {
  @override
  VehicleType get vehicleType => VehicleType.bicycle;
}

Code pour la création de classe Stratégie

Cependant, cette approche se limite à abstraire la modélisation du véhicule et de son contenu au sein d'une classe distincte. L'ajout de nouveaux véhicules demeure laborieux et la compréhension des mécanismes internes reste compliquée, malgré une simplification de la construction des widgets et une clarté augmentée des constructeurs.

Ici, VehicleConfiguration gère la distribution des données, l'interface utilisateur, la logique et l'état, ce qui ne résout pas le problème de base, mais le délègue simplement. On veut séparer les responsabilités pour pouvoir mieux les comprendre et mieux les tester. Alors allons-y !


Design Patterns à la rescousse

SRP comme fondation

On sépare les responsabilités en quatre rôles complémentaires, alignés avec le diagramme et le code :

  • VehicleWidgetDirector (Director) : orchestre la construction, choisit quelles sections inclure et dans quel ordre (Header, Specs, Engine, Button).
  • VehiculeCardBuilder (Builder) : assemblent la fiche, implémentent les différences par type
  • VehicleCardConfiguration (notre Strategy qui devient Template Method) : porte les données nécessaires (typeLabel, title, subtitle, specs, engine, onTap, buttonLabel) ; pas de logique de rendu.
  • VehiculeCard : widget final composé (Header, Specs, Engine, Button).
alt_text
Diagramme de parcours pour la seconde version de résolution du widget de fiche de véhicule

Choisir un design pattern qui correspond au mieux pour structurer données et logique métier : Template Method !

Notre classe VehicleConfiguration, notre stratégie va devenir une template method car elle va servir uniquement à fournir les détails des véhicules. Le design pattern Patron de Méthode est un patron de conception comportemental qui permet de mettre le squelette d’un algorithme dans la classe mère, mais laisse les sous-classes redéfinir certaines étapes de l’algorithme sans changer sa structure.

💡
Lien pour la documentation Template Method
abstract class VehicleCardConfiguration {
  const VehicleCardConfiguration({
    required this.typeLabel,
    required this.title,
    required this.subtitle,
    required this.onTap,
    required this.buttonLabel,
  });

  final String typeLabel;
  final String title;
  final String subtitle;
  final VoidCallback onTap;
  final String buttonLabel;

  VehicleType get vehicleType;
}

class CarCardConfiguration extends VehicleCardConfiguration {
  const CarCardConfiguration({
    required super.typeLabel,
    required super.title,
    required super.subtitle,
    required super.onTap,
    required super.buttonLabel,
    required this.specs,
    required this.engine,
  });

  final VehicleSpecsData specs;
  final EngineData engine;

  @override
  VehicleType get vehicleType => VehicleType.car;
}

class BicycleCardConfiguration extends VehicleCardConfiguration {
  const BicycleCardConfiguration({
    required super.typeLabel,
    required super.title,
    required super.subtitle,
    required super.onTap,
    required super.buttonLabel,
    required this.specs,
    this.isElectric = false,
  });

  final VehicleSpecsData specs;
  final bool isElectric;

  @override
  VehicleType get vehicleType => VehicleType.bicycle;
}

Code de la template method la seconde version de résolution du widget de fiche de véhicule

Ce pattern améliore la clarté, une meilleure sécurité sur le type et une logique métier testable. Chaque implémentation de VehicleConfiguration agit comme un nouveau vehicule : le VehicleBuilder s’adapte au type sans conditions éparpillées. Ajouter TruckConfiguration n’implique pas de modifier le VehicleWidget : on respecte l’OCP (ouvert à l’extension, fermé à la modification).

Pattern Builder : construire l’UI pièce par pièce

On sépare la construction d’un widget complexe en méthodes modulaires qui s’appuient sur la configuration. Le Builder est un patron de conception de création qui permet de construire des objets complexes étape par étape. Il permet de produire différentes variations ou représentations d’un objet en utilisant le même code de construction. Lien pour la documentation pattern builder : Builder et de son Directeur

abstract class VehiculeCardBuilder {
  VehiculeCardBuilder();

  late String _vehicleType;
  late String _title;
  late String _subtitle;
  late Widget _icon;

  Widget? _specs;
  Widget? _engine;
  Widget? _button;

  void withType();
  void withTitle();
  void withSubtitle();
  void withIcon();
  void withSpecs();
  void withEngine();
  void withButton();

  Widget build() => VehiculeCard(
    header: VehiculeCardHeader(
      vehicleType: _vehicleType,
      title: _title,
      subtitle: _subtitle,
      icon: _icon,
    ),
    specs: _specs,
    engine: _engine,
    button: _button,
  );
}

class CarBuilder extends VehiculeCardBuilder {
  final CarCardConfiguration configuration;

  CarBuilder({required this.configuration});
  @override
  void withEngine() {
    _engine = CarEngineSection(engine: configuration.engine);
  }

  @override
  void withIcon() {
    _icon = CarIcon();
  }

  @override
  void withSpecs() {
    _specs = CarSpecSection(specs: configuration.specs);
  }

  @override
  void withSubtitle() {
    _subtitle = configuration.subtitle;
  }

  @override
  void withTitle() {
    _title = configuration.title;
  }

  @override
  void withType() {
    _vehicleType = configuration.vehicleType.name;
  }

  @override
  void withButton() {
    _button = VehiculeCardButton(
      onTap: configuration.onTap,
      buttonLabel: configuration.buttonLabel,
    );
  }
}

class BikeBuilder extends VehiculeCardBuilder {
  final BicycleCardConfiguration configuration;

  BikeBuilder({required this.configuration});

  @override
  void withEngine() {
    _engine = null;
  }

  @override
  void withIcon() {
    _icon = BikeIcon();
  }

  @override
  void withSpecs() {
    _specs = BicycleSpecSection(specs: configuration.specs);
  }

  @override
  void withSubtitle() {
    _subtitle = configuration.subtitle;
  }

  @override
  void withTitle() {
    _title = configuration.title;
  }

  @override
  void withType() {
    _vehicleType = configuration.vehicleType.name;
  }

  @override
  void withButton() {
    _button = VehiculeCardButton(
      onTap: configuration.onTap,
      buttonLabel: configuration.buttonLabel,
    );
  }
}

class BikeLoadingBuilder extends BikeBuilder {
  BikeLoadingBuilder({required super.configuration});

  @override
  void withIcon() {
    _icon = CircularProgressIndicator();
  }

  @override
  void withButton() {
    _button = VehiculeCardButton(onTap: () {}, buttonLabel: 'LOADING');
  }
}

Code pour les Builders de la seconde version de résolution du widget de fiche de véhicule

Ce document offre une flexibilité appréciable, des sections réutilisables et une maintenance simplifiée. L’utilisation des mots clés late permettent d’avoir une erreur au runtime si les éléments obligatoires ont été oubliés par le directeur.

alt_text
Diagramme de classe pour l'ensemble de la seconde version de résolution du widget de fiche de véhicule


Pour notre exemple de fiche de véhicule, quelle que soit la méthode utilisée, nous avons besoin d’au moins un titre et un sous-titre, donc si il ne sont pas appelé par le Directeur au moment de la création, il y aura une late initialization error (documentation : https://www.dhiwise.com/post/understanding-the-late-initialization-error-in-flutter)

class VehicleWidgetDirector {
  const VehicleWidgetDirector();

  void buildCarCard(CarBuilder carBuilder) {
    carBuilder
      ..withType()
      ..withTitle()
      ..withSubtitle()
      ..withIcon()
      ..withButton()
      ..withSpecs()
      ..withEngine();
  }

  void buildCarCardWithoutSpecs(CarBuilder carBuilder) {
    carBuilder
      ..withType()
      ..withTitle()
      ..withSubtitle()
      ..withIcon()
      ..withButton();
  }

  void buildBikeCard(BikeBuilder bikeBuilder) {
    bikeBuilder
      ..withType()
      ..withTitle()
      ..withButton()
      ..withSubtitle()
      ..withIcon()
      ..withSpecs();
  }

  void buildBikeCardWithNoButton(BikeBuilder bikeBuilder) {
    bikeBuilder
      ..withType()
      ..withTitle()
      ..withSubtitle()
      ..withIcon()
      ..withSpecs();
  }	
}

Directeur de la seconde version de résolution du widget de fiche de véhicule


Sautons ensemble à la longue conclusion

Comparaison synthétique

Pour notre première version avec les design pattern Strategy et Factory, le VehicleWidget opère la sélection d'une configuration spécifique par les factories, telles que CarConfiguration ou BicycleConfiguration. Chaque configuration expose directement les composants prêts à être affichés (icône, spécifications, moteur, bouton). Le VehicleWidget procède ensuite à la composition du widget final en assemblant ces différents éléments.

Le flux de travail est simple; chaque configuration détermine les éléments à exposer (exemple: il n’y a pas de moteur sur un vélo, à part vos jambes).

Pour notre seconde version avec ses Builder et son Director, le VehicleWidget instancie un Director qui orchestre les étapes de construction (withType/Title/Subtitle/Icon/Specs/Engine/Button). Un Builder concret (CarBuilder/BikeBuilder) lit une configuration de données (CarCardConfiguration/BicycleCardConfiguration). Le Director impose une séquence uniforme et peut faire varier les "recettes" (inclure/exclure des sections).

Le produit final est construit étape par étape, de manière explicite, plus lisible par rapport à la première version.

Avantages par approche

L'implémentation de la première version est simple et rapide, nécessitant moins de classes et de formalités. Le code est très lisible, surtout lorsqu'il y a peu de variantes et d'options. Chaque configuration encapsule ses choix d'interface utilisateur, ce qui facilite la compréhension locale. Ce modèle est idéal pour des écrans plus simples.

Le système de la seconde version offre une forte cohérence du rendu grâce à un directeur centralisé qui permet d'appliquer facilement différentes recettes (par exemple, "fiche d’une voiture sans spécifications", "fiche d’un vélo sans bouton") sans dupliquer la logique d'assemblage. Il est évolutif lorsque le nombre de variantes (voiture, vélo, camion, etc..) ou de sections (section moteur, section nombre de pédales, etc..) augmente.

Le système améliore la testabilité en séparant les tests du directeur, des constructeurs et de la configuration.

void main() {
  testWidgets('BikeLoadingBuilder shows progress and overrides button', (tester) async {
    // caractéristiques du vélo
    final config = BicycleCardConfiguration(
	...
    );

    // builder pour une fiche qui affiche un loader
    final builder = BikeLoadingBuilder(configuration: config);

    const director = VehicleWidgetDirector();
    director.buildBikeCard(builder);

    final widget = builder.build();

    await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget)));

    expect(find.byType(CircularProgressIndicator), findsOneWidget);
    expect(find.text('LOADING'), findsOneWidget);
    // on vérfie que malgré que nous sommes sur une fiche vélo
    // les comportements sont bien écrasés par notre builder
    expect(find.byIcon(Icons.directions_bike), findsNothing);
  });
}

Exemple de code pour tester un cas de fiche de véhicule

De plus, il simplifie l'implémentation des feature flags et les tests A/B en permettant de changer de directeur ou de recette selon un flag.

Bénéfices concrets des patterns

Pattern Description
Factory Simplifie l’instanciation des bonnes variantes (configurations ou builders) sans exposer les détails au reste du code. Rend l’appelant plus propre et réduit le couplage.
Strategy Permute le comportement par type de véhicule sans if/switch partout. Isole ce qui varie (données/présentation spécifique) de ce qui reste stable (structure de la carte). Simplifie l’ajout d’un nouveau véhicule : on ajoute une stratégie.
Template method Favorise la réutilisation du code en centralisant les parties communes de l’algorithme, réduit la duplication en évitant de réécrire les mêmes enchaînements de logique, et apporte de la flexibilité car chaque sous-classe peut redéfinir uniquement les étapes qui varient sans toucher au squelette général. Garantit aussi une meilleure lisibilité et maintenabilité en rendant explicite ce qui est fixe et ce qui peut changer dans un processus.
Director Centralise les recettes d’assemblage pour différents cas d’usage (ex : “car sans specs”). Sépare le “quoi/quand” (ordre, inclusion) du “comment” (Builder). Facilite l’expérimentation : on crée une nouvelle méthode du Director sans toucher aux Builders existants.
Builder Gère la construction d’un objet complexe par étapes, avec sections optionnelles (engine parfois absent). Réduit le risque d’incohérence visuelle : même squelette, étapes explicites. Permet la réutilisation d’étapes (withHeader, withSpecs…) et favorise la DRYness.

Quand choisir quelle approche : Configurateur (sans Builder) vs. Builder + Director

Le choix entre l'utilisation d'un Configurateur simple et l'association Builder + Director dépend principalement de la complexité et des exigences de votre interface utilisateur.

Choisir la première version si vous avez:

  • Peu de variantes, peu d’options conditionnelles.
  • Besoin d’aller vite avec une complexité UI limitée.
  • L’équipe préfère une API simple et un code plus direct.

Choisir la seconde version de notre système si vous avez:

  • Plusieurs variantes, options conditionnelles multiples, recettes différentes du même écran.
  • Exigence forte de consistance et d’évolutivité.
  • Besoin de tests unitaires fins par étape et d’expérimentations guidées.

Risques et garde-fous

L'over-engineering est un risque, mais les patrons de conception restent des outils précieux. Nous avons exploré ensemble quelques exemples pour illustrer leur existence et leur utilité.

Si vous avez seulement une ou deux variantes simples, il est préférable d'éviter une utilisation prématurée de Builder/Director.

Par contre, il y a des inconvénients au configurateur. À mesure que le nombre de variantes augmente, le Configurateur seul peut entraîner une duplication de l'assemblage et des incohérences visuelles.

Alors partons sur un compromis recommandé, il faudrait commencer par le Configurateur. Puis passer à Builder + Director lorsque les variantes ou les "recettes" deviennent trop nombreuses. Dans les deux cas, conservez la Strategy pour la configuration des données.


Votre prochain pas avec les Design Patterns en flutter

Cette approche, portée par les design patterns (Builder, Factory, Strategy, Template Method), transforme un widget « monstre » en architecture modulaire et élégante. On gagne en lisibilité, testabilité, et capacité d’évolution - exactement ce qu’on attend d’un code durable.

Investir dans la qualité du code n’est pas un luxe : c’est un accélérateur de livraison.

Comme promis, voilà le projet avec les différentes versions : https://github.com/thomasdhulst/flutter-design-pattern-configuration-builder

Essayez ces patterns sur vos propres widgets : partagez vos retours, posez vos questions, et comparez vos avant/après.

💡
Pour aller plus loin, visitez ce site en flutter qui liste tous les design pattern et une implémentation en Dart: Flutter Design Patterns
💡
Allez fouiller sur le site référence des patterns : Refactoring.guru