EduQate, première partie — les trois premiers mois
En avril 2026, j'ai commencé à construire la plateforme d'applications multi-tenant de JoyPix, seul, à partir d'un court pitch visuel. Je possède tout : l'architecture, trois applications web, une application mobile, l'infrastructure, la conception de la sécurité, et — c'est là que vit vraiment l'histoire — chaque session de débogage entre les deux.
Le produit s'appelle EduQate, une plateforme d'admissions scolaires, en ligne sur eduqate.qa. Mais l'ingénierie n'a jamais été une affaire de domaine. Un limiteur de débit qui compte la mauvaise chose n'est pas un problème propre à un secteur. Tu verras.
J'ai tenu un journal de dev pour ce projet — des décisions, une chronologie, et une liste courante de chaque bug qui mérite d'être retenu — et ce chapitre et le suivant, c'est en gros moi qui le raconte à nouveau avec une voix qui n'est pas « note à moi-même à 1h du matin ».
Jour quatre : une décision née d'une conversation
La première vraie bifurcation d'architecture a été le multi-tenant, et je veux être honnête sur la façon dont ça s'est décidé, parce que ce n'est pas comme ça que ces histoires se déroulent d'habitude.
Personne ne m'a remis un cahier des charges disant « multi-tenant ». Ce qui s'est passé, c'est que pendant la planification, la personne qui pilotait le projet a mentionné — en passant, dans une phrase, écrit nulle part — que la plateforme resterait sur notre propre serveur et qu'il voulait à terme y faire venir beaucoup d'organisations. C'est tout. C'est toute l'entrée.
J'en ai fait un engagement d'architecture : chaque ligne cadrée par un identifiant de tenant, depuis le schéma. Puis j'ai fait la chose apprise à SiccaDev — je suis revenu vers lui et j'ai dit « voici ce que je pense que tu voulais dire, voici ce que ça coûte de le construire ainsi, confirme que j'ai bien compris » avant de rien construire par-dessus.
C'était le bon geste. Tout le reste dans ce projet a changé de forme au moins une fois. À un moment, les formulaires ont été re-spécifiés deux fois en quatre jours. Le modèle de tenant n'a jamais bougé. La différence, c'est que j'ai vérifié l'interprétation pendant qu'il était encore peu coûteux de se tromper.
Jour un, en fait : .env commité
Réglons ça tout de suite. .env est entré avec les premiers commits et en est ressorti le jour même. .gitignore avant tout le reste sur un nouveau projet. Je sais. Tout le monde sait. Tout le monde le fait une fois quand même.
Trois semaines plus tard : la session qui ne voulait pas se terminer
Le schéma initial était en place depuis environ trois semaines quand j'ai remarqué — en utilisant l'application, pas en la relisant — qu'un compte qui aurait dû être verrouillé se baladait toujours sur une session qui, selon toutes les règles, n'aurait pas dû être valide.
Je l'ai trouvé en mettant deux choses côte à côte : ce que le front-end croyait de l'état d'authentification, et ce que les logs du back-end montraient réellement. Ils étaient en désaccord. L'application pensait que le compte était déconnecté. Le back-end acceptait joyeusement encore son jeton.
Évident après coup, comme toujours. Les JWT sont sans état. Une fois que tu en signes un, tu ne peux pas le reprendre. Le jeton n'a aucune idée que le compte a été verrouillé cinq minutes après son émission, et rien dans mon code ne vérifiait.
Le correctif est un compteur token_version sur chaque utilisateur, intégré à chaque jeton et comparé à chaque requête. Incrémente le compteur, et chaque jeton en circulation pour cet utilisateur meurt d'un coup. Pas de liste noire à maintenir, pas de table grandissante de jetons révoqués.
La leçon que j'en ai vraiment tirée : décide comment une session meurt avant d'écrire l'endpoint de connexion. Il est tentant de traiter « révoquer tôt » comme un cas limite pour plus tard. Le temps que tu remarques qu'il manque, c'est parce que quelque chose est déjà passé par la brèche.
Le pooler de connexions qui mentait discrètement
Celui-ci me dérange encore.
J'ai ajouté pgBouncer parce que des connexions Postgres par requête n'allaient pas survivre à une vraie charge. Raisonnable. Ce que je n'avais pas complètement intégré, c'est que le mode transaction-pooling de pgBouncer réutilise la même connexion sous-jacente à la base entre différentes sessions client. Et certains réglages au niveau session — search_path, précisément — ne se réinitialisent pas quand la connexion est passée au client suivant.
Donc : la session A définit un search path. La session B hérite de cette connexion, ne la réinitialise pas, et maintenant les requêtes de la session B lisent et écrivent sur le mauvais schéma. Aucune erreur. Aucun crash. Juste… de mauvaises données, discrètement, au mauvais endroit.
Ça a mis des données de seed dans un état vraiment faux avant que je comprenne ce qui se passait. Je l'ai remonté en apprenant réellement comment le pooler réutilise les connexions en interne, plutôt que de le traiter comme une couche de performance transparente que je pouvais ignorer.
Le correctif a été d'arrêter de me reposer sur quoi que ce soit au niveau session pour le cadrage. Puis, parce que je ne veux plus jamais redécouvrir une corruption de données silencieuse par accident, j'ai construit un job CI qui fait tourner toute la stack via Docker et martèle spécifiquement les requêtes à travers pgBouncer, pour que ce mode de défaillance exact soit attrapé avant d'être livré.
L'infrastructure qui se trouve, invisible, dans le chemin de la requête — poolers, proxys, caches — mérite d'être testée directement à travers, pas qu'on lui fasse confiance pour être transparente. Les bugs qui se cachent dans cette couche ne s'annoncent pas. Ils renvoient simplement la mauvaise chose.
Docker prolifère, parce que chaque décision individuelle était correcte
À un moment, j'ai levé les yeux et il y avait neuf Dockerfiles.
Aucune mauvaise décision isolée. Le back-end a besoin de son propre Dockerfile — d'accord. Le dev a besoin d'arguments de build différents de la prod, alors on ajoute une variante .dev — d'accord. Répète maintenant ça pour web, admin, super-admin, mobile et back-end, et tu as neuf fichiers, et le moindre changement de build partagé implique d'en éditer la moitié en espérant les avoir tous eus.
Il y en a deux maintenant — un back-end, un front-end — avec des arguments de build qui sélectionnent l'application. Les trois applications front-end partagent presque toute leur chaîne et n'ont jamais eu besoin de fichiers séparés. Tout ça découle de la même erreur racine : je n'ai jamais conçu la forme de l'infrastructure d'entrée. Je l'ai fait pousser un besoin concret à la fois — Docker, puis Redis, puis pgBouncer, puis Caddy, puis Cloudflare, puis à terme un service d'images en Rust — et chaque ajout était localement sensé et le total était un bazar. Si je refaisais ça, je verrouillerais la forme de l'infra tôt, même sous la même pression de temps. C'est la chose que je changerais le plus.
Deux machines, une branche, 46 fichiers de tissu cicatriciel de conflits de merge
Autour de la migration vers shadcn/ui — qui a duré deux jours et portait le mode sombre, un rafraîchissement du design et cette consolidation Docker d'un coup — j'ai payé une mauvaise habitude. Je travaillais la même branche depuis deux machines, une Windows et une Linux, sans pulls disciplinés. La facture est arrivée sous la forme d'un commit qui ne fait rien d'autre que corriger des marqueurs de conflit restants dans 46 fichiers.
Ce n'est pas un bug, c'est une défaillance de processus, et je l'ai consigné comme une habitude à corriger plutôt qu'une chose à nettoyer. (Le clivage Windows/Linux a continué à me mordre de façons plus petites pendant des mois — des points d'entrée shell avec des fins de ligne CRLF que sh refuse d'exécuter, des scripts npm mobiles écrits en syntaxe Windows set VAR=&& qui ne tournent pas sous Linux. Deux machines, un dépôt, une infinité de petites taxes.)
Le nettoyage du design, ou : j'ai traité mon propre produit de bouillie d'IA
À la mi-juillet, j'ai fait un audit honnête de l'apparence réelle du produit, sur les quatre applications, et le diagnostic était inconfortable : ça ressemblait à tous les templates de l'ère IA. Photos stock de familles souriantes. Sections « fonctionnalités » en grille d'icônes. Slogans SaaS interchangeables. Et — le vrai point bas — un défilement de faux noms d'organisations codés en dur qui traversait l'écran de bienvenue mobile comme si c'étaient de vraies données.
Deux choses sont sorties de cet audit que j'utilise encore.
D'abord, une inversion. J'y suis entré en pensant arracher le système de design. Au lieu de ça, l'audit a trouvé que la chose la plus solide de toute la base de code était la partie que j'avais à moitié oublié d'avoir construite : la couche de mouvement. De la géométrie réactive au gyroscope sur mobile qui s'incline quand tu bouges le téléphone. De la chorégraphie au scroll sur le web. Une animation de célébration à particules. Le tout piloté par des tokens, le tout conscient du reduced-motion. Le générique n'était pas dans le système de design — il était dans tout ce qui l'enveloppait. Donc la première décision a été : la couche de mouvement et de tokens est intouchable, et le boulot du nettoyage est de retirer le déchet autour et de pousser le bon système dans les écrans qui l'utilisaient à peine.
Ensuite, et plus transférable : j'ai écrit les objectifs anti-vide comme des règles vérifiables. Je travaille en solo. Il n'y a pas de relecteur design. « Rends ça moins vide » comme une ambiance dérive tout droit vers son point de départ en une semaine. Alors à la place : chaque carte a besoin d'une surface teintée, d'une bordure, d'une structure de contenu en trois parties, et d'un élément accessoire. Chaque ligne de liste a besoin d'un visuel de tête, de deux faits secondaires, et d'une pastille de statut en fin. Chaque écran s'ouvre sur un élément d'ancrage avant toute barre de filtres. Le contenu fabriqué est purement interdit, supprimé à vue. Quand il n'y a pas de relecteur, la checklist est le relecteur.
L'audit a aussi révélé quelque chose de vraiment drôle d'une manière sinistre : presque tout ce dont la refonte avait besoin était déjà installé et enterré. Toute une bibliothèque de cartes interactives, posée dans l'application web derrière un interrupteur que personne ne trouverait jamais. Des paquets de carte native et de géolocalisation sur mobile, installés, inutilisés. Cette animation de célébration finie — construite, peaufinée, jamais montée sur un seul écran. L'écart entre ce que la base de code pouvait faire et ce que les écrans montraient s'est révélé être l'essentiel de la refonte. Avant de concevoir quoi que ce soit de nouveau, va faire l'inventaire de ce que tu as déjà construit et oublié.
14 juillet : j'ai arrêté de faire confiance à ma propre relecture de code
C'est le tournant de tout le projet, alors il a droit à sa propre section même si « j'ai changé mon processus » n'est pas une phrase palpitante.
Mon habitude avait été : lire le code, confirmer qu'il compile, confirmer qu'il a l'air correct, cocher le flux comme fait. Je l'ai remplacée par une règle et un registre. Rien ne compte comme fait tant que ça n'a pas été parcouru de bout en bout, en tant que vrai utilisateur, dans l'application réellement en fonctionnement, avec la chose précise que j'ai cliquée et la chose précise que j'ai vue, écrites en regard d'une date. Toucher du code partagé rétrograde automatiquement chaque flux qui en dépend à « non vérifié », parce qu'un correctif par ici a le don de casser quelque chose d'adjacent par là.
La première fois que j'ai passé ce contrôle sur une tranche de l'application dont j'aurais parié de l'argent qu'elle était solide, ça a révélé plus de onze vrais bugs. Chacun était invisible à une relecture attentive du code. En voici trois :
- Un bug de sérialisation de réponse qui avait corrompu chaque nombre décimal précis dans chaque réponse d'API — chaque frais, chaque paiement, chaque échéance — en la représentation interne d'une bibliothèque. Chaque endpoint renvoyait quand même du HTTP 200. On ne pouvait le voir qu'en lisant un vrai corps de réponse. (Toute l'histoire au chapitre suivant — elle est bonne.)
- Une course sur le lien de vérification qui affichait une erreur rouge effrayante aux utilisateurs après avoir vérifié leur compte avec succès, parce que l'endpoint de confirmation partait deux fois par chargement de page et que le handler n'était pas sûr contre ça.
- Une adresse IP périmée dans
.envqui garantissait l'échec de la connexion Google au moment où je l'essaierais — pour des raisons qui n'avaient rien à voir avec le code OAuth, et tout à voir avec le refus de Google d'accepter une IP de LAN comme URI de redirection.
Aucun de ceux-là ne fait échouer un typecheck. Aucun de ceux-là ne fait échouer un test « est-ce que la requête réussit ». Tous apparaissent à l'instant où tu fais réellement tourner la chose en tant que personne censée l'utiliser.
Je me suis fait reprendre, en pleine session, pour un péché voisin : je faisais confiance à mes propres tests de non-régression, et mes tests de non-régression ne couvraient que les flux que j'avais déjà trouvés et corrigés une fois. Ce n'est pas du test. C'est un musée des bugs passés. La remarque était juste et je ne l'ai jamais oubliée.
C'est comme ça que je veux travailler par défaut maintenant, pas un exercice ponctuel. Le chapitre suivant est en gros un florilège de ce que cette discipline a attrapé.