04.11.26
DataLoader GraphQL : éviter les erreurs de batch12 min de lecture2 332 mots

DataLoader GraphQL : éviter les erreurs de batch

Corrigez les bugs silencieux de DataLoader : ordre des résultats, lignes manquantes, cache et tests pour fiabiliser vos résolveurs GraphQL.

DataLoader GraphQLerreur N+1 GraphQLbatch DataLoaderrésolveur GraphQLcache par requête
DataLoader GraphQL : éviter les erreurs de batch

DataLoader dans GraphQL : corriger le N+1 sans perdre la cohérence des données

Vous avez repéré un problème N+1 dans une API GraphQL : une liste de 25 articles déclenche 25 requêtes supplémentaires pour charger leurs auteurs. Vous ajoutez DataLoader, et le nombre de requêtes passe de 26 à 2. Les tests sont verts. Sur le papier, c'est réglé.

Sauf qu'une implémentation naïve peut associer un article au mauvais auteur sans déclencher la moindre erreur. Et, dans le pire cas, un seul identifiant invalide peut faire tomber une bonne partie des résolveurs. Réduire le nombre de requêtes, oui. Mais encore faut-il respecter exactement le contrat attendu par DataLoader.

Dans cet article, je détaille comment : interpréter ce contrat, remettre les résultats dans le bon ordre, gérer les lignes absentes et éviter que le cache (ou la logique d'accès) n'introduise d'autres défauts. Et je propose une méthode de test qui vérifie l'association réelle entre chaque élément et sa relation, pas seulement le nombre de requêtes.

Pourquoi DataLoader peut-il associer le mauvais auteur ?

DataLoader regroupe des chargements individuels en un seul appel. Un résolveur demande l'auteur d'un article, un autre celui d'un deuxième article : au lieu d'exécuter une requête SQL par auteur, le batch récupère plusieurs utilisateurs en une fois.

Le gain est réel. Mais DataLoader impose une règle simple : la fonction de batch doit renvoyer une liste contenant un résultat pour chaque clé demandée, et dans le même ordre. Si les clés reçues sont [u3, u1, u2], alors la valeur à l'index 0 doit correspondre à u3, celle à l'index 1 à u1, et celle à l'index 2 à u2.

Et point important : DataLoader ne « recroise » pas les identifiants. Il associe les résultats aux demandes par position. Donc la fonction de batch doit reconstruire cette correspondance, même si la requête SQL renvoie des lignes dans un ordre inattendu.

Le piège de ANY dans PostgreSQL

Une version « qui marche » au début ressemble souvent à ceci :

const userById = new DataLoader<string, User>(async (ids) => {

const { rows } = await db.query<User>(

'SELECT * FROM users WHERE id = ANY($1::uuid[])',

[ids],

);

return rows;

});

La requête réduit bien les allers-retours vers la base. En revanche, WHERE id = ANY(...) ne garantit pas que PostgreSQL renverra les lignes dans l'ordre du tableau ids. Le moteur choisit un plan d'exécution adapté au contexte (scan, index, statistiques...), et l'ordre observé peut changer.

Si PostgreSQL renvoie [u1, u2, u3] alors que DataLoader attend [u3, u1, u2], chaque résultat peut se retrouver rattaché au mauvais résolveur. GraphQL peut continuer à répondre « correctement » en apparence (la forme des objets est bonne), mais les liens entre articles et auteurs sont faux.

Pourquoi les tests passent-ils malgré tout ?

En développement, ou avec une base de test peu réaliste, les utilisateurs sont parfois insérés dans le même ordre que celui de vos tests. Dans ce cas, un scan séquentiel peut produire un ordre qui cache le problème.

Avec des mocks, c'est encore plus fréquent : le faux module renvoie les lignes dans l'ordre « prévu », donc vous ne déclenchez jamais le scénario qui met le contrat en évidence.

Le comportement peut aussi diverger en production quand les données évoluent et que PostgreSQL choisit un autre plan. Et surtout : un test qui vérifie seulement que les auteurs existent ne prouve pas que chaque article reçoit le bon auteur. Ce qu'il faut valider, c'est l'identité de la relation.

Comment respecter le contrat d'un batch DataLoader ?

La règle est simple : vous indexez les lignes récupérées par leur clé, puis vous renvoyez un tableau aligné sur l'ordre des clés demandées. La requête SQL peut renvoyer l'ensemble dans n'importe quel ordre : votre code doit ensuite réaligner.

function byKey<T, K>(

keys: readonly K[],

rows: T[],

keyOf: (row: T) => K,

): (T | null)[] {

const map = new Map(rows.map((row) => [keyOf(row), row]));

return keys.map((key) => map.get(key) ?? null);

}

Dans le loader des auteurs :

const userById = new DataLoader<string, User | null>(async (ids) => {

const { rows } = await db.query<User>(

'SELECT * FROM users WHERE id = ANY($1::uuid[])',

[ids],

);

return byKey(ids, rows, (user) => user.id);

});

Avec ça, vous garantissez deux choses : le tableau renvoyé a la même longueur que les clés demandées, et chaque position correspond à la bonne clé. L'ordre renvoyé par PostgreSQL ne compte plus.

Une valeur par clé, même quand la donnée manque

Le null dans le type de retour n'est pas juste un détail de typage : il sert à représenter une clé pour laquelle aucune ligne n'a été trouvée. Ça arrive quand une donnée a été supprimée, qu'une référence est obsolète, ou qu'il existe déjà une incohérence dans la base.

Ensuite, le résolveur choisit comment exposer cette absence à l'API : champ nullable, valeur par défaut, ou erreur métier. Si le champ GraphQL est non nullable, une erreur peut remonter selon les règles GraphQL. Dans tous les cas, le choix appartient au modèle d'API : vous ne voulez pas que DataLoader « échoue » uniquement parce qu'une ligne manque.

Garder les types alignés avec le contrat

Si l'absence d'utilisateur est possible, le type doit le refléter. Déclarer DataLoader<string, User> alors que le batch renvoie parfois null, c'est masquer une réalité du système.

En mettant DataLoader<string, User | null>, vous forcez le code appelant à traiter ce cas. C'est particulièrement utile en équipe : au lieu d'avoir un bug qui explose plus tard dans l'interface ou dans la sérialisation GraphQL, vous le repérez au moment où vous écrivez le code.

Que faire lorsqu'une ligne liée manque ?

Un batch doit garder une place pour chaque clé, même si aucune ligne ne correspond. Sinon, un seul identifiant absent peut faire échouer tout l'appel regroupé.

Prenons 25 articles, dont un pointe vers un utilisateur supprimé. PostgreSQL renvoie 24 utilisateurs. Si votre fonction renvoie directement ces 24 lignes, le tableau ne correspond plus aux 25 demandes. DataLoader peut alors détecter le mauvais nombre de résultats et rejeter toutes les promesses du batch. Le « petit » défaut (auteur absent) devient une erreur globale sur la liste.

En pratique :

  • relation singulière : renvoyez plutôt null
  • collection : renvoyez un tableau vide []

Regrouper les résultats d'une relation un-à-plusieurs

Pour charger les éléments d'une commande, l'idée n'est pas « une commande → une ligne ». Il faut regrouper par clé étrangère, puis produire une liste par commande demandée :

const itemsByOrderId = new DataLoader<string, OrderItem[]>(async (orderIds) => {

const { rows } = await db.query<OrderItem>(

'SELECT * FROM order_items WHERE order_id = ANY($1::uuid[]) ORDER BY created_at',

[orderIds],

);

const groups = new Map<string, OrderItem[]>();

for (const row of rows) {

const items = groups.get(row.order_id) ?? [];

items.push(row);

groups.set(row.order_id, items);

}

return orderIds.map((id) => groups.get(id) ?? []);

});

Ici, ORDER BY created_at organise l'ordre dans chaque groupe. Mais ça ne remplace pas le réalignement global du tableau extérieur : celui-ci doit toujours suivre l'ordre de orderIds.

Deux ordres différents sont en jeu : l'ordre des clés du batch, puis l'ordre des lignes dans chaque groupe. La requête peut gérer le second, votre code doit gérer le premier.

Choisir une représentation cohérente de l'absence

Pour une relation singulière, null signifie « rien trouvé ». Pour une collection, [] signifie « il n'y a aucun élément ». Évitez de renvoyer undefined par accident : ça rend le contrat flou et complique les résolveurs.

Et faites aussi la différence entre une absence normale et une vraie erreur. Une ligne manquante peut être un cas attendu. Une panne SQL, non. Ne convertissez pas tout en valeur vide : vous masqueriez un problème d'infrastructure derrière un résultat « vide ».

Comment éviter les fuites de cache et tester le correctif ?

L'ordre et la taille des résultats ne sont pas les seuls risques. DataLoader a un cache intégré (par clé). Sa durée de vie doit donc correspondre à la portée réelle où vos résultats restent valides.

Créer les loaders pour chaque requête

Si vous déclarez les loaders au niveau d'un module, ils survivent à plusieurs requêtes HTTP. La deuxième requête peut alors réutiliser une valeur déjà mise en cache dans le contexte de la première. Or ces résultats peuvent devenir périmés, et dans une app multi-utilisateur (ou multi-tenant), ils peuvent aussi exposer des données qu'on ne devrait pas voir.

La bonne approche consiste à créer les loaders dans la fabrique du contexte GraphQL, pour que chaque requête ait son propre cache :

context: () => ({

db,

loaders: createLoaders(db),

});

Le contexte peut partager la connexion ou un pool. En revanche, loaders et cache doivent être isolés par requête, sauf si vous avez construit un cache partagé avec des règles claires d'invalidation et de cloisonnement.

Ne pas confondre chargement et autorisation

Un loader dont la clé est « juste un identifiant » ne sait pas, par lui-même, distinguer des niveaux d'accès différents. Si votre batch applique une autorisation et renvoie une valeur déjà filtrée, cette décision peut être mise en cache sous la clé de l'identifiant puis réutilisée telle quelle dans le même périmètre de cache.

Dans une architecture simple, chargez l'objet, puis vérifiez l'accès dans le résolveur avec le contexte de la requête. Si vous devez absolument appliquer la restriction dans la requête SQL (par exemple pour l'isolation entre organisations), alors rendez la règle explicite : le loader doit être construit dans le bon contexte, et la clé doit inclure toutes les dimensions qui changent le résultat. Ne comptez pas sur un cache indexé par id pour « séparer » des droits qui dépendent du contexte.

Tester les associations, pas seulement le nombre de requêtes

Vérifier que vous passez de 26 à 2 requêtes prouve l'efficacité du batch. Ça ne prouve ni l'ordre, ni la correspondance entre objets.

Ajoutez un test qui vérifie, pour chaque article, que l'identifiant d'auteur renvoyé correspond bien à l'identifiant attendu.

it('associe chaque article à son propre auteur', async () => {

const users = await createUsers(5);

for (const user of [...users].reverse()) {

await createPost({ authorId: user.id });

}

const response = await execute(`

{ posts(first: 5) { authorId author { id } } }

`);

for (const post of response.data.posts) {

expect(post.author.id).toBe(post.authorId);

}

});

Idéalement, faites tourner ce test avec PostgreSQL réel, ou avec un environnement d'intégration suffisamment proche de la production. Un mock qui renvoie des lignes dans un ordre « prévisible » peut reproduire exactement le mauvais scénario que vous essayez d'éliminer.

Pour être encore plus précis, testez aussi directement la fonction de batch avec des clés permutées, puis vérifiez le comportement quand une clé n'a pas de ligne correspondante. Les tests unitaires sont rapides ; le test d'intégration vous dit si le comportement tient avec la base réellement utilisée.

Conclusion

DataLoader corrige efficacement le N+1, mais il ne transforme pas magiquement une requête SQL en réponses correctement associées. Le point clé, c'est de respecter son contrat : même nombre de valeurs que de clés, même ordre, et représentation explicite des absences.

Dans un projet réel, ajoutez à ça des loaders limités à une requête, une gestion d'accès adaptée au contexte et des tests qui valident les relations elles-mêmes. Vous gardez le gain de performance, sans laisser un défaut discret s'infiltrer dans vos données.

Questions frequentes

Non. DataLoader exige que votre fonction de batch renvoie les résultats dans l’ordre des clés reçues, mais PostgreSQL ne garantit pas que ANY(...) restitue les lignes dans cet ordre. Il faut indexer les résultats par leur clé, puis reconstruire le tableau attendu.
Pour une relation singulière, renvoyez généralement null. Pour une collection, renvoyez un tableau vide. Dans les deux cas, conservez une valeur à la position de chaque clé demandée. Le résolveur détermine ensuite la réponse GraphQL appropriée.
En règle générale, non. Un loader global conserve son cache entre les requêtes et peut servir une valeur périmée ou mal isolée. Créez les loaders dans le contexte GraphQL de chaque requête, sauf si vous avez conçu et sécurisé explicitement un cache partagé.
Créez des données dont l’ordre de chargement diffère de l’ordre d’insertion, exécutez la requête GraphQL, puis comparez pour chaque article l’identifiant de l’auteur renvoyé à l’identifiant attendu. Un test d’intégration avec PostgreSQL complète utilement un test unitaire de la fonction de batch.

Articles similaires