EduQate
2026 — en coursMon travail quotidien chez JoyPix, Doha · en ligne sur eduqate.qa
C'est mon travail quotidien. La plateforme d'applications de JoyPix, construite de bout en bout à partir d'un court pitch visuel. Des organisations reçoivent et examinent des candidatures du public : un côté candidat, un côté opérateur, et un niveau d'administration plateforme au-dessus des deux. Trois apps web et une app mobile, dix-neuf modules de domaine sur seize préoccupations centrales, cinq couches où chaque dépendance pointe dans le même sens. En ligne sur eduqate.qa.
Rôle — Seul développeur chez JoyPix — architecture, back-end, toutes les apps client, infrastructure, sécurité
- Modules de domaine
- 19
- Apps client
- 4
- Couches d'architecture
- 5
- Services dans la stack
- 10
- Tests automatisés
- 7 000+
- Comptes rendus de problèmes
- 12
La contrainte qui l'a façonné
Pas de second développeur. Chaque décision d'architecture était la mienne, sans personne pour la confronter. C'est pour ça que le monitoring d'erreurs, les contrôles de dérive de schéma et les tests de non-régression ciblés sont devenus porteurs au lieu d'être du bonus.
L'architecture
Comment c'est agencé
Cinq couches, une seule direction de dépendances : clients, paquets partagés, modules de domaine, préoccupations centrales, infrastructure. Chaque flèche pointe vers le bas.
Quatre apps client partagent un petit ensemble de paquets — types, un client d'API, des hooks de requête, et deux kits d'UI qui n'importent rien d'autre en interne. En dessous, dix-neuf modules de domaine (~21k lignes) sur seize préoccupations centrales (~11k lignes) : auth, résolution de politiques, paramètres, un outbox transactionnel, stockage. Puis Postgres, Redis, le stockage objet et un service d'images en Rust.
Le but n'est pas la propreté. C'est que je peux lire la portée d'un changement directement sur le diagramme : touche un paquet partagé et chaque flux en dessous compte comme non vérifié tant que je ne l'ai pas reparcouru.
Deux exceptions, trouvées en comptant les imports plutôt qu'en faisant confiance au diagramme. Le dispatcher d'outbox remonte vers les modules de notification pour router les événements, et l'auth remonte pour l'enregistrement utilisateur. Les deux se corrigent avec une interface possédée par la couche inférieure, et aucune ne m'a rien coûté jusqu'ici.
Les décisions qui méritent qu'on les questionne
Un outbox transactionnel au lieu d'appeler les effets de bord en ligne. Soumettre une candidature doit à l'utilisateur un e-mail de confirmation et une notification. Appeler ces services dans la même requête qui écrit en base laisse un vrai trou : si l'API d'e-mail a un mauvais moment juste après le commit, la confirmation est perdue et rien ne le signale. À la place j'écris l'événement dans la même transaction que le changement, et un processus séparé le livre ensuite, avec des réessais en cas d'échec.
Une hiérarchie de paramètres qui est en fait un garde-fou. Chaque comportement configurable a un défaut plateforme, qu'une organisation peut outrepasser, et que chaque succursale peut outrepasser à nouveau. En surface ça se lit comme de la flexibilité. La vraie raison, c'est qu'un admin de succursale peut faire un choix réellement nuisible, et que quelqu'un au-dessus doit pouvoir le corriger.
Invalidation de session pour des jetons sans état par conception. Un compteur de version sur chaque utilisateur, intégré à chaque jeton à l'émission et vérifié à chaque requête. Forcer une déconnexion complète, c'est un seul incrément : tous les jetons de cet utilisateur cessent de fonctionner, et il n'y a aucune liste noire à maintenir.
Un form builder, et une discussion sur qui tient le stylo. Chaque organisation a besoin de son propre formulaire de candidature, et la question ouverte était qui le construit. L'instinct était de laisser les admins configurer le leur. J'ai poussé dans l'autre sens : le personnel n'est pas forcément technique, et un formulaire en production est exactement l'endroit où une petite erreur devient un vrai problème en plein cycle, sans équipe de support de garde. L'écriture des formulaires reste côté plateforme avec une étape d'aperçu et de confirmation, et les formulaires sont versionnés pour que chaque soumission soit verrouillée à la version contre laquelle elle a été remplie.
La dérive de schéma attrapée en CI. Un contrôle qui compare la définition du schéma aux migrations appliquées à chaque exécution et échoue dès qu'elles divergent, au lieu de laisser ce trou ressortir plus tard sous forme de bug déroutant.
Le faire tourner, pas seulement le construire
La moitié de ce métier qui n'est pas dans la base de code. Je suis responsable de l'environnement dans lequel ça tourne autant que du code.
Une seule commande monte toute la stack en local — base de données, pooler de connexions, Redis, le service d'images en Rust, l'API, trois apps web, un reverse proxy — chaque dépendance conditionnée à un vrai health check, pour que l'API ne puisse pas démarrer contre une base qui n'est pas encore prête. Les conteneurs de dev sont préfixés à l'écart de la production, après une défaillance CI que j'ai remontée jusqu'au runner partageant un démon Docker avec la stack en production : un compose up au même nom entrait en collision avec des conteneurs de production en fonctionnement, pas avec les restes d'un run annulé.
La production tourne sur trois réseaux Docker isolés — web public, périphérie, données — pour que la base ne soit atteignable que depuis le seul service qui fait le pont vers elle. Rien n'est publié sur l'hôte. Le seul chemin entrant est un tunnel qui compose vers l'extérieur, donc aucun port entrant à attaquer, et il tourne en deux répliques pour le failover.
Le proxy de périphérie refuse par défaut tout hôte qu'il ne reconnaît pas, plafonne la taille du corps de requête et les timeouts de client lent avant qu'une requête n'atteigne l'application, et sert une page de maintenance aux couleurs du produit pendant les quelques secondes où le back-end est absent lors d'un échange de conteneurs.
Le back-end tourne comme un cluster de processus, pas un seul — le runtime épingle un processus par cœur CPU. Tout ce que le clustering casserait sinon — compteurs de limitation de débit, rooms de socket, codes d'échange OAuth, revendications de jobs de fond — a d'abord été déplacé vers Redis, et le démarrage refuse de partir en production sans lui, pour qu'il ne puisse pas se dégrader discrètement en comportement par processus.
CI/CD, et ce que « vert » doit vraiment vouloir dire
Chaque workflow tourne sur des runners auto-hébergés et déclare un bloc de permissions au moindre privilège. Le déploiement n'est pas un job de CI : un script sur le serveur y construit les images, applique les migrations, attend le health check du back-end, analyse les logs de chaque service, et m'envoie le résultat par e-mail dans tous les cas.
Le pipeline qui conditionne un build : lint, typecheck, un contrôle de dérive de migration qui échoue dès que le fichier de schéma et les migrations appliquées divergent, un job smoke qui fait tourner toute la stack *à travers* le pooler de connexions pour attraper la classe de bug qui a un jour corrompu des données, l'audit des dépendances, CodeQL, et un scan Trivy des paquets vulnérables et des secrets qui fuient.
J'ai cessé de faire confiance au « vert » deux fois ici. Une chaîne de workflows multi-fichiers ne s'est jamais déclenchée, parce que ce genre de trigger n'est évalué que depuis la branche par défaut. Aucune erreur nulle part ; le run n'était simplement jamais tenté. J'ai fusionné la chaîne dans un seul fichier, où les dépendances job-à-job n'ont pas ce piège. Séparément, toute une exécution CI est un jour passée au vert alors que rien n'avait été construit.
Des sauvegardes que j'ai vraiment restaurées, et un runbook de déploiement
La base est dumpée chaque nuit, expédiée hors site et élaguée sur une fenêtre de rétention — et un job séparé restaure chaque semaine la dernière sauvegarde dans un conteneur jetable et vérifie que les tables reviennent non vides. Un script de sauvegarde qui sort avec zéro n'est pas la même affirmation qu'une sauvegarde restaurable.
Deux chemins de déploiement, tous les deux écrits. Un de routine, où les migrations purement additives passent sans risque avec l'ancien code qui tourne pendant les quelques secondes d'un échange. Et un chemin cassant qui lève d'abord un drapeau de maintenance — gardé pour qu'il ne puisse pas être basculé à la légère — lance la migration derrière lui, et ne le retire qu'une fois qu'un scan des logs de chaque service revient propre.
Les cicatrices sont dans le runbook aussi. Ne jamais lancer d'élagage de volumes sur une machine où le volume de la base pourrait être momentanément détaché. Donner à la production et au monitoring leurs propres noms de projet compose, pour qu'un teardown sur le mauvais fichier ne puisse pas atteindre le mauvais volume. Les nouveaux index sur des tables qui contiendront de vraies données vont dans leur propre migration, construits en mode concurrent, parce qu'un build d'index simple verrouille la table pendant toute sa durée.
La surface de sécurité, réglée une fois et imposée partout
Une seule chaîne de guards globale — throttle, authentifier, vérifier le rôle, vérifier le scope — dans un ordre fixe, refus par défaut à chaque couche. Une route qui ne déclare aucun rôle est fermée. Une route qui déclare un scope doit quand même re-dériver et re-vérifier la propriété dans le service, parce qu'un en-tête de requête peut l'emporter sur le paramètre d'URL et que le guard seul ne suffit pas.
Mots de passe : un hash lent, un contrôle contre une liste de fuites sur chaque chemin qui peut en définir un, un verrouillage par compte plutôt que par IP, et un hash factice sur le chemin « utilisateur inexistant » pour que le timing de réponse ne permette pas d'énumérer les comptes.
Jetons : des JWT sans état avec un compteur de version par utilisateur pour une révocation instantanée, une rotation de rafraîchissement avec détection de réutilisation qui tue toute la session sur un jeton rejoué, le rafraîchissement dans un cookie httpOnly et le jeton d'accès en mémoire seulement — jamais dans le stockage local.
La limitation de débit se clé sur l'utilisateur authentifié, pas sur l'IP. Clé sur l'IP, tous ceux derrière un réseau de bureau partagent un seul budget, ce qui ressemble exactement à une attaque. Ça a demandé que l'étape d'authentification tourne avant le limiteur : un ordre délibéré, pas un accident.
Comment ça a été construit
Avril 2026 — aujourd'hui
JoyPix — seul développeur sur la plateforme
La plateforme d'applications multi-tenant de JoyPix, construite de bout en bout seul : trois apps web, une app mobile, infrastructure, sécurité. En ligne sur eduqate.qa.
Les problèmes qui sont sortis de ce projet54
- Un microservice qui tournait alors que rien ne l'utilisait2026-08-31Le service génère des variantes 200px et 800px à chaque upload et les stocke dans documents.thumbnails_json. Chaque résolveur côté organisation — resolveLogoUrl, resolveCoverUrl, aperçu de galerie, galerie complète, photos du personnel — signait document
- Une collision d'id de dégradé SVG2026-08-31Chaque carte rendait son voile comme un dégradé react-native-svg inline utilisant le même id d'élément. Dans une liste, ceux-là entrent en collision entre éléments et se résolvent vers la mauvaise définition, donc les cartes rendaient une boîte grise opaque plate au lieu d'un voile. Basculé
- Le mobile qui jetait le type de document à l'upload2026-08-27En construisant le Centre de documents, le plan était un filtre par type de document. La taxonomie existait déjà de bout en bout — program_document_requirements semés dans document_requests à la création de candidature, avec un endpoint d'attachement qui marche déjà ouvert
- Une CI qui manquait sans cesse de mémoire2026-08-23Le runner auto-hébergé a une RAM finie et faisait tourner rust, CodeQL et Trivy en même temps. Il a fallu deux passes : sérialiser les jobs lourds, plafonner la mémoire de CodeQL et Trivy, un seul thread pour CodeQL, exclure node_modules et les dossiers de build — puis ajouter 8 Go de swap
- Un mode maintenance devant lequel les navigateurs intégrés passaient2026-08-23Avec le mode maintenance activé, un navigateur normal affichait l'écran de maintenance et un navigateur intégré (Messenger, Instagram) affichait l'application normale sans données.
- Trois applications sans mise en page mobile, une sans navigation2026-08-20La plainte était que l'application était « très bordélique » sur un téléphone. Mesuré à 390px :
- Deux éléments sticky sur le même pixel2026-08-20À largeur de téléphone, la navigation de saut de section de la page de détail d'organisation (sticky top-0, z-30) se fixait au même pixel que l'en-tête de l'application (sticky top-0, z-20) et peignait par-dessus, cachant le logo, le sélecteur de langue et la moitié du bouton Créer un compte.
- Une 2FA qui renvoyait les gens à la connexion2026-08-19Juste après avoir déplacé l'authentification super-admin vers des variables d'environnement avec une 2FA e-mail adossée à Redis, terminer l'étape OTP te ramenait à la connexion. Le cookie CSRF était limité à l'hôte et la seconde étape de l'échange est servie depuis un autre sous-domaine, donc le jeton
- Un IDOR sur les paramètres de tenant2026-08-19Un IDOR simple — la vérification de propriété que toute autre route cadrée sur l'organisation applique n'était pas appliquée sur les paramètres, donc passer l'id d'une autre organisation suffisait. La moitié la plus utile du correctif a été de centraliser les vérifications de propriété plutôt que
- Une étiquette arabe coupée par une largeur pensée pour l'anglais2026-08-18L'indicateur de progression de l'inscription mobile avait stepLabel: { maxWidth: 64 } avec numberOfLines={1}. « Personal info » rentre tout juste. « المعلومات الشخصية » non, et la troncature de React Native Web à cette largeur n'ajoute pas de points de suspension, elle coupe net — donc
Notes liées
- L'outbox transactionnel
- Multi-tenant au niveau ligne, et le seul bug qui compte
- pgBouncer, et le bug search_path qui a corrompu des données
- Révoquer un jeton qui est sans état par conception
- Deux chemins de déploiement, et savoir sur lequel tu es
- Intégrer la PDPPL du Qatar au produit, pas autour
- Quand le runner CI est aussi la machine de production
- Trois réseaux de conteneurs, pour que la base n'ait qu'un voisin
- Un kit de tests qui ne dépend pas de la stack
- Un service Rust pour l'unique job lié au CPU
- Un tunnel qui compose vers l'extérieur, pour n'avoir aucun port entrant
- Une sauvegarde que tu n'as pas restaurée est une hypothèse