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 anyet 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’
iddans les réponses de mutation - Utiliser
refetchQueriespour les listes après un create/delete - Réserver les
updatefunctions aux cas complexes (ajout optimiste) - Ne jamais utiliser
no-cachepar 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', { orderId }) 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 :
import * as Sentry from "@sentry/react-native";
// Dans le handler de départ en livraison
Sentry.addBreadcrumb({
category: "delivery",
message: "delivery.departed",
data: {
orderId: order.id,
orderNumber: order.number,
warehouseId: order.warehouse_id,
eta: order.eta_minutes,
},
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.
// telemetry/index.ts
export interface TelemetryEvent {
name: string;
data?: Record<string, unknown>;
}
export interface TelemetryProvider {
track(event: TelemetryEvent): void;
identify(userId: string, traits?: Record<string, unknown>): void;
setContext(key: string, value: Record<string, unknown>): void;
}
// telemetry/sentry.ts
import * as Sentry from "@sentry/react-native";
export const sentryProvider: TelemetryProvider = {
track({ name, data }) {
Sentry.addBreadcrumb({
category: name.split(".")[0],
message: name,
data,
level: "info",
});
},
identify(userId, traits) {
Sentry.setUser({ id: userId, ...traits });
},
setContext(key, value) {
Sentry.setContext(key, value);
},
};
Et dans le code métier, on n’importe plus Sentry directement :
import { telemetry } from "../telemetry";
// Le code métier ne connaît pas Sentry
telemetry.track({
name: "delivery.departed",
data: { orderId: order.id, eta: order.eta_minutes },
});
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 quand le contexte ne justifie pas un stack GraphQL/serverless comme celui de Cajoo.