← Projets

EduQate

2026 — en cours

Mon 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
NestJSPrismaPostgreSQLpgBouncerRedisSocket.ioReact 19ViteReact Native / ExpoRustshadcn/uiDocker ComposePM2CaddyCloudflare Tunnel

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

Apps client3 SPA web · 1 React NativePaquets partagéstypes · client api · hooks de requête · 2 kits UIModules de domaine19 modules · ~21k lignesPréoccupations centralesauth · politique · paramètres · outbox · stockageInfrastructurePostgres · Redis · stockage objet · service d'images Rust2 violationsconnuesdispatcher outbox → notifications · auth → enregistrement utilisateur
Chaque flèche pointe vers le bas. La valeur n'est pas la propreté — c'est que le rayon de souffle d'un changement se lit sur le diagramme, ce qui rend un registre de vérification possible. Les deux flèches en pointillés sont de vraies violations trouvées en comptant les imports plutôt qu'en faisant confiance à l'image.

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

+ 44 autres dans Problèmes →

Notes liées