NestJSGraphQLDataLoaderMongoDBPerformance

DataLoader avec NestJS et GraphQL : éliminez le problème N+1

Le problème N+1 ralentit vos API GraphQL. Apprenez à le résoudre avec DataLoader dans NestJS et MongoDB : batching, cache par requête et code complet.

AMAlexis Mouchon11 min de lecture

Votre API GraphQL fonctionne, mais elle est lente dès qu'on charge une liste avec ses relations. En regardant les logs, vous découvrez des dizaines de requêtes MongoDB là où une ou deux suffiraient. C'est le problème N+1, l'un des pièges les plus classiques de GraphQL — et l'un des plus simples à corriger une fois qu'on a compris le mécanisme.

Dans cet article, on va voir d'où vient ce problème, pourquoi GraphQL y est particulièrement exposé, et comment le résoudre proprement avec DataLoader dans une stack NestJS + GraphQL + MongoDB. Avec du code complet, prêt à reprendre.

C'est quoi, le problème N+1 ?

Imaginons une API qui expose des articles et leurs auteurs. Vous écrivez une query GraphQL toute simple :

query {
  posts {
    id
    title
    author {
      id
      name
    }
  }
}

Côté serveur, GraphQL résout cette requête champ par champ. Il appelle d'abord le resolver posts qui retourne, disons, 20 articles. Puis, pour chaque article, il appelle le resolver du champ author. Résultat : 1 requête pour récupérer les posts, puis 20 requêtes pour récupérer les auteurs un par un. Soit N+1 requêtes (ici 21) là où une requête groupée aurait suffi.

À petite échelle, ça passe inaperçu. Mais avec 200 articles, c'est 201 allers-retours vers MongoDB pour une seule query. La latence explose, la base de données sature, et l'expérience utilisateur se dégrade. Le pire, c'est que le code paraît parfaitement correct — le problème est structurel, pas logique.

// Le resolver de champ "author" — coupable du N+1
@ResolveField(() => Author)
async author(@Parent() post: Post): Promise<Author> {
  // Appelé une fois PAR post → autant de findById que de posts
  return this.authorService.findById(post.authorId);
}

Pourquoi GraphQL est particulièrement exposé

Avec une API REST classique, c'est vous qui décidez de la forme des requêtes SQL ou Mongo derrière chaque endpoint. Vous pouvez optimiser un populate ou un $lookup à la main.

GraphQL inverse cette logique : c'est le client qui compose sa requête, et le serveur résout chaque champ indépendamment via des resolvers. Cette flexibilité est la grande force de GraphQL, mais elle a un coût. Comme chaque resolver de champ ignore le contexte global de la requête, il ne sait pas qu'il est appelé 200 fois d'affilée pour le même type de données. Chacun fait sa petite requête dans son coin.

Si vous débutez avec GraphQL côté NestJS, je vous renvoie d'abord à mon article GraphQL Code First avec NestJS qui pose les bases des resolvers et des @ResolveField. La suite suppose que vous êtes à l'aise avec ces concepts.

La solution : DataLoader, batching et cache

DataLoader est une petite librairie maintenue par l'équipe GraphQL. Elle résout le N+1 grâce à deux mécanismes complémentaires.

Le batching regroupe tous les appels individuels effectués pendant un même tick de la boucle d'événements Node.js. Au lieu de déclencher 200 findById, DataLoader collecte les 200 identifiants demandés et vous laisse les charger en une seule requête via un $in. C'est le cœur de l'optimisation.

Le caching mémorise les résultats au sein d'une même requête. Si deux articles partagent le même auteur, l'auteur n'est chargé qu'une fois. Ce cache est volontairement limité à la durée d'une requête HTTP — on ne veut pas servir des données périmées entre deux utilisateurs.

Le principe est simple : vous fournissez à DataLoader une fonction de batch qui reçoit un tableau de clés et doit retourner un tableau de valeurs dans le même ordre. C'est cette contrainte d'ordre qui fait toute la subtilité de l'implémentation.

Implémentation dans NestJS

Étape 1 — La fonction de batch

On commence par la fonction qui charge plusieurs auteurs en une requête. Le point critique : le tableau retourné doit correspondre exactement à l'ordre des clés reçues, même si MongoDB renvoie les documents dans un ordre différent.

// author.loader.ts
import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
import { AuthorService } from './author.service';
import { Author } from './author.schema';

@Injectable({ scope: Scope.REQUEST }) // une instance par requête HTTP
export class AuthorLoader {
  constructor(private readonly authorService: AuthorService) {}

  public readonly batchAuthors = new DataLoader<string, Author>(
    async (ids: readonly string[]): Promise<Author[]> => {
      // UNE seule requête Mongo pour tous les ids
      const authors = await this.authorService.findByIds([...ids]);

      // On indexe par id pour réordonner selon les clés demandées
      const authorMap = new Map(
        authors.map((author) => [author.id.toString(), author]),
      );

      // Respect impératif de l'ordre des ids en entrée
      return ids.map((id) => authorMap.get(id));
    },
  );
}

Le décorateur Scope.REQUEST est essentiel : il garantit une nouvelle instance de loader à chaque requête. Sans cela, le cache interne de DataLoader serait partagé entre tous les utilisateurs — une fuite de données et une source de bugs sournois.

Étape 2 — Le service avec findByIds

Côté service, on expose une méthode de chargement groupé qui utilise l'opérateur $in de MongoDB :

// author.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Author } from './author.schema';

@Injectable()
export class AuthorService {
  constructor(
    @InjectModel(Author.name) private readonly authorModel: Model<Author>,
  ) {}

  async findByIds(ids: string[]): Promise<Author[]> {
    // Un seul aller-retour réseau, peu importe le nombre d'ids
    return this.authorModel.find({ _id: { $in: ids } }).exec();
  }
}

Étape 3 — Brancher le loader dans le resolver

Il ne reste qu'à remplacer l'appel direct par un appel au loader. La signature publique du resolver ne change pas — seul son fonctionnement interne devient performant.

// post.resolver.ts
import { Resolver, ResolveField, Parent } from '@nestjs/graphql';
import { Post } from './post.schema';
import { Author } from './author.schema';
import { AuthorLoader } from './author.loader';

@Resolver(() => Post)
export class PostResolver {
  constructor(private readonly authorLoader: AuthorLoader) {}

  @ResolveField(() => Author)
  async author(@Parent() post: Post): Promise<Author> {
    // Plus de findById direct : DataLoader regroupe et met en cache
    return this.authorLoader.batchAuthors.load(post.authorId.toString());
  }
}

Le .load() ne déclenche pas immédiatement la requête. DataLoader attend la fin du tick courant pour collecter tous les authorId demandés par les 200 resolvers, puis appelle votre fonction de batch une seule fois avec l'ensemble des identifiants. On passe de 201 requêtes à 2.

Étape 4 — Enregistrer le module

Pensez à déclarer le loader comme provider et à l'exporter si d'autres modules en ont besoin.

// post.module.ts
import { Module } from '@nestjs/common';
import { PostResolver } from './post.resolver';
import { AuthorLoader } from './author.loader';
import { AuthorService } from './author.service';

@Module({
  providers: [PostResolver, AuthorLoader, AuthorService],
})
export class PostModule {}

Mesurer le gain réellement obtenu

Optimiser sans mesurer, c'est avancer à l'aveugle. Le plus simple est d'activer le logging des requêtes Mongoose pour compter les allers-retours avant et après :

// main.ts — en développement uniquement
import mongoose from 'mongoose';

if (process.env.NODE_ENV !== 'production') {
  let queryCount = 0;
  mongoose.set('debug', (collection, method) => {
    queryCount++;
    console.log(`[Mongo #${queryCount}] ${collection}.${method}`);
  });
}

Avant DataLoader, sur une query de 200 articles, vous verrez défiler 200 lignes authors.findOne. Après, une seule ligne authors.find. Sur des données réelles, j'ai déjà vu des temps de réponse passer de 1,8 s à moins de 120 ms sur ce simple changement. Le gain est rarement subtil — il est spectaculaire.

Les pièges à éviter

Le loader doit être request-scoped. C'est l'erreur la plus fréquente. Un loader en singleton partage son cache entre toutes les requêtes : un utilisateur peut voir les données chargées pour un autre, et le cache ne se vide jamais. Toujours Scope.REQUEST.

L'ordre du tableau retourné est sacré. Si votre fonction de batch retourne les valeurs dans un ordre différent de celui des clés, DataLoader associera les mauvais auteurs aux mauvais articles. C'est pour ça qu'on passe par une Map indexée plutôt que de retourner directement le résultat de find.

Gérez les clés introuvables. Si un authorId ne correspond à aucun document, votre Map.get() retourne undefined. DataLoader accepte null/undefined, mais assurez-vous que votre schéma GraphQL autorise le champ à être nullable, sinon vous obtiendrez une erreur de résolution.

Attention au scope REQUEST en cascade. Un provider Scope.REQUEST « contamine » les providers qui l'injectent, qui deviennent eux aussi request-scoped. C'est généralement sans conséquence ici, mais gardez-le en tête pour les chaînes d'injection profondes. Pour aller plus loin sur la transformation et la validation des données en amont, voir mon article sur les Pipes et Interceptors NestJS.

Aller plus loin

DataLoader ne se limite pas aux relations un-à-un. Pour les relations un-à-plusieurs (les commentaires d'un article, par exemple), la fonction de batch retourne un tableau de tableaux, indexé sur la clé parente plutôt que sur l'identifiant du document. Le principe reste identique : une clé en entrée, une valeur (ou une liste) à la même position en sortie.

Vous pouvez aussi combiner DataLoader avec une couche de cache plus durable (Redis) pour les données qui changent rarement, ou l'associer à du temps réel — j'en parle dans mon article sur les WebSockets avec NestJS et Socket.io si vos données évoluent en direct.

Conclusion

Le problème N+1 n'est pas un bug, c'est une conséquence naturelle de la façon dont GraphQL résout les champs. La bonne nouvelle, c'est qu'il se règle de manière élégante et localisée avec DataLoader : une fonction de batch qui regroupe les requêtes, un cache limité à la requête HTTP, et un loader request-scoped. Quelques dizaines de lignes pour transformer une API qui rame en API qui répond en quelques millisecondes — sans rien changer au contrat exposé au client.

Si vous construisez une API GraphQL avec NestJS, intégrez DataLoader dès le départ : c'est bien plus simple que de l'ajouter après coup, quand les ralentissements commencent à se faire sentir en production.


Vous avez un projet web ou une API à concevoir et vous voulez partir sur des bases solides et performantes ? N'hésitez pas à me contacter pour en discuter — je serai ravi d'échanger sur votre projet (contact@alexis-mouchon.fr).