ITSkillsCenter
Blog

GraphQL code-first avec NestJS 11 et Apollo Server

11 دقائق للقراءة

GraphQL en mode code-first reste l’approche la plus productive pour exposer une API consommée par un front Next.js ou React Native. Les types TypeScript écrits une seule fois servent à la fois pour la logique métier et pour le schéma SDL exposé aux clients, ce qui élimine la duplication et les dérives entre les deux. Ce tutoriel monte un module GraphQL complet sous NestJS 11.1 avec Apollo : entités décorées, resolvers typés, dataloaders pour résoudre le N+1, abonnements en temps réel via graphql-ws, et garde-fous d’autorisation.

📍 Article principal : NestJS 11 pour startup : architecture production 2026. Cette brique d’API consomme la persistance Prisma et la sécurité JWT décrites dans les autres tutoriels.

Prérequis

Étape 1 — Installer @nestjs/graphql et Apollo Server

L’écosystème NestJS 11 supporte trois drivers GraphQL : Apollo Server (référence), Mercurius (basé Fastify), et Yoga (alternative légère). Apollo reste le choix par défaut grâce à sa documentation, sa maturité, et son écosystème d’outils (Apollo Studio, federation, persisted queries). Pour un produit qui démarre, ne pas optimiser prématurément vers Mercurius si on n’a pas mesuré un goulot HTTP réel.

cd apps/api
pnpm add @nestjs/graphql @nestjs/apollo @apollo/server graphql graphql-ws ws

Le paquet graphql-ws est le transport WebSocket moderne pour les abonnements, qui a remplacé subscriptions-transport-ws obsolète depuis 2023. Le paquet ws est sa dépendance native côté serveur Node. Ces deux dépendances ne sont nécessaires que si on prévoit d’exposer des subscriptions ; pour une API purement query/mutation, on peut s’en passer.

Étape 2 — Configurer GraphQLModule en mode code-first

Le module se configure dans app.module.ts via GraphQLModule.forRoot avec le driver Apollo. L’option autoSchemaFile demande à Nest de générer le schéma SDL automatiquement à partir des classes décorées. Le fichier produit (schema.gql) est utile pour les outils tiers comme graphql-codegen côté client, mais ne devrait jamais être édité à la main.

// app.module.ts (extrait)
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: join(process.cwd(), 'apps/api/src/schema.gql'),
  sortSchema: true,
  playground: false,
  plugins: [ApolloServerPluginLandingPageLocalDefault()],
  context: ({ req }) => ({ req }),
  subscriptions: { 'graphql-ws': true },
})

L’option playground: false désactive l’ancien Playground GraphiQL au profit du nouveau Apollo Sandbox embarqué via le plugin LandingPageLocalDefault. Le contexte expose req aux resolvers, ce qui leur donne accès à l’utilisateur authentifié injecté par JwtAuthGuard. sortSchema: true stabilise l’ordre des types dans le SDL généré, ce qui rend les diffs git lisibles et évite les changements aléatoires en CI.

Étape 3 — Définir les types avec décorateurs

En code-first, chaque type GraphQL est une classe TypeScript décorée. Le décorateur @ObjectType() marque la classe comme exposable dans le schéma, et @Field() déclare chaque champ. Pour les enums, registerEnumType les rend disponibles côté GraphQL. Cette approche évite de maintenir deux représentations du même domaine.

// users/user.entity.ts
import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql';
import { Role } from '@prisma/client';

registerEnumType(Role, { name: 'Role' });

@ObjectType()
export class User {
  @Field(() => ID) id: string;
  @Field() email: string;
  @Field(() => Role) role: Role;
  @Field() createdAt: Date;
}

Le mot de passe est délibérément absent de @Field : aucun champ non décoré ne fuite dans le schéma exposé. Cette protection par opt-in est une qualité fondamentale du code-first comparé au schema-first où il faut écrire deux fois la liste des champs et risquer une fuite par oubli. Le type Role importé de @prisma/client garantit la cohérence entre la base et le schéma GraphQL — un nouveau rôle ajouté au schéma Prisma apparaît automatiquement dans le SDL.

Étape 4 — Écrire un resolver de query

Un resolver est un service Nest classique enrichi de décorateurs GraphQL. @Query expose une opération de lecture, @Mutation une opération d’écriture, @ResolveField calcule un champ dérivé. Les arguments sont déclarés via @Args avec le type TypeScript correspondant.

// users/users.resolver.ts
@Resolver(() => User)
export class UsersResolver {
  constructor(private prisma: PrismaService) {}

  @Query(() => User, { nullable: true })
  async user(@Args('id', { type: () => ID }) id: string) {
    return this.prisma.user.findUnique({ where: { id } });
  }

  @Query(() => [User])
  async users() {
    return this.prisma.user.findMany();
  }
}

Le resolver est enregistré comme provider du module UsersModule. Une fois lancé, l’application expose le endpoint /graphql et le sandbox Apollo permet de tester la query { user(id: "...") { email role } }. La query renvoie exactement les champs demandés — pas plus, pas moins. Cette propriété est ce qui rend GraphQL puissant pour les fronts qui composent leurs vues à partir de plusieurs ressources.

Étape 5 — Mutations et input types

Les mutations modifient l’état serveur. Pour structurer leurs paramètres, GraphQL impose des input types distincts des object types. Le décorateur @InputType() définit ces structures côté NestJS, et la validation s’enchaîne avec class-validator via la pipe globale ValidationPipe.

// users/dto/create-user.input.ts
@InputType()
export class CreateUserInput {
  @Field() @IsEmail() email: string;
  @Field() @MinLength(12) password: string;
  @Field(() => Role, { defaultValue: 'MEMBER' }) role: Role;
}

// users/users.resolver.ts
@Mutation(() => User)
async createUser(@Args('input') input: CreateUserInput) {
  return this.prisma.user.create({
    data: { ...input, password: await argon2.hash(input.password) },
  });
}

La pipe ValidationPipe appliquée globalement intercepte chaque input et applique les contraintes de class-validator. Un email invalide ou un mot de passe trop court renvoie une erreur GraphQL structurée que le client peut afficher au champ correspondant. Cette validation au plus tôt est ce qui distingue une API qui sert de barrière fiable d’une API qui propage des données malformées en base.

Étape 6 — Résoudre le problème N+1 avec DataLoader

Le pire piège GraphQL est le N+1. Une query qui demande 100 utilisateurs avec leurs commandes déclenche naïvement 1 + 100 requêtes SQL : une pour la liste, puis une par utilisateur pour ses commandes. La solution est DataLoader, qui collecte les IDs demandés dans la même tick d’event loop et les batche en une seule requête WHERE userId IN (...).

// users/users.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class OrdersByUserLoader {
  constructor(private prisma: PrismaService) {}
  loader = new DataLoader<string, Order[]>(async (userIds) => {
    const orders = await this.prisma.order.findMany({
      where: { userId: { in: userIds as string[] } },
    });
    return userIds.map((id) => orders.filter((o) => o.userId === id));
  });
}

Le scope REQUEST garantit qu’un loader ne mélange pas les données entre deux requêtes concurrentes — un point critique. Le ResolveField utilise ensuite this.loader.load(parent.id) au lieu d’un findMany direct. Sur la query qui demande 100 utilisateurs et leurs commandes, le nombre de requêtes SQL passe de 101 à 2. Cette optimisation est essentielle dès qu’une API GraphQL atteint des charges réelles.

Étape 7 — Subscriptions en temps réel avec graphql-ws

Les abonnements diffusent des événements aux clients connectés via WebSocket. Le pattern typique : un client s’abonne à orderCreated, l’API publie l’événement à chaque création de commande via un PubSub, et tous les abonnés reçoivent la mise à jour. graphql-subscriptions fournit l’implémentation in-memory pour démarrer ; en production multi-instances, on bascule sur graphql-redis-subscriptions qui utilise Redis Pub/Sub.

// orders/orders.resolver.ts
@Subscription(() => Order, { name: 'orderCreated' })
orderCreated() { return this.pubSub.asyncIterator('orderCreated'); }

@Mutation(() => Order)
async createOrder(@Args('input') input: CreateOrderInput) {
  const order = await this.prisma.order.create({ data: input });
  await this.pubSub.publish('orderCreated', { orderCreated: order });
  return order;
}

Côté client, l’abonnement consomme le flux via graphql-ws. L’authentification du WebSocket se fait dans le connectionParams du handshake initial, où le client envoie son JWT que NestJS valide via le contexte des subscriptions. Sans cette vérification, n’importe qui pourrait s’abonner à n’importe quel événement.

Étape 8 — Sécuriser les resolvers avec guards et directives

Les guards JWT et Casbin créés dans le tutoriel d’authentification fonctionnent identiquement sur les resolvers GraphQL. Le décorateur @UseGuards s’applique à un resolver entier ou à un endpoint particulier. Pour des contrôles plus fins, les directives GraphQL permettent de marquer des champs sensibles avec @Directive('@auth(role: ADMIN)') et de les filtrer côté serveur avant la sérialisation.

@Mutation(() => User)
@UseGuards(JwtAuthGuard, PoliciesGuard)
async deleteUser(@Args('id', { type: () => ID }) id: string) {
  return this.prisma.user.delete({ where: { id } });
}

Le guard PoliciesGuard récupère le rôle depuis l’utilisateur authentifié et interroge Casbin avec l’objet /users/:id et l’action delete. Si la politique refuse, GraphQL renvoie une erreur FORBIDDEN avec un code structuré. Cette uniformité entre REST et GraphQL est le bénéfice principal d’avoir extrait l’autorisation dans un service Casbin réutilisable.

Étape 9 — Persisted queries et limitation de complexité

Une API GraphQL publique sans garde-fous accepte n’importe quelle requête, ce qui ouvre deux risques : la requête malveillante qui demande dix niveaux de relations imbriquées et écroule la base, et la requête volumineuse qui sature la bande passante. Deux protections complémentaires se branchent en quelques lignes : un calcul de coût avec graphql-query-complexity qui rejette les requêtes au-delà d’un budget, et le pattern persisted queries qui n’autorise que les requêtes pré-enregistrées par hash.

// app.module.ts
GraphQLModule.forRoot({
  driver: ApolloDriver,
  validationRules: [ costAnalysis({ maximumCost: 1000 }) ],
  persistedQueries: { cache: 'bounded' },
})

Le coût attribué à chaque champ via une directive @cost(value: 5) permet de refléter le coût SQL réel — une jointure complexe coûte plus cher qu’une lecture de colonne. Côté client, Apollo Client supporte les persisted queries de manière transparente : la première fois, il envoie le texte complet ; ensuite, seul le hash voyage. Le bénéfice est double : sécurité et bande passante.

Erreurs fréquentes

Erreur Cause Solution
Schéma vide à la génération Resolvers non enregistrés dans un module Vérifier providers du module concerné
N+1 silencieux en prod ResolveField sans DataLoader Activer Apollo Trace + Loader request-scoped
Subscriptions perdues entre instances PubSub in-memory graphql-redis-subscriptions
Type Date sérialisé en string ISO Default GraphQLISODateTime Ne pas surcharger sauf besoin client
Erreurs 500 non typées côté client Filter Apollo absent ApolloError + extensions code

Le N+1 silencieux est le piège le plus fréquent. Sur Apollo Studio ou un proxy de tracing, une query qui passe de 200 ms à 1,2 seconde après la mise en ligne d’un nouveau ResolveField est presque toujours un N+1. La règle qui marche : tout ResolveField qui interroge la base passe par un DataLoader, sans exception. Le code review doit refuser le merge sinon.

Bonnes pratiques opérationnelles

Trois habitudes paient en production. Versionner le fichier schema.gql généré dans git permet de détecter en revue les ruptures de contrat — un champ obligatoire qui devient optionnel, un type retiré. Activer le tracing Apollo (variable APOLLO_INCLUDE_TRACES=true) expose la latence champ par champ, ce qui rend visible un ResolveField qui ralentit. Et déclarer un id de type ID systématiquement sur chaque type permet aux clients d’utiliser le cache normalisé d’Apollo Client sans configuration supplémentaire.

FAQ

Code-first ou schema-first ?
Le code-first gagne en productivité quand TypeScript est déjà la source de vérité côté backend. Le schema-first est préférable quand le schéma SDL est conçu en amont par une équipe d’API design et qu’il devient le contrat partagé entre frontend, backend et clients tiers. La majorité des startups Node.js partent en code-first.

Faut-il exposer REST et GraphQL en parallèle ?
C’est tout à fait viable et même recommandé pour beaucoup de produits. REST sert les intégrations partenaires et les webhooks (clients qui consomment du JSON simple). GraphQL sert le front interne. Les deux peuvent partager les mêmes services métier sans duplication grâce à l’injection de dépendances NestJS.

Comment gérer les uploads de fichiers ?
Ne pas utiliser graphql-upload qui ouvre des CVE et complique la sécurité. Préférer un endpoint REST POST /uploads avec URL signée S3 et exposer ensuite l’URL résultante dans GraphQL. Le tutoriel file upload S3 détaille cette approche.

Apollo Federation ou monolithe GraphQL ?
La fédération est un outil pour grosses entreprises avec dizaines de services. Pour une startup, un monolithe GraphQL exposant plusieurs domaines reste largement suffisant et évite la complexité opérationnelle de la fédération.

Tutoriels associés

Références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité