GraphQL dans NestJS
TL;DR —
@nestjs/graphqlte 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.
| Axe | Code-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 |
| Refactor | Renomme un champ → TS te suit partout | Edit SDL puis re-génère, drift possible |
| Découplage schéma/code | Faible (couplé aux décorateurs Nest) | Fort (le schéma vit indépendamment) |
| Schéma piloté par le front / contrat | Plus dur (le schéma émerge du back) | Naturel (schema-driven design, review du SDL en PR) |
| Outillage tiers (linters SDL, mocks) | Moins direct | Premier-classe |
| Risque #1 | Le SDL généré dérive si tu oublies sortSchema | Drift 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
// 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
// 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)
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)
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 unEventEmitteren mémoire. Il marche en dev et casse silencieusement dès le 2ᵉ pod (un client connecté au pod A ne voit jamais unpublishfait 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
- DataLoader par requête — scope
REQUEST, jamais singleton (sinon cache pollué entre users). Un loader = une route DB. - Pagination Relay —
Connection<T>avecedges,node,pageInfo,cursor. Standard, compatible Apollo Client cache. - Erreurs typées — au lieu de throw, retourne une union
type CreateUserResult = User | EmailAlreadyTaken | ValidationFailed. Le client gère sans deviner. - Persisted queries — en prod, accepte seulement des
queryId(hash) précompilés côté client. Tue 80 % des attaques d'introspection / queries malveillantes. - Complexity / depth limiting —
graphql-query-complexitycalcule un coût et rejettecost > 1000. Indispensable en API publique. - 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/graphqlv7, 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/graphqlv12+. Vérifie les typesGraphQLModuleOptions(plusieurs propriétés deprecated).
Library notes :
- Apollo Server 2 → 3 :
apollo-server-expressdeprecated, on passe à@apollo/server+ middleware Express. - Apollo 3 → 4 : retrait de
apollo-server-core, plugins refondus,formatErrorsignature 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. Utilisegraphql-redis-subscriptions.
⚠️ Pitfalls
- N+1 silencieux — c'est l'erreur n°1. Active
apollo-tracingou un plugin de logging par champ, regarde le nombre de requêtes DB par query GraphQL. Toujours DataLoader. - 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. - Query depth illimitée — un attaquant écrit
posts { author { posts { author { ... } } } }x 50. CPU mort. Limite à 7-10. - Erreur Apollo en chaîne — un throw dans un sub-resolver fait planter tout le champ parent (null). Gère explicitement, retourne
nullou union d'erreurs. - PubSub in-memory + 3 replicas — un subscriber connecté à instance A ne reçoit pas les events publiés sur B. Utilise Redis PubSub adapter.
@ResolveFieldqui refetch le parent — si tu refais unfindByIddu parent dans chaque field resolver, tu détruis la perf. Le@Parent()est déjà chargé.- Scalaires custom mal sérialisés —
Date,BigInt,JSON: déclare-les explicitement (GraphQLISODateTime,GraphQLJSON) sinon string brut côté client. - 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 :
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 :
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.
@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.
@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.
@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.
// 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;
}// 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));
});
}// 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}`);
}
}// 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.
// 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: truen'existe plus. Si tu le passes, Nest l'ignore silencieusement et tes subscriptions ne montent jamais. Le transport se déclare viasubscriptions['graphql-ws']. De même,contextreçoit une forme différente en HTTP ({ req, res }) et en WS ({ connectionParams, extra }) — ne suppose jamais quereqexiste 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 :
| Niveau | Mécanisme | Ce qu'il protège | Limite |
|---|---|---|---|
| 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é |
| Champ | guard + @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.
// 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.resolvespans). Tu vois immédiatement quel@ResolveFieldest 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.
// 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), jamaisnew Anthropic()dans un champ.
// 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'],
};
}
}// 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
awaittoute 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 patternrunIdrend la génération reprenable et annulable. AbortControllerdes deux côtés : la déconnexion du client WS (onComplete/onDisconnect) déclencheac.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, lajobId = runIdte 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
ToolRegistryest 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, reconnexionLast-Event-IDnative). 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
- Nest GraphQL quick start : https://docs.nestjs.com/graphql/quick-start
- Apollo Server : https://www.apollographql.com/docs/apollo-server/
- DataLoader : https://github.com/graphql/dataloader
- Apollo Federation : https://www.apollographql.com/docs/federation/
- graphql-query-complexity : https://github.com/slicknode/graphql-query-complexity
- Production Ready GraphQL — Marc-André Giroux
- The Guild GraphQL ecosystem : https://the-guild.dev/
- Mercurius : https://mercurius.dev/