Skip to content

GraphQL dans NestJS ​

TL;DR — @nestjs/graphql te laisse choisir code-first (TypeScript → SDL généré) ou schema-first (SDL → types générés). Sous le capot, driver Apollo (par défaut) ou Mercurius (Fastify). Les vrais pièges ne sont pas les resolvers, ce sont : N+1, complexité de query non bornée, erreurs d'erreurs (typage), subscriptions à l'échelle, et federation vs stitching.

🧠 Mental model — ASCII diagram + analogy ​

Analogie : REST = menu fixe d'un fast-food (chaque endpoint un plat). GraphQL = buffet à la carte, le client compose son assiette. Mais sans contrôle : le client peut demander la cuisine entière en une requête → perf catastrophique.

   Client query
       │
       ▼
   ┌─────────────┐    parse + validate    ┌────────────┐
   │ HTTP/WS     │ ─────────────────────▶ │  Schema    │
   └─────────────┘                         └────┬───────┘
                                                │ resolve tree
                                                ▼
   ┌─────────────────────────────────────────────────┐
   │  Resolver(Query) ──▶ Resolver(Field) ──▶ ...    │
   │       │                  │                       │
   │       ▼                  ▼                       │
   │  DataLoader        DataLoader (batch + cache)    │
   │       │                  │                       │
   │       ▼                  ▼                       │
   │     DB / HTTP / gRPC backends                    │
   └─────────────────────────────────────────────────┘

Chaque champ peut avoir son resolver. Sans DataLoader, un champ User.posts exécuté pour 100 users = 100 requêtes DB. Avec DataLoader = 1 requête WHERE userId IN (...).

⚖️ Code-first vs Schema-first — la première décision ​

Le choix structure tout le reste du projet. Pour un ex-TS, code-first est presque toujours le bon défaut, mais sache défendre les deux.

AxeCode-first (@ObjectType)Schema-first (.graphql → codegen)
Source de véritéLes classes TS, le SDL est généréLe SDL .graphql, les types TS sont générés
RefactorRenomme un champ → TS te suit partoutEdit SDL puis re-génère, drift possible
Découplage schéma/codeFaible (couplé aux décorateurs Nest)Fort (le schéma vit indépendamment)
Schéma piloté par le front / contratPlus dur (le schéma émerge du back)Naturel (schema-driven design, review du SDL en PR)
Outillage tiers (linters SDL, mocks)Moins directPremier-classe
Risque #1Le SDL généré dérive si tu oublies sortSchemaDrift type/SDL si codegen pas en CI

Mental model staff : code-first optimise la vélocité d'une équipe TS full-stack ; schema-first optimise le contrat partagé entre équipes/langages (le SDL devient l'artefact négocié, reviewé, versionné). Federation pousse souvent vers schema-first car le supergraphe est un contrat. Quel que soit le choix, committe le schema.gql généré et fais échouer la CI s'il diffère — c'est ta seule défense contre un breaking change accidentel.

🛠️ Code minimal — realistic working snippet ​

Setup code-first avec Apollo ​

ts
// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      sortSchema: true,
      playground: false,
      introspection: process.env.NODE_ENV !== 'production',
      context: ({ req, res }) => ({ req, res }),
    }),
  ],
})
export class AppModule {}

Object type + resolver ​

ts
// user.model.ts
import { ObjectType, Field, ID } from '@nestjs/graphql';

@ObjectType()
export class User {
  @Field(() => ID) id: string;
  @Field() email: string;
  @Field(() => [Post]) posts: Post[];
}

// user.resolver.ts
import { Resolver, Query, ResolveField, Parent, Args } from '@nestjs/graphql';

@Resolver(() => User)
export class UserResolver {
  constructor(
    private readonly users: UserService,
    private readonly postsLoader: PostsByUserLoader,
  ) {}

  @Query(() => User, { nullable: true })
  user(@Args('id') id: string) {
    return this.users.findById(id);
  }

  @ResolveField(() => [Post])
  posts(@Parent() user: User) {
    return this.postsLoader.load(user.id);
  }
}

DataLoader (N+1 fix) ​

ts
import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class PostsByUserLoader extends DataLoader<string, Post[]> {
  constructor(private readonly db: PostsRepository) {
    super(async (userIds: readonly string[]) => {
      const posts = await db.findByUserIds([...userIds]);
      const map = new Map<string, Post[]>();
      userIds.forEach((id) => map.set(id, []));
      posts.forEach((p) => map.get(p.userId)!.push(p));
      return userIds.map((id) => map.get(id)!);
    });
  }
}

Subscription (WS) ​

ts
import { Subscription, Resolver } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';

const pubSub = new PubSub();

@Resolver()
export class CommentsResolver {
  @Subscription(() => Comment, {
    filter: (payload, vars) => payload.commentAdded.postId === vars.postId,
  })
  commentAdded(@Args('postId') postId: string) {
    // graphql-subscriptions v2 : `asyncIterableIterator` (l'ancien
    // `asyncIterator` est deprecated). Avec graphql-redis-subscriptions
    // l'API est identique mais les events traversent les instances.
    return pubSub.asyncIterableIterator('commentAdded');
  }
}
// Quelque part : pubSub.publish('commentAdded', { commentAdded: newComment });

Note de scale : new PubSub() est un EventEmitter en mémoire. Il marche en dev et casse silencieusement dès le 2ᵉ pod (un client connecté au pod A ne voit jamais un publish fait sur le pod B). En prod : graphql-redis-subscriptions (ou NATS/Kafka). On l'injecte via un provider 'PUB_SUB' (cf. l'exemple end-to-end) pour pouvoir swapper sans toucher aux resolvers.

🎯 Patterns courants ​

  1. DataLoader par requête — scope REQUEST, jamais singleton (sinon cache pollué entre users). Un loader = une route DB.
  2. Pagination Relay — Connection<T> avec edges, node, pageInfo, cursor. Standard, compatible Apollo Client cache.
  3. Erreurs typées — au lieu de throw, retourne une union type CreateUserResult = User | EmailAlreadyTaken | ValidationFailed. Le client gère sans deviner.
  4. Persisted queries — en prod, accepte seulement des queryId (hash) précompilés côté client. Tue 80 % des attaques d'introspection / queries malveillantes.
  5. Complexity / depth limiting — graphql-query-complexity calcule un coût et rejette cost > 1000. Indispensable en API publique.
  6. Federation v2 — découpe ton schéma en subgraphs (Users, Orders, Inventory). Un Router (Apollo Router en Rust) fait la composition. Bien meilleur que le stitching legacy.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11 ​

  • Nest 7 : @nestjs/graphql v7, Apollo Server 2, schema-first dominant.
  • Nest 8 : split du package — @nestjs/graphql + @nestjs/apollo (driver). Apollo Server 3.
  • Nest 9 : Mercurius driver officiel (@nestjs/mercurius) pour Fastify. Apollo 3 → 4 en option.
  • Nest 10 : Apollo Server 4 par défaut, configuration de plugin via plugins: []. Federation v2 supportée.
  • Nest 11 : Apollo Server 5 supporté, ESM-first. @nestjs/graphql v12+. Vérifie les types GraphQLModuleOptions (plusieurs propriétés deprecated).

Library notes :

  • Apollo Server 2 → 3 : apollo-server-express deprecated, on passe à @apollo/server + middleware Express.
  • Apollo 3 → 4 : retrait de apollo-server-core, plugins refondus, formatError signature change.
  • Apollo 4 → 5 : ESM strict, Node 20+, certains plugins doivent être recompilés.
  • Mercurius : 2x plus rapide sur Fastify pour les schémas larges, mais écosystème de plugins moins riche.
  • graphql-subscriptions (PubSub mémoire) : ok dev, pas en prod multi-instances. Utilise graphql-redis-subscriptions.

⚠️ Pitfalls ​

  1. N+1 silencieux — c'est l'erreur n°1. Active apollo-tracing ou un plugin de logging par champ, regarde le nombre de requêtes DB par query GraphQL. Toujours DataLoader.
  2. Introspection en prod — un attaquant télécharge tout ton schéma. Désactive (introspection: false) sauf si tu fais une API publique documentée.
  3. Query depth illimitée — un attaquant écrit posts { author { posts { author { ... } } } } x 50. CPU mort. Limite à 7-10.
  4. Erreur Apollo en chaîne — un throw dans un sub-resolver fait planter tout le champ parent (null). Gère explicitement, retourne null ou union d'erreurs.
  5. PubSub in-memory + 3 replicas — un subscriber connecté à instance A ne reçoit pas les events publiés sur B. Utilise Redis PubSub adapter.
  6. @ResolveField qui refetch le parent — si tu refais un findById du parent dans chaque field resolver, tu détruis la perf. Le @Parent() est déjà chargé.
  7. Scalaires custom mal sérialisés — Date, BigInt, JSON : déclare-les explicitement (GraphQLISODateTime, GraphQLJSON) sinon string brut côté client.
  8. Stitching pour scaler — schema stitching est legacy, casse-tête de versioning. Préfère Apollo Federation v2 ou un BFF dédié.

🧪 Testing ​

Unit resolver — instancie le resolver avec mocks :

ts
const resolver = new UserResolver(usersMock, loaderMock);
const u = await resolver.user('u1');
expect(u.id).toBe('u1');

Integration — bootstrap l'app et envoie des requêtes HTTP :

ts
import * as request from 'supertest';

it('returns user with posts in one query', async () => {
  const res = await request(app.getHttpServer())
    .post('/graphql')
    .send({
      query: `query($id: String!) {
        user(id: $id) { id email posts { id title } }
      }`,
      variables: { id: 'u1' },
    });
  expect(res.body.errors).toBeUndefined();
  expect(res.body.data.user.posts).toHaveLength(3);
});

Snapshot du schéma — commit schema.gql généré, et fais un test qui le compare au schéma actuel pour détecter les breaking changes par accident.

Subscriptions — apollo-server-testing permet de simuler des WS, sinon utilise un vrai client graphql-ws contre le serveur en cours.

🎬 Cas d'usage concrets ​

LegalTech — DMS query graph ​

Qui — Cabinet d'avocats d'affaires (200 collaborateurs) qui consolide ses Document Management Systems hérités (NetDocuments + iManage). Problème — Le front (React + mobile iOS) doit naviguer dossier → pièces → clients → factures sans 4 endpoints REST par écran. Chaque écran a un besoin de champs différent. Comment — Schéma GraphQL fédéré, DataLoader pour batcher les requêtes hétérogènes vers les deux DMS.

ts
@Resolver(() => Matter)
export class MatterResolver {
  constructor(
    private loaders: DataLoaderFactory,
    private matters: MatterService,
  ) {}

  @Query(() => Matter, { nullable: true })
  matter(@Args('id') id: string) { return this.matters.findById(id); }

  @ResolveField(() => [Document])
  documents(@Parent() matter: Matter, @Context() ctx: GqlContext) {
    return this.loaders.documentsByMatter(ctx).load(matter.id);
  }

  @ResolveField(() => Client)
  client(@Parent() matter: Matter, @Context() ctx: GqlContext) {
    return this.loaders.clientById(ctx).load(matter.clientId);
  }
}

Gains — Un écran "vue dossier" passe de 7 requêtes REST à 1 query GraphQL, p95 < 220 ms, schéma typé partagé entre web et mobile.

E-commerce — Product variant query ​

Qui — Marque française de prêt-à-porter (catalogue 50 K SKUs avec variantes taille/couleur/matière). Problème — La page produit a besoin des variantes filtrées par stock dispo, le mini-cart juste des titres et prix, le PIM (back-office) tout. Une réponse REST monolithique coûte cher en bande passante mobile. Comment — Schéma GraphQL avec field-level resolvers, complexity analysis pour limiter les abus, fragments réutilisés par les apps.

ts
@Resolver(() => Product)
export class ProductResolver {
  @Query(() => Product, { nullable: true })
  product(@Args('handle') handle: string) { return this.products.byHandle(handle); }

  @ResolveField(() => [Variant])
  async variants(
    @Parent() product: Product,
    @Args('inStock', { nullable: true }) inStock?: boolean,
    @Context() ctx?: GqlContext,
  ) {
    const all = await ctx!.loaders.variantsByProduct.load(product.id);
    return inStock ? all.filter((v) => v.stockLevel > 0) : all;
  }

  @ResolveField(() => Price)
  price(@Parent() product: Product, @Context() ctx: GqlContext) {
    return ctx.loaders.priceByProduct.load({ id: product.id, currency: ctx.currency });
  }
}

Gains — Réduction de 60% du payload mobile, le PIM réutilise les mêmes resolvers que le front public.

RH — Org chart navigation ​

Qui — SIRH français qui modélise des organigrammes complexes (matrice, dotted-lines, fonctions partagées). Problème — Naviguer "qui reporte à qui" récursivement en REST = enfer de pagination. Les écrans changent constamment de profondeur. Comment — Type récursif Employee.manager + Employee.reports, DataLoader pour batcher la résolution par niveau.

ts
@ObjectType()
export class Employee {
  @Field() id: string;
  @Field() fullName: string;
  @Field() jobTitle: string;
}

@Resolver(() => Employee)
export class EmployeeResolver {
  @Query(() => Employee) employee(@Args('id') id: string) { return this.svc.byId(id); }

  @ResolveField(() => Employee, { nullable: true })
  manager(@Parent() e: Employee, @Context() ctx: GqlContext) {
    return e.managerId ? ctx.loaders.employee.load(e.managerId) : null;
  }

  @ResolveField(() => [Employee])
  reports(@Parent() e: Employee, @Context() ctx: GqlContext) {
    return ctx.loaders.reportsByManager.load(e.id);
  }
}

Gains — N+1 batchés par niveau, l'org chart 6 niveaux se charge en 1 query, calcul de span-of-control trivial côté front.

🛠️ Exemple end-to-end ​

Contexte — Le SIRH ci-dessus expose une API GraphQL pour son module "talent review" : un manager doit voir son équipe, leurs derniers feedbacks, leur compétences, et pouvoir ajouter un objectif. On combine resolvers code-first, DataLoader, autorisations par directive et subscription pour les notifications.

ts
// src/employee/employee.model.ts
import {
  ObjectType, InputType, Field, ID, Int,
  registerEnumType, GraphQLISODateTime,
} from '@nestjs/graphql';

@ObjectType()
export class Employee {
  @Field(() => ID) id: string;
  @Field() fullName: string;
  @Field() email: string;
  @Field() jobTitle: string;
  @Field({ nullable: true }) managerId?: string;
}

@ObjectType()
export class Feedback {
  @Field(() => ID) id: string;
  @Field() text: string;
  @Field() authorId: string;
  @Field() targetId: string;
  @Field(() => Int) rating: number;
  @Field() createdAt: Date;
}

// Un union TS (`'draft' | 'active' | 'done'`) N'EST PAS un type GraphQL.
// `@Field()` sur un union de string littéraux lèvera
// `CannotDetermineOutputTypeError`. Il FAUT un enum GraphQL enregistré.
export enum ObjectiveStatus {
  DRAFT = 'draft',
  ACTIVE = 'active',
  DONE = 'done',
}
registerEnumType(ObjectiveStatus, { name: 'ObjectiveStatus' });

@ObjectType()
export class Objective {
  @Field(() => ID) id: string;
  @Field() title: string;
  @Field() description: string;
  @Field(() => ObjectiveStatus) status: ObjectiveStatus;
  @Field() employeeId: string;
  @Field(() => GraphQLISODateTime) createdAt: Date; // sinon `Date` sort en string brut
}

@InputType()
export class CreateObjectiveInput {
  @Field() employeeId: string;
  @Field() title: string;
  @Field() description: string;
}
ts
// src/employee/employee.loaders.ts
@Injectable({ scope: Scope.REQUEST })
export class EmployeeLoaders {
  constructor(
    private employees: EmployeeService,
    private feedbacks: FeedbackService,
    private skills: SkillService,
  ) {}

  readonly employee = new DataLoader<string, Employee>(async (ids) => {
    const rows = await this.employees.findByIds([...ids]);
    const map = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => map.get(id) ?? new Error(`Employee ${id} not found`) as any);
  });

  readonly reportsByManager = new DataLoader<string, Employee[]>(async (managerIds) => {
    const rows = await this.employees.findByManagers([...managerIds]);
    return managerIds.map((id) => rows.filter((r) => r.managerId === id));
  });

  readonly feedbacksByTarget = new DataLoader<string, Feedback[]>(async (targetIds) => {
    const rows = await this.feedbacks.recentForTargets([...targetIds], 5);
    return targetIds.map((id) => rows.filter((r) => r.targetId === id));
  });

  readonly skillsByEmployee = new DataLoader<string, Skill[]>(async (employeeIds) => {
    const rows = await this.skills.forEmployees([...employeeIds]);
    return employeeIds.map((id) => rows.filter((r) => r.employeeId === id));
  });
}
ts
// src/employee/employee.resolver.ts
@Resolver(() => Employee)
@UseGuards(GqlAuthGuard)
export class EmployeeResolver {
  constructor(
    private employees: EmployeeService,
    private objectives: ObjectiveService,
    private loaders: EmployeeLoaders,
    @Inject('PUB_SUB') private pubsub: PubSub,
  ) {}

  @Query(() => Employee, { nullable: true })
  me(@CurrentUser() user: AuthUser) {
    return this.employees.findById(user.id);
  }

  @Query(() => [Employee])
  async myTeam(@CurrentUser() user: AuthUser) {
    return this.loaders.reportsByManager.load(user.id);
  }

  @ResolveField(() => Employee, { nullable: true })
  manager(@Parent() e: Employee) {
    return e.managerId ? this.loaders.employee.load(e.managerId) : null;
  }

  @ResolveField(() => [Employee])
  reports(@Parent() e: Employee) {
    return this.loaders.reportsByManager.load(e.id);
  }

  @ResolveField(() => [Feedback])
  recentFeedback(@Parent() e: Employee) {
    return this.loaders.feedbacksByTarget.load(e.id);
  }

  @ResolveField(() => [Skill])
  skills(@Parent() e: Employee) {
    return this.loaders.skillsByEmployee.load(e.id);
  }

  @ResolveField(() => [Objective])
  objectives(@Parent() e: Employee, @CurrentUser() user: AuthUser) {
    if (user.id !== e.id && user.id !== e.managerId) {
      throw new ForbiddenException();
    }
    return this.objectives.forEmployee(e.id);
  }

  @Mutation(() => Objective)
  async createObjective(
    @Args('input') input: CreateObjectiveInput,
    @CurrentUser() user: AuthUser,
  ) {
    const target = await this.employees.findById(input.employeeId);
    if (!target || target.managerId !== user.id) throw new ForbiddenException();

    const objective = await this.objectives.create({
      ...input, status: ObjectiveStatus.DRAFT, authorId: user.id,
    });
    await this.pubsub.publish(`objective.created.${input.employeeId}`, {
      objectiveCreated: objective,
    });
    return objective;
  }

  @Subscription(() => Objective, {
    filter: (payload, variables) =>
      payload.objectiveCreated.employeeId === variables.employeeId,
  })
  objectiveCreated(@Args('employeeId') employeeId: string) {
    return this.pubsub.asyncIterableIterator(`objective.created.${employeeId}`);
  }
}
ts
// src/app.module.ts
@Module({
  imports: [
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      // `driver` reste au niveau racine (c'est lui qui choisit le type du config).
      // useFactory retourne UNIQUEMENT les options Apollo, pas le driver.
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        autoSchemaFile: 'schema.gql',
        sortSchema: true,
        // Apollo Server 4/5 : `installSubscriptionHandlers` est SUPPRIMÉ.
        // On déclare le transport WS directement ici (graphql-ws, le standard ;
        // subscriptions-transport-ws est mort, ne l'utilise pas en neuf).
        subscriptions: {
          'graphql-ws': {
            // auth au handshake WS — req.user n'existe pas encore ici
            onConnect: (ctx) => verifyWsToken(ctx.connectionParams?.authorization),
          },
        },
        introspection: config.get('NODE_ENV') !== 'production',
        // Le contexte HTTP et le contexte WS n'ont PAS la même forme.
        context: (ctx) => ctx, // on normalise dans un plugin / guard, cf. plus bas
        // depthLimit + complexity sont des *validation rules* GraphQL standard.
        validationRules: [depthLimit(7)],
        // La complexité a besoin du schéma résolu → plugin, pas validationRule statique.
        plugins: [complexityPlugin({ maximumComplexity: 1000 })],
      }),
    }),
    EmployeeModule, FeedbackModule, ObjectiveModule, SkillModule,
  ],
})
export class AppModule {}

Le DataLoader évite l'explosion de requêtes quand le front demande "toute mon équipe avec leurs feedbacks et compétences" (1 query pour l'équipe, 1 batch pour les feedbacks, 1 batch pour les skills, vs 1 + N + N en naïf), depthLimit(7) empêche un client malveillant de demander un graphe infini, et la subscription permet au coach RH d'être notifié temps réel quand un manager fixe un objectif.

Le complexityPlugin référencé ci-dessus n'est pas magique — c'est ~20 lignes qui réutilisent le schéma résolu. La raison pour laquelle ce doit être un plugin et pas une validationRule statique : le coût d'un champ dépend du schéma compilé (les directives @complexity, les multiplicateurs de pagination first:), or les validationRules GraphQL standard tournent avant que Nest n'ait fini de construire le schéma à partir des décorateurs.

ts
// complexity.plugin.ts
import { GraphQLError } from 'graphql';
import { getComplexity, fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
import type { ApolloServerPlugin } from '@apollo/server';

export function complexityPlugin(opts: { maximumComplexity: number }): ApolloServerPlugin {
  return {
    async requestDidStart() {
      return {
        async didResolveOperation({ request, document, schema }) {
          const complexity = getComplexity({
            schema,
            operationName: request.operationName,
            query: document,
            variables: request.variables,
            estimators: [
              // 1) respecte `@Field({ complexity: N })` ; 2) fallback coût 1/champ
              fieldExtensionsEstimator(),
              simpleEstimator({ defaultComplexity: 1 }),
            ],
          });
          if (complexity > opts.maximumComplexity) {
            throw new GraphQLError(
              `Query trop coûteuse: ${complexity} > ${opts.maximumComplexity}`,
              { extensions: { code: 'QUERY_TOO_COMPLEX', complexity } },
            );
          }
          // exporte la métrique (cf. section Observabilité)
          metrics.histogram('graphql.complexity', complexity, { op: request.operationName });
        },
      };
    },
  };
}

Le coût par champ se déclare sur le décorateur : @Field(() => [Post], { complexity: 5 }), ou en multiplicateur de pagination via un estimator custom (complexity = childComplexity * args.first). C'est le seul moyen fiable de borner une query paginée : sans multiplicateur, first: 10000 coûte autant que first: 1 pour l'analyseur.

Piège Apollo Server 4/5 fréquent en entretien : installSubscriptionHandlers: true n'existe plus. Si tu le passes, Nest l'ignore silencieusement et tes subscriptions ne montent jamais. Le transport se déclare via subscriptions['graphql-ws']. De même, context reçoit une forme différente en HTTP ({ req, res }) et en WS ({ connectionParams, extra }) — ne suppose jamais que req existe côté subscription.

🔁 Quand utiliser / éviter ​

Utilise GraphQL :

  • multiples clients (web, mobile, partenaires) avec besoins différents
  • agrégation de plusieurs backends (BFF)
  • équipe frontend forte qui veut piloter ses besoins
  • typage strict end-to-end (TS + codegen)

Évite GraphQL :

  • API simple, un seul consommateur → REST/tRPC suffit
  • payloads binaires (fichiers, streams) — GraphQL est texte/JSON, mauvais fit
  • équipe backend seule, pas de codegen client → tu perds le bénéfice
  • besoin de cache HTTP standard (CDN, ETag) — GraphQL POST passe à côté

Alternatives :

  • tRPC : si client + serveur sont TypeScript, plus simple, type-safe sans codegen
  • REST + OpenAPI : standard, cache HTTP, debuggable avec curl
  • gRPC-Web : RPC strict, perf top, mais binaire et plus rigide

🪜 Perf pitfalls — checklist mentale ​

Avant de pousser un schéma en prod, vérifie chaque ligne :

[ ] Tous les @ResolveField "to-many" passent par un DataLoader
[ ] Profondeur max limitée (graphql-depth-limit)
[ ] Coût max limité (graphql-query-complexity, cost: 1000)
[ ] Persisted queries activées pour les clients officiels
[ ] Introspection désactivée en prod
[ ] PubSub branché sur Redis si > 1 instance
[ ] Erreurs métier modélisées en union types, pas en throw
[ ] Cache côté résolveur (responseCachePlugin Apollo) sur lectures publiques
[ ] Tracing OpenTelemetry / Apollo Studio actif
[ ] Tests de schema breaking (snapshot du SDL)

🆚 Federation vs Stitching vs BFF ​

  • Stitching (legacy) : tu fusionnes manuellement plusieurs schémas dans un gateway Node.js. Avantage : simple à démarrer. Inconvénient : couplage fort, breaking changes douloureux, perfs limitées.
  • Federation v1 : approche déclarative avec directives (@key, @external), mais composition côté Node.
  • Federation v2 : composition côté contrôle (rover compose), exécution avec Apollo Router (Rust, x10 perf). Sépare clean les subgraphs (équipes), supports entities partagées.
  • BFF (Backend For Frontend) : un seul Nest expose un GraphQL composite qui appelle plusieurs REST/gRPC backends. Plus simple si tu ne fais pas du multi-équipe.

Pour une start-up jusqu'à 4-5 équipes : BFF suffit. Au-delà, ou avec besoins d'isolation/cycle de release indépendants, passe à Federation v2.

🔐 AuthZ — où mettre l'autorisation dans un graphe ​

Le réflexe REST (« un guard par route ») ne suffit pas en GraphQL : un seul POST /graphql traverse N resolvers. La question n'est pas « cette route est-elle protégée » mais « ce champ, pour cet utilisateur, sur cette entité, est-il visible ». Trois niveaux, du plus grossier au plus fin :

NiveauMécanismeCe qu'il protègeLimite
Opération@UseGuards(GqlAuthGuard) sur le resolver« es-tu loggé / as-tu le rôle X »ne voit pas la donnée, juste l'identité
Champguard + @RequirePermission() sur @ResolveField« ce champ est-il autorisé pour ce rôle »statique, pas par-ligne
Instance (row-level)check dans le resolver avec le @Parent() chargé« ce manager peut-il voir CET employé »doit s'exécuter après le fetch

Le piège classique : faire le check d'autorisation avant d'avoir la donnée (objectives ne sait pas si l'appelant est le manager tant que l'Employee parent n'est pas chargé). En GraphQL le row-level check vit naturellement dans le field resolver, où @Parent() est déjà résolu — c'est exactement ce que fait objectives() dans l'exemple end-to-end.

ts
// Guard GraphQL : récupère le contexte via GqlExecutionContext, PAS context.switchToHttp()
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    const { req, connectionParams } = ctx.getContext();
    return req ?? { headers: { authorization: connectionParams?.authorization } };
  }
}

Mental model staff : ne laisse jamais la forme du graphe (la query) décider de la sécurité. Un attaquant qui contrôle la query atteint n'importe quel champ ; la seule frontière fiable est le code du resolver. Et attention au leak via les erreurs : un EntityNotFound vs Forbidden distinct laisse un attaquant énumérer les IDs existants (oracle). Pour les ressources sensibles, renvoie le même null/Forbidden dans les deux cas.

🔭 Observabilité — instrumenter un graphe ​

En REST tu lis GET /users/:id 200 12ms. En GraphQL tout est POST /graphql 200 : sans instrumentation par champ, tu es aveugle. Ce qu'un staff branche :

  • Opération nommée obligatoire — rejette les queries anonymes (query { ... } sans nom) en prod. Sans nom d'opération, impossible d'agréger les métriques par query côté APM.
  • Tracing par resolver — Apollo expose des traces OpenTelemetry par champ (graphql.field.resolve spans). Tu vois immédiatement quel @ResolveField est le hot path et quel champ fait du N+1.
  • Compteur de DB calls par requête — wrappe ton repo dans un compteur stocké dans le contexte GraphQL. Une query qui fait 1 hit DB attendu mais en fait 80 = N+1 non batché. Log dbCalls > seuil.
  • Métriques de coût — exporte la complexité calculée par requête (histogramme). Une dérive du p99 de complexité = un client qui s'est mis à demander des graphes plus profonds.
  • Resolver error rate par champ — un champ qui throw 5 % du temps tire le p99 de toute la query (Apollo renvoie quand même un 200 partiel). Alerte par errors[].path.
ts
// Plugin Apollo qui compte les appels DB et logue les N+1 suspects
const dbCallTracker: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async willSendResponse({ contextValue, operationName }) {
        const calls = contextValue.dbCalls ?? 0;
        if (calls > 20) {
          logger.warn({ operationName, dbCalls: calls }, 'Possible N+1');
        }
        metrics.histogram('graphql.db_calls', calls, { op: operationName });
      },
    };
  },
};

🤖 Servir un agent IA via GraphQL (Anthropic) ​

GraphQL et le streaming LLM cohabitent mal sur le papier (une query retourne une fois et termine), mais c'est précisément un cas où les subscriptions brillent : un agent qui pense en plusieurs étapes (tool-use loop) est une série d'events, pas une réponse unique. Modèle mental : une mutation lance la génération et renvoie un runId ; une subscription streame les tokens et la trace d'outils sous ce runId.

Modèles Anthropic actuels : flagship claude-opus-4-8, claude-sonnet-4-6 (workhorse), claude-haiku-4-5 (rapide/cheap). Toujours via le SDK en mode streaming, avec retries SDK activés. Le client est injecté (forRootAsync), jamais new Anthropic() dans un champ.

ts
// llm.module.ts — client DI'd, configurable, testable (on peut mocker le token)
@Module({})
export class LlmModule {
  static forRootAsync(): DynamicModule {
    return {
      module: LlmModule,
      imports: [ConfigModule],
      providers: [
        {
          provide: 'ANTHROPIC',
          inject: [ConfigService],
          useFactory: (c: ConfigService) =>
            new Anthropic({
              apiKey: c.getOrThrow('ANTHROPIC_API_KEY'),
              maxRetries: 4, // retries SDK (429 / overloaded) avec backoff
            }),
        },
      ],
      exports: ['ANTHROPIC'],
    };
  }
}
ts
// agent.resolver.ts — mutation déclenche, subscription streame
type AgentEvent =
  | { __typename: 'TokenDelta'; text: string }
  | { __typename: 'ToolCall'; name: string; status: 'running' | 'done' | 'error' }
  | { __typename: 'AgentDone'; stopReason: string }
  | { __typename: 'AgentError'; message: string };

@Resolver()
export class AgentResolver {
  constructor(
    @Inject('ANTHROPIC') private llm: Anthropic,
    @Inject('PUB_SUB') private pubsub: PubSub,
    private tools: ToolRegistry,
  ) {}

  // Idempotent : même runId rejoué => on ne relance pas la génération.
  @Mutation(() => String)
  async startAgentRun(
    @Args('input') input: AgentRunInput,
    @CurrentUser() user: AuthUser,
  ): Promise<string> {
    const runId = input.clientRunId ?? randomUUID(); // clé d'idempotence
    await this.guardCost(user.id); // rate-limit + budget par user au bord
    // On NE bloque PAS la mutation sur la génération : on enfile et on rend le runId.
    this.runAgent(runId, input, user).catch((e) =>
      this.pubsub.publish(`agent.${runId}`, {
        agentStream: { __typename: 'AgentError', message: String(e?.message ?? e) },
      }),
    );
    return runId;
  }

  @Subscription(() => AgentEvent, {
    filter: (p, v, ctx) => p.runId === v.runId && p.ownerId === ctx.user.id,
  })
  agentStream(@Args('runId') runId: string) {
    return this.pubsub.asyncIterableIterator(`agent.${runId}`);
  }

  // Boucle agentique côté serveur : stream tokens + exécution d'outils.
  private async runAgent(runId: string, input: AgentRunInput, user: AuthUser) {
    const ac = new AbortController();
    this.registerCancel(runId, ac); // un `stopAgentRun(runId)` => ac.abort()
    let messages: Anthropic.MessageParam[] = [{ role: 'user', content: input.prompt }];

    for (let turn = 0; turn < 8; turn++) {
      const stream = this.llm.messages.stream(
        {
          model: 'claude-sonnet-4-6',
          max_tokens: 1024,
          tools: this.tools.schemas(),
          messages,
        },
        { signal: ac.signal }, // déconnexion client => on coupe l'appel LLM
      );

      stream.on('text', (text) =>
        this.publish(runId, user.id, { __typename: 'TokenDelta', text }),
      );
      const msg = await stream.finalMessage();

      const toolUses = msg.content.filter((b) => b.type === 'tool_use');
      if (toolUses.length === 0) {
        this.publish(runId, user.id, {
          __typename: 'AgentDone',
          stopReason: msg.stop_reason ?? 'end_turn',
        });
        return;
      }

      // Exécute les outils, renvoie les résultats au modèle (tour suivant).
      const results: Anthropic.ToolResultBlockParam[] = [];
      for (const tu of toolUses) {
        this.publish(runId, user.id, { __typename: 'ToolCall', name: tu.name, status: 'running' });
        try {
          const out = await this.tools.run(tu.name, tu.input, ac.signal);
          this.publish(runId, user.id, { __typename: 'ToolCall', name: tu.name, status: 'done' });
          results.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(out) });
        } catch (e) {
          this.publish(runId, user.id, { __typename: 'ToolCall', name: tu.name, status: 'error' });
          results.push({ type: 'tool_result', tool_use_id: tu.id, is_error: true, content: String(e) });
        }
      }
      messages = [...messages, { role: 'assistant', content: msg.content }, { role: 'user', content: results }];
    }
  }

  private publish(runId: string, ownerId: string, agentStream: AgentEvent) {
    return this.pubsub.publish(`agent.${runId}`, { runId, ownerId, agentStream });
  }
}

Décisions de design que défend un staff ici :

  • Mutation non-bloquante + subscription plutôt qu'un champ qui await toute la génération : une query GraphQL qui prend 40 s tient une connexion HTTP ouverte, casse les timeouts de gateway, et ne streame rien. Le pattern runId rend la génération reprenable et annulable.
  • AbortController des deux côtés : la déconnexion du client WS (onComplete/onDisconnect) déclenche ac.abort() → on arrête de payer des tokens pour un client parti. Sans ça, un user qui ferme l'onglet te coûte de l'argent.
  • Idempotence par clientRunId : un retry réseau de la mutation ne relance pas une 2ᵉ génération (donc pas de double facturation). Si tu enfiles dans BullMQ, la jobId = runId te donne l'idempotence gratuitement, plus une retry policy cost-aware (ne re-tente pas un job qui a déjà brûlé 50 k tokens sans checkpoint).
  • Cost-guard au bord (guardCost) : rate-limit + budget par user avant d'appeler le modèle. La complexité GraphQL ne capture pas le coût LLM — un champ « pas cher » à parser peut déclencher une génération à 10 cents. Le garde-fou est métier, pas syntaxique.
  • Union discriminée AgentEvent (TokenDelta | ToolCall | AgentDone | AgentError) : le front sait exactement quel event il reçoit et rend une timeline d'outils typée. C'est le pendant serveur du « tool-call trace timeline » côté Angular.
  • MCP / endpoint agent : si tu exposes ces outils à d'autres agents, le ToolRegistry est aussi ton serveur MCP — mêmes schémas JSON, même couche d'autorisation, exposés via un transport dédié plutôt que par le graphe public.

Alternative SSE : si tu n'as pas besoin du bidirectionnel (le client n'envoie rien pendant la génération), un endpoint REST/SSE (text/event-stream) à côté du graphe est souvent plus simple à opérer que des subscriptions WS (pas de PubSub partagé, scaling HTTP standard, reconnexion Last-Event-ID native). Beaucoup d'équipes gardent GraphQL pour les données et SSE pour le stream LLM. Choisis WS/subscriptions seulement si tu veux aussi pousser des events non sollicités (collaboration multi-user sur la même run).

🏋️ Exercices ​

1. Détecter et tuer un N+1 (implement) ​

Objectif — Prouver le N+1 avant/après DataLoader avec une vraie métrique, pas à l'œil. Indice/Solution — Wrappe le repo dans un compteur (ctx.dbCalls++). Query users { posts { title } } sur 50 users sans loader → observe ~51 appels. Ajoute le DataLoader scope REQUEST → 2 appels. Assert dans un test d'intégration que ctx.dbCalls <= 2.

2. Borner un graphe hostile (production-grade) ​

Objectif — Rendre l'API publique résistante à une query abusive. Indice/Solution — Compose trois défenses : depthLimit(7) (validation rule), un plugin graphql-query-complexity (maximumComplexity: 1000, coût par champ via @Field({ complexity: 5 })), et un cap sur aliases/directives (un attaquant duplique le même champ via 1000 alias pour multiplier le coût sous un même nom). Teste avec une query récursive profonde : elle doit être rejetée avant d'atteindre un resolver.

3. Erreurs métier en union types (refactor) ​

Objectif — Remplacer les throw d'un mutation par un résultat typé exploitable par le client. Indice/Solution — union CreateObjectiveResult = Objective | NotManager | EmployeeNotFound. Le resolver retourne un des membres au lieu de throw new ForbiddenException(). Côté schéma, ajoute __resolveType. Vérifie que le client peut faire ... on NotManager { reason } sans inspecter errors[].

4. Subscriptions multi-instances (break it then fix it) ​

Objectif — Reproduire le bug « le subscriber ne reçoit rien » puis le corriger. Indice/Solution — Lance 2 pods derrière un LB. Connecte un client WS au pod A, publie via une mutation routée vers le pod B. Le client ne reçoit rien (PubSub in-memory). Remplace new PubSub() par graphql-redis-subscriptions (provider 'PUB_SUB', même interface) → les events traversent les pods. Bonus : vérifie le filtre filter() pour ne pas leak un event entre tenants.

5. Streaming agent annulable (AI, production-grade) ​

Objectif — Streamer une réponse LLM via subscription et l'annuler proprement à la déconnexion. Indice/Solution — Implémente startAgentRun (mutation → runId) + agentStream (subscription). Câble onComplete/onDisconnect du transport graphql-ws à ac.abort(). Vérifie avec un faux client qui se déconnecte au milieu : aucun token n'est généré après l'abort (logge stream.on('text') et compte). Ajoute l'idempotence : rejouer la même mutation avec le même clientRunId ne relance pas la génération.

6. Federation v2 — extraire un subgraph (architecture) ​

Objectif — Casser un monolithe GraphQL en deux subgraphs composés par Apollo Router. Indice/Solution — Marque Employee comme entity (@key(fields: "id")) dans le subgraph RH. Un subgraph « Feedback » étend Employee avec @extends + @external id et ajoute recentFeedback. Compose avec rover supergraph compose, sers via Apollo Router. Vérifie qu'une query qui touche les deux subgraphs fait bien un _entities resolve et que le N+1 inter-subgraph est batché (representations).

🎤 En entretien ​

« Pourquoi le N+1 est-il structurellement pire en GraphQL qu'en REST, et comment DataLoader le règle exactement ? » En REST tu contrôles la forme de la réponse côté serveur ; en GraphQL le client compose l'arbre, donc un champ relationnel s'exécute une fois par parent résolu. DataLoader interpose une couche qui batche (collecte tous les .load(id) du même tick d'event-loop en un seul findByIds) et mémoïse par requête — d'où le scope REQUEST obligatoire pour ne pas polluer le cache entre utilisateurs.

« Comment empêches-tu une query malveillante de tuer ton API publique ? » Trois couches indépendantes : depth limiting (graphe borné), query complexity/cost (budget par requête, coût par champ), et persisted queries (en prod, seuls des hash de queries précompilées côté client sont acceptés → l'attaquant ne peut pas envoyer de query arbitraire). On y ajoute introspection désactivée et rate-limit au bord. Aucune de ces couches seule ne suffit.

« Subscriptions ou SSE pour streamer une réponse LLM, et pourquoi ? » SSE si le flux est unidirectionnel (serveur → client) : plus simple à scaler (HTTP standard, reconnexion Last-Event-ID, pas de PubSub partagé). Subscriptions WS si tu as besoin de bidirectionnel ou de pousser des events non sollicités (collaboration multi-user). Dans les deux cas : AbortController serveur câblé sur la déconnexion client pour arrêter de payer des tokens, et idempotence par runId pour les retries.

« Federation v2 vs un BFF GraphQL — quand bascules-tu ? » BFF (un seul Nest qui agrège des backends) tant que tu as peu d'équipes : un seul cycle de release, simple à opérer. Tu passes à Federation v2 quand plusieurs équipes doivent posséder leur subgraph et releaser indépendamment : la composition (rover compose) et l'exécution (Apollo Router, Rust) découplent les schémas via les entities (@key). Le coût : une gateway de plus à opérer et une discipline de schéma partagée.

« Code-first ou schema-first, et qu'est-ce qui change vraiment à l'échelle ? » Code-first quand une équipe TS full-stack possède tout le stack : le refactor suit le compilateur, vélocité maximale. Schema-first quand le SDL est un contrat négocié entre équipes ou langages (review du .graphql en PR, codegen multi-langage, Federation). Le vrai risque des deux est le drift : code-first oublie sortSchema et le SDL généré bouge sans raison ; schema-first oublie de re-générer et les types TS mentent. Dans les deux cas la CI doit échouer si le schema.gql committé diffère.

« Pourquoi le cache de DataLoader ne remplace-t-il pas un cache applicatif, et où est le piège ? » DataLoader mémoïse par requête (scope REQUEST) : son but est de dédoublonner les .load(id) d'une même query, pas de servir le cache entre requêtes. Le piège classique est de le passer en singleton « pour cacher entre users » → tu sers la donnée de l'utilisateur A à l'utilisateur B, et tu sers du stale après une mutation. Pour du cache cross-request, c'est une couche séparée (Redis / responseCachePlugin Apollo avec @cacheControl), avec invalidation explicite sur mutation — jamais le DataLoader.

🔗 Liens ​

Bibliothèque tech perso — Achref