# GraphQL managé et cloud functions : retour sur notre mission chez Cajoo > Comment un stack GraphQL managé avec Hasura, des cloud functions AWS Lambda et un client généré nous permettent d'accompagner la croissance de Cajoo, la startup française du quick commerce. Date : 18/11/2021 Auteurs : Aurélien N., Raphael P. Tags : GraphQL, React Native, Serverless, Telemetry, Startup --- Cajoo, c'est une startup de quick commerce lancée en janvier 2021 à Paris. Le concept : livrer des courses du quotidien en moins de 15 minutes, depuis des dark stores répartis dans les quartiers. Des livreurs salariés, des vélos électriques, environ 2 000 références produit, un service de 7h à minuit. La boîte vient de lever 40 millions avec Carrefour, elle s'étend dans une dizaine de villes, et la croissance ne laisse aucun répit aux équipes tech. On intervient depuis neuf mois pour accompagner cette croissance. Cet article est un retour d'expérience à deux voix sur le stack technique — et en particulier sur ce que GraphQL a changé dans notre façon de travailler. ## Le contexte : pourquoi faire appel à des seniors Quand Cajoo nous a contactés, l'équipe tech existait et livrait déjà. L'app client, l'app coursier, le back-office — tout tournait. Mais la croissance imposait un rythme de features que l'équipe interne ne pouvait pas absorber seule. Pas par manque de talent — par manque de bande passante et d'expérience sur certains sujets. Le besoin n'était pas de "rajouter des bras". On en a vu, des missions où on arrive pour coder des tickets dans un backlog, tête baissée, sans recul. Ici, le besoin était différent : apporter de l'expérience pour **structurer** ce qui avait été écrit dans l'urgence du lancement, **refactorer** sans casser la prod, et **transmettre** des pratiques aux développeurs plus juniors de l'équipe. C'est un rôle qu'on connaît bien. Le senior externe n'est pas là pour montrer qu'il code plus vite. Il est là pour poser les bonnes questions avant d'écrire la première ligne : est-ce que l'architecture actuelle va tenir la charge dans six mois ? Est-ce que l'équipe pourra maintenir ce code sans nous ? Est-ce qu'on résout le vrai problème ou le symptôme ? Le stack était posé : **Hasura** comme moteur GraphQL managé au-dessus d'un PostgreSQL, des **cloud functions AWS Lambda** en TypeScript pour la logique métier, **React Native** pour les apps mobiles (une app client, une app coursier), et un monorepo Lerna pour orchestrer les dix-huit services. On travaille ensemble — Aurélien sur l'app coursier, Raphael sur le backend serverless — dans le même open space. Et c'est cette proximité qui rend le stack GraphQL particulièrement efficace. ## Aurélien — L'app coursier et le virage Apollo ### Arriver sur un projet en mouvement Quand je suis arrivé sur l'app coursier, elle fonctionnait. Les coursiers l'utilisaient tous les jours pour recevoir leurs assignations, naviguer vers les clients, confirmer les livraisons. Mais le code avait été écrit dans l'urgence du lancement — et c'est normal, il _fallait_ lancer vite. Sauf que les besoins avaient déjà dépassé ce que l'architecture initiale pouvait absorber proprement. La première chose que je fais quand j'arrive sur un projet existant, c'est ne pas toucher au code pendant quelques jours. Je lis, je comprends les conventions (même informelles), je repère les points de friction, j'écoute les devs qui sont là depuis le début. C'est tentant de refactorer dès le premier jour — c'est rarement la bonne approche. Il faut d'abord comprendre _pourquoi_ le code est comme il est avant de décider comment il devrait être. Ici, le constat était clair : il y avait des vues à ajouter — suivi de shifts, historique des livraisons, gestion des incidents — mais aussi un problème plus structurel. Le client GraphQL était codé à la main. Chaque query était une string template, chaque réponse typée manuellement. Ça marchait, mais c'était fragile. À chaque évolution du schéma côté Hasura, il fallait retrouver les queries impactées, mettre à jour les types, et espérer ne rien oublier. Sur un backend qui bouge plusieurs fois par jour, c'est intenable. ### Le passage au client généré Le premier chantier structurant a été de migrer vers un client GraphQL généré avec `graphql-codegen`. Le principe : on écrit les queries et mutations, on lance le codegen, et il produit un SDK TypeScript complet — types des variables, types des réponses, hooks Apollo typés. Le schéma Hasura est la source de vérité unique. Côté backend, Raphael avait déjà mis ça en place. Côté app, c'était encore du fait-main. La migration n'a pas été un big bang — on a migré query par query, en commençant par les plus critiques (les assignations, le suivi de livraison), puis en rattrapant progressivement le reste. C'est une approche que j'applique systématiquement sur les refactorings : jamais de branche qui vit trois semaines. Des petits pas, mergés régulièrement, qui améliorent les choses sans risquer la prod. Le gain a été immédiat : - **Quand Raphael ajoutait un champ dans Hasura**, je relançais le codegen et TypeScript me montrait exactement où l'utiliser — ou me signalait les breaking changes. Plus besoin de Slack pour dire "j'ai changé le format de la réponse". - **Plus de types manuels.** Fini les `as any` et les interfaces qui dérivent. Si la query compile, les types sont corrects. - **L'autocomplétion dans l'IDE** est devenue un vrai outil de découverte de l'API. Un nouveau développeur pouvait explorer le schéma en tapant un point. C'est un gain d'onboarding qu'on sous-estime. ### Le cache Apollo — former l'équipe Le passage à Apollo Client a amené un deuxième bénéfice : le cache normalisé. Apollo stocke chaque entité par son `id` et son `__typename`, et met à jour automatiquement toutes les vues qui référencent cette entité quand une mutation la modifie. En théorie, c'est magique. En pratique, ça demande de la rigueur. C'est le sujet sur lequel j'ai passé le plus de temps avec les développeurs juniors de l'équipe. Le cache Apollo n'est pas intuitif au premier abord. Il faut comprendre pourquoi une liste ne se met pas à jour après un `create` (le cache ne peut pas deviner qu'un nouvel élément appartient à une liste paginée), quand utiliser `refetchQueries` vs `cache.modify`, et pourquoi muter directement un objet du cache est une très mauvaise idée. Plutôt que de laisser chacun se débrouiller (et réinventer les mêmes erreurs), on a établi des conventions documentées : - Toujours retourner l'`id` dans les réponses de mutation - Utiliser `refetchQueries` pour les listes après un create/delete - Réserver les `update` functions aux cas complexes (ajout optimiste) - Ne jamais utiliser `no-cache` par défaut — c'est une fausse bonne idée qui tue la réactivité de l'app Le rôle du senior ici, ce n'est pas de coder plus vite. C'est de poser un cadre clair pour que l'équipe entière avance dans la même direction, y compris quand on ne sera plus là. Ces conventions, une fois partagées et comprises, ont débloqué l'équipe. Le cache est devenu un allié au lieu d'un mystère. ### React Navigation full-typée L'autre restructuration importante a été la navigation. L'app utilisait React Navigation, mais les routes n'étaient pas typées — on passait des params en `any`, et les écrans recevaient des props sans validation. Sur une app de livraison où l'on navigue entre une dizaine d'écrans avec des données critiques (numéro de commande, coordonnées GPS, statut de livraison), c'était un risque concret. J'ai mis en place le typage strict avec un `RootStackParamList` exhaustif. Chaque `navigate('OrderDetail', )` est vérifié à la compilation : le nom de la route existe, les params attendus sont présents et du bon type. Ça a éliminé une classe entière de bugs runtime — les fameux "undefined is not an object" quand un écran attendait un param qu'on n'avait pas passé. C'est typiquement le genre de refactoring qu'une équipe en mode urgence ne priorise jamais. Et c'est typiquement ce qu'un senior externe peut pousser, parce qu'il a vu les conséquences de ne pas le faire sur d'autres projets. ### La télémétrie Sentry — retracer le parcours du livreur Le dernier chantier dont je veux parler, c'est la mise en place d'une télémétrie poussée avec Sentry. Sur une app de livraison, quand le support reçoit un appel d'un coursier bloqué en pleine course, il faut comprendre ce qui s'est passé — vite. On a configuré les **breadcrumbs** Sentry pour enregistrer chaque étape significative du parcours : ouverture de l'app, réception d'une assignation, départ vers le client, scan de la commande, confirmation de livraison, retour au warehouse. Chaque breadcrumb inclut les métadonnées pertinentes — numéro de commande, id du warehouse, timestamp. En React Native, un breadcrumb Sentry c'est un appel simple : ```typescript // Dans le handler de départ en livraison Sentry.addBreadcrumb({ category: "delivery", message: "delivery.departed", data: , level: "info", }); ``` Le piège, c'est de saupoudrer ces appels partout dans le code. On se retrouve vite avec du `Sentry.addBreadcrumb` dans chaque composant, chaque handler, chaque callback. C'est bruyant, c'est couplé, et le jour où on veut changer de provider (Segment, Datadog, un outil interne), on a des centaines de fichiers à modifier. J'ai poussé pour extraire ça dans un module de télémétrie dédié. L'idée : une interface abstraite que le reste de l'app utilise, et une implémentation Sentry qu'on peut remplacer sans toucher au code métier. ```typescript // telemetry/index.ts name: string; data?: Record; } track(event: TelemetryEvent): void; identify(userId: string, traits?: Record): void; setContext(key: string, value: Record): void; } // telemetry/sentry.ts track() { Sentry.addBreadcrumb(); }, identify(userId, traits) { Sentry.setUser(); }, setContext(key, value) , }; ``` Et dans le code métier, on n'importe plus Sentry directement : ```typescript // Le code métier ne connaît pas Sentry telemetry.track({ name: "delivery.departed", data: , }); ``` Le jour où on branche Segment en plus de Sentry (ou à la place), on ajoute un provider, on compose les deux dans un `multiProvider`, et aucun composant ne change. C'est le genre de refactoring qui prend une demi-journée et qui évite des semaines de migration plus tard. Encore un sujet où l'expérience du senior fait la différence — non pas coder le truc, mais _voir venir_ le besoin. Le résultat a transformé le support. Quand un coursier signale un problème, l'équipe ouvre Sentry et retrace tout son parcours dans l'app, écran par écran, action par action : On voit exactement où ça a bloqué — un écran qui n'a pas reçu la bonne data, une mutation qui a échoué silencieusement, une navigation avec des params manquants. Avant, le support demandait au coursier de "décrire ce qu'il avait fait" — en plein Paris, en vélo, sous la pluie. Maintenant, on le voit. ## Raphael — Le backend GraphQL managé ### Pourquoi Hasura, concrètement Quand on parle de GraphQL en backend, la première image qui vient c'est un serveur Apollo Server avec des resolvers écrits à la main. C'est puissant, mais c'est du travail — chaque table, chaque relation, chaque filtre demande du code. Sur un projet qui va vite, avec des dizaines de tables et des besoins qui changent chaque semaine, ça devient un goulot d'étranglement. Hasura prend le problème à l'envers : il se branche sur PostgreSQL et expose automatiquement un schéma GraphQL complet — queries, mutations, subscriptions, filtres, tri, pagination, agrégations. Ajouter une table dans la base, c'est l'avoir disponible dans l'API immédiatement. Une relation entre deux tables devient une nested query. Le CRUD est gratuit dès le premier jour. On se concentre sur la logique métier. Chez Cajoo, la base a plus de 40 tables — orders, users, riders, warehouses, products, coupons, assignations, shifts… Écrire des resolvers CRUD pour tout ça aurait pris des semaines. Avec Hasura, ce temps est investi ailleurs : dans les permissions, dans les event triggers, dans les cloud functions qui portent la vraie logique. ### Les permissions row-level — la sécurité déclarative Un des aspects les plus sous-estimés de Hasura, c'est le système de permissions. On définit, pour chaque rôle, quelles colonnes sont visibles et quel filtre s'applique aux lignes. Un coursier ne voit que les infos nécessaires à sa livraison en cours. Un utilisateur ne voit que ses propres commandes. Le customer service a une vue plus large, mais limitée. Pas de middleware d'autorisation à écrire, pas de `if (user.id !== order.userId)` dans chaque resolver. La sécurité est déclarative, versionnable dans Git, et appliquée par le moteur avant même que la query SQL soit exécutée. On a huit rôles différents — user, rider, customer_service, hub_pilot, catman, marketing, pricing, et des variantes — et chacun a une vue taillée sur mesure de l'API. Quand on ajoute un rôle, on déclare ses permissions une fois, et elles s'appliquent partout. C'est le genre d'architecture qui scale avec la complexité organisationnelle, pas contre elle. ### L'architecture événementielle — sans broker Ce qui rend le stack vraiment intéressant, c'est les event triggers. Hasura surveille les changements dans les tables PostgreSQL et déclenche des webhooks — nos Lambda functions — quand certaines conditions sont remplies. C'est de l'architecture événementielle sans Kafka, sans RabbitMQ, sans infrastructure de messaging à maintenir. Sur la table `orders` seule, on a seize triggers. Quand le paiement passe, on crée la livraison. Quand la livraison est confirmée, on ajoute les points de fidélité. Quand une commande est annulée, on déclenche le remboursement Stripe. Le découplage est total : le service de paiement ne sait pas qu'une livraison va être créée. Chaque Lambda fait une seule chose, avec du retry intégré — si elle échoue, Hasura réessaie automatiquement. Pas besoin de dead letter queues ni de systèmes de compensation complexes. Les cron triggers complètent le tableau : nettoyage des paniers abandonnés, désactivation des coupons expirés, réassignation des commandes en retard (toutes les minutes), rafraîchissement des ETAs (toutes les deux minutes). Dix-huit tâches planifiées, toutes déclarées dans un fichier YAML versionné. Pas de scheduler externe à maintenir. ### Le schema stitching — Stripe dans le graph On a intégré Stripe directement dans le schéma GraphQL via un remote schema. Une Lambda expose un sous-schéma qui wrape l'API Stripe, et Hasura le stitch dans le schéma principal. Le front peut query les moyens de paiement d'un utilisateur dans la même requête que son profil — une seule requête réseau, pas de waterfall. ### La vraie force : la boucle front/back Le point le plus important en pratique, c'est la fluidité de communication que ce stack permet. Quand j'ajoute une colonne, une relation ou un computed field dans Hasura, Aurélien relance son codegen et il a immédiatement les types à jour. Pas de spec à écrire, pas de Swagger à synchroniser, pas de "tu peux me dire le format de la réponse ?". Le schéma GraphQL _est_ la documentation, et elle est toujours à jour parce qu'elle _est_ le code. On travaille dans le même open space. Quand je modifie une mutation, je dis "c'est poussé", il relance son codegen, et cinq minutes plus tard c'est intégré côté app. Ce rythme-là, avec une API REST et des types manuels, on ne le tiendrait pas. C'est aussi un avantage pour l'équipe interne. Le codegen génère automatiquement les types depuis le schéma — c'est la même source de vérité pour le back, le front, et les apps mobiles. Quand un développeur junior rejoint le projet, il n'a pas à deviner le format d'une réponse API. Il tape un point dans son IDE et il voit tout ce qui est disponible. L'onboarding est plus rapide, les erreurs d'intégration disparaissent. ## Ce qu'on retient Neuf mois chez Cajoo nous confirment ce qu'on soupçonnait : GraphQL managé avec Hasura, ce n'est pas juste "du GraphQL sans écrire de resolvers". C'est un changement d'architecture qui déplace la complexité au bon endroit. - **Le CRUD est gratuit.** On écrit du code uniquement pour la logique métier. - **Les permissions sont déclaratives.** Huit rôles, des dizaines de tables, zéro middleware d'autorisation. - **L'architecture est événementielle par défaut.** Les event triggers remplacent un message broker entier. - **Le contrat front/back est garanti par le schéma.** Le codegen élimine une classe entière de bugs d'intégration. - **Le monitoring est essentiel.** Les breadcrumbs Sentry sur l'app coursier ont transformé le support. Et le rôle du senior dans tout ça, ce n'est pas de coder les features plus vite. C'est de prendre le recul pour structurer, de poser les conventions qui permettront à l'équipe de tenir le rythme sans nous, et de pousser les refactorings que personne ne priorise quand on est en mode survie. Le quick commerce va vite. Il faut que le code suive — mais proprement. --- *On a aussi écrit sur [pourquoi Rails reste notre choix par défaut](/blog/pourquoi-rails-en-2022) quand le contexte ne justifie pas un stack GraphQL/serverless comme celui de Cajoo.*