Les habitudes d'architecture et de qualité de code derrière chaque produit Tabaga Team, avec les chiffres de nos propres dépôts.
Un studio solo n'a pas de deuxième équipe pour rattraper une mauvaise fusion, pas de service QA, et personne d'autre pour expliquer un plantage à un pharmacien à huit heures du matin. Notre façon de construire est donc surtout un ensemble d'habitudes qui rendent les surprises rares. Ce billet les liste honnêtement, avec les chiffres de nos dépôts plutôt que des adjectifs, pour que vous puissiez juger vous-même si les habitudes tiennent.
- 12
- produits avec la même forme de dossiers
- 14
- types d'échec dans une seule hiérarchie scellée
- 468
tests dans une seule app, beaucoup de RTL et de contraste
- 65
constats dans un audit que nous avons écrit sur notre propre produit
Un seul squelette
Chaque app que nous livrons a le même squelette : un dossier core/ pour ce que toute l'app partage, et un dossier par fonctionnalité avec data, domain et presentation à l'intérieur. La présentation, ce sont les widgets et l'état ; le domaine, les entités, les cas d'usage et les types d'échec ; les données, les dépôts et les mappers. L'état est en BLoC ou Cubit dans la plupart des produits et en Riverpod dans deux ; l'injection de dépendances est get_it avec injectable ; la navigation est GoRouter avec des gardes par rôle.
Deux règles rendent le squelette utile. Les fonctionnalités importent le core, et le core n'importe jamais une fonctionnalité. La présentation dépend du domaine, le domaine des données, et jamais l'inverse. Un nouveau produit commence en copiant la forme, pas en en décidant une.
Le backend est un détail
La règle qui compte le plus est le sens des flèches. Rien au-dessus de la couche données n'importe un type Firebase, Supabase ou Appwrite. Quand un produit a besoin d'un backend, il a besoin d'une interface dans core/services/abstractions, et l'adaptateur concret est choisi une fois, dans le conteneur.
C'est grâce à cette règle que la marketplace de pièces auto a pu quitter Firebase pour Appwrite en touchant environ 78 fichiers Dart et 18 fonctions cloud au lieu de toute l'app, et qu'un produit pour le bâtiment tourne sur Appwrite Cloud, sur un VPS en Algérie, ou sur une machine dans le bureau du client avec le même code et une URL différente.
Des murs, pas des conventions
Les conventions se dégradent ; les contraintes, non. La plateforme de commande au restaurant est un monorepo avec trois paquets internes : core, UI et admin. L'app client dépend des deux premiers et pas du troisième, donc le code de gestion ne peut pas finir dans le binaire client, même par erreur. La frontière vit dans pubspec.yaml, pas dans une revue de code.
Nous appliquons le même instinct ailleurs. La plateforme de mode livre trois apps depuis un seul dépôt sous forme de flavours, partageant un seul backend, et a déplacé chaque écriture sensible derrière des fonctions serveur pour qu'un client ne puisse que demander, jamais décider. La marketplace d'équipements impose ses règles métier dans la base : durée de vie des annonces, modération avant publication, un propriétaire ne peut pas mettre en avant ses propres annonces, via des triggers et des contraintes de vérification. La marketplace de jobs étudiants garde les codes OTP dans une table que le client n'a pas du tout la permission de lire.
“Si une règle compte, faites-en une contrainte quelque part où l'app ne peut pas aller.
”
Des erreurs qu'on peut tenir en main
Les dépôts renvoient Either<Failure, T>. Un échec est un type scellé, quatorze sortes dans notre plus gros produit : réseau, authentification, validation, permission, introuvable, conflit et ainsi de suite, chacun portant de quoi s'expliquer en arabe, en français et en anglais. Le bloc transforme Right en état chargé et Left en état d'erreur, et les widgets ne voient jamais d'exception.
Deux conséquences que nous aimons. La concurrence optimiste devient un ConflictFailure que l'interface peut traiter avec un vrai message au lieu d'un plantage. Et les chemins de code pour « le serveur a dit non » sont écrits et testés dès le premier jour, parce que le système de types ne vous laisse pas les oublier.
La base de données est un document de conception
Les produits hors-ligne d'abord traitent la base locale comme le stockage principal : Drift sur SQLite, SQLCipher pour le chiffrement, les migrations à un seul endroit versionné. Un schéma est en version 51 après deux mois d'usage quotidien. Le gestionnaire de pharmacie épingle son emplacement de stockage avec un test, parce qu'un produit renommé a un jour fait pointer un nouveau build vers un dossier vide et que les registres avaient l'air supprimés. Le backend de licences a un test de dérive de schéma qui fait échouer le build quand la base en production et les migrations ne sont pas d'accord, depuis qu'un CREATE TABLE IF NOT EXISTS a silencieusement sauté une nouvelle colonne en production alors que chaque test unitaire passait contre un dépôt en mémoire.
Les tests comme mémoire
Nous ne courons pas après la couverture ; nous épinglons des décisions. La marketplace d'équipements a 468 tests sur une trentaine de fichiers, et une bonne part vérifient l'internationalisation, la mise en page de droite à gauche et le contraste des couleurs plutôt que la logique. La marketplace de pièces auto a fait tourner environ 398 tests unitaires plus des tests d'intégration contre un vrai backend avant que quiconque ne fasse confiance à une migration. L'app pour les crèches a un test qui échoue si une police est téléchargée à l'exécution au lieu d'être embarquée, parce que l'app est utilisée là où il n'y a pas de Wi-Fi. Le gestionnaire de pharmacie a un test pour les identifiants de marque et un pour l'adoption de l'ancienne base.
Chacun de ces tests existe parce que quelque chose a un jour mal tourné, ou a failli. C'est la seule métrique de couverture que nous suivons.
Du commit au comptoir
Les commits suivent le format conventionnel, en anglais ou en français selon qui lira le changelog. L'analyseur impose les règles RTL : EdgeInsetsDirectional, AlignmentDirectional, start et end, jamais left ni right. La CI tourne à chaque push et pull request. Les tags de release sont refusés s'ils ne sont pas sur main. Les installateurs Windows sont vérifiés par somme de contrôle et signés avec une clé Ed25519 dans la CI, et un installateur non signé n'est pas publiable. Les canaux de release se règlent par licence, pas par build, donc nous savons toujours quel build un client faisait tourner quand quelque chose a cassé.
Pour les apps des stores, les parties ennuyeuses sont automatisées : la déclaration de sécurité des données de Play est générée par un script, les étapes de la console qui n'ont pas d'API sont documentées pas à pas, et les notes de version sont écrites en trois langues dans le changelog lui-même.
Le design system vit dans l'app
Chaque produit porte ses propres jetons de design et une galerie de composants intégrée, accessible à /gallery dans les builds de debug. L'une d'elles liste environ quarante-cinq widgets. Les logos sont dessinés en géométrie vectorielle dans le code, et chaque icône de lanceur, écran de démarrage et visuel de store est généré depuis cette source unique, pour que l'icône du store et le logo dans l'app ne puissent jamais diverger.
Dire « pas prêt » à voix haute
Nous auditons notre propre travail et écrivons le résultat même quand il n'est pas flatteur. La plateforme de mode est passée par un programme de durcissement en 92 phases avec douze audits par rôle avant la remise. La marketplace de froid a un AUDIT.md avec 65 constats, dont trois critiques, et le verdict « pas prêt pour la production » dès le premier paragraphe. Les feuilles de route utilisent des tableaux d'état à trois couleurs, et « pas construit » est une couleur.
Rien ici n'est malin. C'est le même petit ensemble d'habitudes appliqué à chaque produit, pour que chacun soit plus facile que le précédent.