Aller au contenu
Retour au blog

GraphQL en pratique : deux produits, un schéma

7 min de lecture
GraphQLArchitectureBackendScaling

Quand un produit grandit et se scinde en deux, la question fatale arrive : deux backends ou un seul ? Sur Crafteo — une plateforme de freelances experts — la marketplace (Discover) et l'outil de gestion (Manage) avaient des besoins fondamentalement différents. Un schéma GraphQL partagé a été la réponse. Mais le faire fonctionner a demandé plus de discipline que prévu.

Produit fictif. Patterns d'ingénierie réels.

Deux produits, deux patterns d'accès

Discover est un produit orienté lecture. Les entreprises parcourent des sélections hebdomadaires d'experts freelances pré-qualifiés, filtrent par compétences, consultent des profils, comparent des candidats. Les requêtes sont larges, fréquentes, et tolérantes à une légère latence. Une entreprise cherchant "ingénieurs data seniors disponibles le mois prochain" déclenche une requête qui scanne des milliers de profils d'experts, applique des filtres, et retourne une liste classée. Avec plus de 300 clients actifs quotidiens et plus de 20 000 profils d'experts en base, ces patterns de lecture généraient une charge conséquente.

Manage est l'opposé. Les équipes suivent le cycle de vie des missions — qualification, matching, signature de contrat, exécution, facturation. Les mutations sont fréquentes, le statut change en permanence, et la cohérence est critique. Quand un manager ops marque une mission comme "contrat signé", la disponibilité de l'expert doit se mettre à jour immédiatement, le client doit être notifié, et le workflow de facturation doit être déclenché. Une lecture périmée ici n'est pas un inconvénient mineur — c'est une erreur opérationnelle.

Les deux produits interrogeaient les mêmes collections MongoDB. La même collection experts servait les requêtes de browsing de Discover et les mutations de profils de Manage. La même collection missions gérait les résultats de matching de Discover et le suivi de cycle de vie de Manage. Et c'est là que la tension a commencé.

GraphQL comme couche d'arbitrage

Loading diagram…

La décision de partager un seul Apollo Server était pragmatique : les entités métier — experts, entreprises, missions — étaient authentiquement partagées. Les dupliquer à travers deux backends aurait signifié synchroniser l'état entre deux bases de données, ce qui est un problème plus difficile que de partager un schéma.

Le schéma était conçu autour de ces entités partagées avec des extensions spécifiques par produit. La requête expertProfile servait les deux produits, mais les champs résolus différaient selon le contexte. Discover avait besoin de la disponibilité, des compétences et d'un résumé. Manage avait besoin de l'historique contractuel, du statut des missions et des détails de facturation. La capacité de GraphQL à laisser le client sélectionner exactement les champs nécessaires rendait cela naturel — chaque produit requêtait le même type mais ne récupérait que sa tranche pertinente.

Là où les deux produits s'affrontaient — et comment le schéma arbitrait

Le défi central était que chaque optimisation pour un produit risquait de dégrader l'autre.

Côté Discover, la menace était le volume. Une seule page de recherche déclenchait des requêtes à travers des milliers de profils d'experts avec compétences imbriquées, fenêtres de disponibilité et données de localisation. Sans garde-fous, ces lectures larges saturaient les connexions MongoDB et privaient les mutations de Manage de capacité d'écriture. L'analyse de complexité au niveau GraphQL était la première défense : chaque requête était scorée selon les champs demandés et la profondeur des resolvers imbriqués. Les requêtes dépassant un seuil de complexité étaient rejetées avant d'atteindre MongoDB. Cela attrapait les cas limites — un client Discover demandant accidentellement l'historique imbriqué des missions pour chaque expert d'une liste — qui auraient généré des milliers d'appels base.

Côté Manage, la menace était la cohérence. Un changement de statut de mission devait se propager immédiatement — la disponibilité de l'expert, la notification client, le déclenchement de la facturation. Manage ne pouvait pas tolérer de cohérence éventuelle, mais la charge de lecture de Discover sur les mêmes collections créait de la contention de verrous. Le pattern DataLoader aidait des deux côtés : il batchait et dédupliquait les appels base au sein d'une même requête. Quand une requête de liste résolvait 50 profils d'experts, DataLoader consolidait 50 lectures MongoDB individuelles en une seule requête batch, libérant de la capacité de connexion pour les écritures de Manage.

Les index MongoDB étaient le champ de bataille silencieux. Des index composites sur compétences, disponibilité et localisation servaient les recherches filtrées de Discover. Des index sur le statut des missions et les timestamps de mise à jour servaient les écritures fréquentes de Manage. Mais ajouter un index accélérant les lectures de Discover pouvait ralentir le débit d'écriture de Manage — chaque écriture doit mettre à jour chaque index correspondant. La stratégie d'indexation n'a pas été définie une fois pour toutes — elle a évolué au fur et à mesure du monitoring des patterns réels en production, toujours en équilibrant le gain d'un produit contre le coût pour l'autre.

L'évolution du schéma : là où la tension devenait permanente

Les protections runtime ci-dessus géraient le trafic quotidien. Mais le conflit plus profond était structurel : quand deux produits consomment le même schéma, un changement qui sert l'un peut silencieusement casser l'autre.

Discover voulait des profils d'experts plus riches — plus de champs filtrables, des portfolios imbriqués, des prévisions de disponibilité. Chaque nouveau champ signifiait plus de données à traverser pour les requêtes larges de Discover. Manage voulait des mutations plus légères — écritures rapides, overhead de validation minimal, temps de réponse serrés. Un champ qui enrichissait l'expérience de browsing de Discover ajoutait de l'overhead d'écriture au cycle de vie des missions de Manage.

On a établi des règles strictes d'évolution du schéma. Ajouter des champs était toujours safe. Renommer ou supprimer des champs exigeait un cycle de deprecation — marquer l'ancien champ comme deprecated, s'assurer que les deux consommateurs avaient migré, puis supprimer dans une release ultérieure. Les changements de type sur les champs partagés étaient interdits sans un plan de migration couvrant les deux produits.

Les tests d'intégration croisés étaient le filet de sécurité. Chaque changement de schéma déclenchait une suite de tests exécutant des requêtes de Discover et Manage contre le schéma mis à jour. Ces tests attrapaient des régressions que les tests unitaires manquaient — un champ que Manage n'utilisait plus mais dont Discover dépendait encore, ou un nouveau champ nullable qui cassait l'hypothèse non-null de Discover.

Ce que j'en retiens

GraphQL n'est pas magique — c'est un contrat. Sa valeur réelle émerge quand ce contrat est maintenu avec discipline : analyse de complexité pour protéger les performances, DataLoader pour prévenir le N+1, règles d'évolution du schéma pour empêcher les régressions cross-produits, tests d'intégration pour vérifier que tout tient ensemble.

Sans cette discipline, le schéma partagé devient un point de couplage, pas un point d'unification. L'architecture à deux produits a fonctionné parce qu'on a traité le schéma comme une infrastructure partagée avec la même rigueur qu'une migration de base de données — pas comme une commodité pour éviter de dupliquer du code.

La plateforme servait plus de 300 clients actifs quotidiens à travers les deux produits depuis un seul API. Mais la métrique que je surveillais le plus n'était pas le débit — c'était le nombre de changements de schéma livrés sans casser aucun des deux consommateurs. Ce nombre devait rester à 100%, et il l'est resté.