Use Cases (Mis à jour: 02/06/2026)

Développer GraphQL avec Claude Code : SDL, resolvers typés, DataLoader et tests

Guide GraphQL avec Claude Code : SDL, resolvers typés, N+1, autorisation, complexité, erreurs et tests.

Développer GraphQL avec Claude Code : SDL, resolvers typés, DataLoader et tests

Quand vous demandez à Claude Code de créer une API GraphQL, la première décision n’est pas la liste des types. Il faut d’abord décider où vit le contrat, où l’autorisation est appliquée, comment éviter N+1, comment refuser les opérations trop profondes ou trop coûteuses, et comment les erreurs et les tests prouvent que l’API est publiable. Un simple “mets ça en GraphQL” peut produire une démo fonctionnelle mais fragile.

Dans ce guide, Claude Code est utilisé comme partenaire d’implémentation. SDL signifie schema definition language, c’est le contrat texte du schéma GraphQL. Un resolver est la fonction qui retourne la valeur d’un champ. DataLoader est un outil de batching limité à une requête, utile pour regrouper les lectures répétées pendant l’exécution d’une opération.

Gardez les sources officielles ouvertes : GraphQL Schemas and Types, Pagination, Authorization, Security, le Response Format de la spécification, le DataLoader README, GraphQL Code Generator TypeScript Resolvers et les Claude Code docs. Pour continuer dans ClaudeCodeLab, lisez aussi développement API, tests API et techniques de prompt.

Choisir schema-first ou resolver-first

Schema-first veut dire écrire le SDL d’abord, le traiter comme contrat public, puis écrire resolvers et tests. Resolver-first veut dire partir de services existants, de fonctions TypeScript ou d’une couche de données, puis stabiliser le schéma. Avec Claude Code, schema-first est plus facile à relire pour une nouvelle API publique, mobile ou multi-équipes. Resolver-first convient mieux lorsqu’une logique métier existe déjà.

Trois cas sont fréquents. Dans un dashboard SaaS, les limites équipe, facturation et audit doivent être visibles avant le code. Dans une API mobile, GraphQL réduit les allers-retours, mais impose pagination, limites d’opération et erreurs stables. Dans un outil analytique interne, la liberté de requête est utile, mais les règles de tenant et de données sensibles restent dans la logique métier.

Donnez ce choix à Claude Code dès le départ.

Design this GraphQL API schema-first.
Requirements:
- Put SDL in src/graphql/schema.graphql
- Type TypeScript resolvers with GraphQL Code Generator
- Use cursor-based connections for list fields
- Keep authorization in repo/service functions, not scattered through resolvers
- Create DataLoader instances per request inside createContext
- Include depth and complexity validation, safe error formatting, and Vitest tests
- Report verification commands and remaining pitfalls after implementation

Le serveur choisi peut changer selon le projet. Ce qui ne doit pas changer, ce sont le contrat, l’autorisation, le contrôle de charge et les tests.

Placer le SDL au centre

Ce SDL définit une petite API de reviews produit. Il suit le modèle de connexion décrit dans la documentation officielle de pagination : edges, node, cursor, pageInfo. Un champ imbriqué comme Product.reviews est pratique, mais il crée aussi le point d’entrée du N+1 et des requêtes profondes.

npm install graphql @graphql-tools/schema dataloader
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers vitest tsx typescript
# src/graphql/schema.graphql
interface Node {
  id: ID!
}

type Product implements Node {
  id: ID!
  title: String!
  isPublic: Boolean!
  averageRating: Float
  reviews(first: Int = 10, after: String): ReviewConnection!
  createdAt: String!
}

type Review implements Node {
  id: ID!
  product: Product!
  rating: Int!
  body: String!
  authorId: ID!
  createdAt: String!
}

type ProductEdge {
  node: Product!
  cursor: String!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type ReviewEdge {
  node: Review!
  cursor: String!
}

type ReviewConnection {
  edges: [ReviewEdge!]!
  pageInfo: PageInfo!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

input CreateReviewInput {
  productId: ID!
  rating: Int!
  body: String!
}

type Query {
  product(id: ID!): Product
  products(first: Int = 10, after: String): ProductConnection!
}

type Mutation {
  createReview(input: CreateReviewInput!): Review!
}

Le typage GraphQL ne remplace pas toute validation. rating: Int! ne garantit pas une valeur entre 1 et 5. La longueur du texte, le HTML dangereux et la visibilité des produits privés doivent être vérifiés dans les services ou resolvers.

Générer des resolvers typés

Le raccourci dangereux en TypeScript consiste à utiliser any pour parent, args et context. C’est rapide au début, mais chaque changement de schéma devient risqué. Le plugin typescript-resolvers de GraphQL Code Generator génère les signatures depuis le SDL.

// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "src/graphql/schema.graphql",
  generates: {
    "src/graphql/generated/resolvers-types.ts": {
      plugins: ["typescript", "typescript-resolvers"],
      config: {
        contextType: "../context#GraphQLContext",
        mappers: {
          Product: "../models#ProductRow",
          Review: "../models#ReviewRow",
        },
        useIndexSignature: true,
      },
    },
  },
};

export default config;
{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "test:graphql": "npm run codegen && vitest run test/graphql.test.ts"
  }
}

mappers indique que le type GraphQL et la ligne de stockage ne sont pas identiques. Product expose reviews et averageRating, alors que ProductRow ne les contient pas forcément. Cette différence explicite aide Claude Code à ne pas inventer de champs dans le modèle de données.

Corriger N+1 avec DataLoader par requête

N+1 apparaît quand une liste provoque une lecture supplémentaire par ligne. Dix produits peuvent devenir dix requêtes de reviews. DataLoader regroupe les .load() d’une même requête et renvoie les valeurs dans l’ordre des clés. Son cache est par requête : il ne remplace pas Redis et ne doit pas être global.

// src/graphql/models.ts
export type Viewer = { id: string; role: "user" | "admin" } | null;

export type ProductRow = {
  id: string;
  title: string;
  ownerId: string;
  isPublic: boolean;
  createdAt: string;
};

export type ReviewRow = {
  id: string;
  productId: string;
  rating: number;
  body: string;
  authorId: string;
  createdAt: string;
};

const products: ProductRow[] = [
  { id: "prod_1", title: "Claude Code Playbook", ownerId: "user_1", isPublic: true, createdAt: "2026-06-02T00:00:00.000Z" },
  { id: "prod_private", title: "Internal Prompt Pack", ownerId: "user_2", isPublic: false, createdAt: "2026-06-02T00:00:00.000Z" },
];

const reviews: ReviewRow[] = [
  { id: "rev_1", productId: "prod_1", rating: 5, body: "Practical.", authorId: "user_1", createdAt: "2026-06-02T00:10:00.000Z" },
  { id: "rev_2", productId: "prod_1", rating: 4, body: "Useful for reviews.", authorId: "user_2", createdAt: "2026-06-02T00:20:00.000Z" },
];

function canSeeProduct(viewer: Viewer, product: ProductRow) {
  return product.isPublic || viewer?.role === "admin" || viewer?.id === product.ownerId;
}

export const repo = {
  productById(id: string, viewer: Viewer) {
    const product = products.find((item) => item.id === id);
    return product && canSeeProduct(viewer, product) ? product : null;
  },
  products(viewer: Viewer) {
    return products.filter((product) => canSeeProduct(viewer, product));
  },
  reviewsByProductIds(productIds: readonly string[], viewer: Viewer) {
    const visibleIds = new Set(
      products.filter((product) => productIds.includes(product.id) && canSeeProduct(viewer, product)).map((product) => product.id),
    );
    return reviews.filter((review) => visibleIds.has(review.productId));
  },
  createReview(input: { productId: string; rating: number; body: string }, viewer: NonNullable<Viewer>) {
    const product = products.find((item) => item.id === input.productId);
    if (!product || !canSeeProduct(viewer, product)) return null;
    if (input.rating < 1 || input.rating > 5) return null;

    const review: ReviewRow = {
      id: `rev_${reviews.length + 1}`,
      productId: input.productId,
      rating: input.rating,
      body: input.body.slice(0, 1000),
      authorId: viewer.id,
      createdAt: new Date().toISOString(),
    };
    reviews.push(review);
    return review;
  },
};
// src/graphql/context.ts
import { randomUUID } from "node:crypto";
import DataLoader from "dataloader";
import { repo, type ReviewRow, type Viewer } from "./models";

function createLoaders(viewer: Viewer) {
  return {
    reviewsByProductId: new DataLoader<string, ReviewRow[]>(async (productIds) => {
      const rows = repo.reviewsByProductIds(productIds, viewer);
      const grouped = new Map<string, ReviewRow[]>();

      for (const productId of productIds) grouped.set(productId, []);
      for (const review of rows) grouped.get(review.productId)?.push(review);

      return productIds.map((productId) => grouped.get(productId) ?? []);
    }),
  };
}

export type GraphQLContext = {
  viewer: Viewer;
  requestId: string;
  loaders: ReturnType<typeof createLoaders>;
};

export function createContext(options: { viewer?: Viewer; requestId?: string } = {}): GraphQLContext {
  const viewer = options.viewer ?? null;
  return {
    viewer,
    requestId: options.requestId ?? randomUUID(),
    loaders: createLoaders(viewer),
  };
}
// src/graphql/resolvers.ts
import { GraphQLError } from "graphql";
import type { Resolvers } from "./generated/resolvers-types";
import { repo, type ReviewRow } from "./models";

function encodeCursor(id: string) {
  return Buffer.from(id).toString("base64url");
}

function decodeCursor(cursor?: string | null) {
  if (!cursor) return null;
  try {
    return Buffer.from(cursor, "base64url").toString("utf8");
  } catch {
    return null;
  }
}

function connectionFromRows<T extends { id: string }>(rows: T[], args: { first?: number | null; after?: string | null }) {
  const first = Math.min(Math.max(args.first ?? 10, 1), 50);
  const afterId = decodeCursor(args.after);
  const startIndex = afterId ? rows.findIndex((row) => row.id === afterId) + 1 : 0;
  const slice = rows.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + first + 1);
  const page = slice.slice(0, first);

  return {
    edges: page.map((node) => ({ node, cursor: encodeCursor(node.id) })),
    pageInfo: {
      hasNextPage: slice.length > first,
      endCursor: page.length > 0 ? encodeCursor(page[page.length - 1].id) : null,
    },
  };
}

export const resolvers: Resolvers = {
  Node: {
    __resolveType(parent) {
      return "rating" in parent ? "Review" : "Product";
    },
  },
  Query: {
    product: (_parent, args, context) => repo.productById(args.id, context.viewer),
    products: (_parent, args, context) => connectionFromRows(repo.products(context.viewer), args),
  },
  Product: {
    reviews: async (product, args, context) => {
      const rows = await context.loaders.reviewsByProductId.load(product.id);
      return connectionFromRows(rows, args);
    },
    averageRating: async (product, _args, context) => {
      const rows = await context.loaders.reviewsByProductId.load(product.id);
      if (rows.length === 0) return null;
      return rows.reduce((sum: number, review: ReviewRow) => sum + review.rating, 0) / rows.length;
    },
  },
  Review: {
    product: (review, _args, context) => {
      const product = repo.productById(review.productId, context.viewer);
      if (!product) {
        throw new GraphQLError("Product is not available.", { extensions: { code: "FORBIDDEN" } });
      }
      return product;
    },
  },
  Mutation: {
    createReview: (_parent, args, context) => {
      if (!context.viewer) {
        throw new GraphQLError("Login required.", { extensions: { code: "UNAUTHENTICATED" } });
      }

      const review = repo.createReview(args.input, context.viewer);
      if (!review) {
        throw new GraphQLError("Review could not be created.", { extensions: { code: "BAD_USER_INPUT" } });
      }
      return review;
    },
  },
};

L’autorisation reste dans repo. La documentation GraphQL recommande de déléguer cette logique à la couche métier pour éviter les duplications de conditions dans chaque resolver.

Limiter profondeur et complexité

La flexibilité de GraphQL peut devenir coûteuse : nesting profond, first trop grand, nombreux alias, batch lourd. La page Security recommande trusted documents, pagination, depth limiting, breadth/batch limiting, rate limiting et query complexity analysis.

// src/graphql/demandControl.ts
import {
  GraphQLError,
  Kind,
  type ASTVisitor,
  type FieldNode,
  type FragmentDefinitionNode,
  type SelectionSetNode,
  type ValidationContext,
} from "graphql";

type Limits = {
  maxDepth: number;
  maxComplexity: number;
  defaultListSize: number;
  maxListSize: number;
};

const defaultLimits: Limits = {
  maxDepth: 6,
  maxComplexity: 300,
  defaultListSize: 10,
  maxListSize: 50,
};

export function depthAndComplexityRule(limits: Partial<Limits> = {}) {
  const merged = { ...defaultLimits, ...limits };

  return (context: ValidationContext): ASTVisitor => {
    const fragments = new Map<string, FragmentDefinitionNode>();

    return {
      FragmentDefinition(node) {
        fragments.set(node.name.value, node);
      },
      OperationDefinition(node) {
        const score = scoreSelectionSet(node.selectionSet, fragments, new Set(), 0, 1, merged);

        if (score.depth > merged.maxDepth) {
          context.reportError(new GraphQLError(`GraphQL operation is too deep: ${score.depth}`, { nodes: node }));
        }
        if (score.complexity > merged.maxComplexity) {
          context.reportError(
            new GraphQLError(`GraphQL operation is too complex: ${score.complexity}`, { nodes: node }),
          );
        }
      },
    };
  };
}

function listMultiplier(field: FieldNode, limits: Limits) {
  const arg = field.arguments?.find((item) => item.name.value === "first" || item.name.value === "last");
  if (!arg) return 1;
  if (arg.value.kind !== Kind.INT) return limits.defaultListSize;
  return Math.min(Number.parseInt(arg.value.value, 10), limits.maxListSize);
}

function scoreSelectionSet(
  selectionSet: SelectionSetNode,
  fragments: Map<string, FragmentDefinitionNode>,
  seenFragments: Set<string>,
  depth: number,
  multiplier: number,
  limits: Limits,
) {
  let maxDepth = depth;
  let complexity = 0;

  for (const selection of selectionSet.selections) {
    if (selection.kind === Kind.FIELD) {
      const nextMultiplier = multiplier * listMultiplier(selection, limits);
      complexity += nextMultiplier;

      if (selection.selectionSet) {
        const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth + 1, nextMultiplier, limits);
        maxDepth = Math.max(maxDepth, child.depth);
        complexity += child.complexity;
      }
    }

    if (selection.kind === Kind.INLINE_FRAGMENT) {
      const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth, multiplier, limits);
      maxDepth = Math.max(maxDepth, child.depth);
      complexity += child.complexity;
    }

    if (selection.kind === Kind.FRAGMENT_SPREAD) {
      const fragmentName = selection.name.value;
      if (seenFragments.has(fragmentName)) continue;
      const fragment = fragments.get(fragmentName);
      if (!fragment) continue;

      seenFragments.add(fragmentName);
      const child = scoreSelectionSet(fragment.selectionSet, fragments, seenFragments, depth, multiplier, limits);
      maxDepth = Math.max(maxDepth, child.depth);
      complexity += child.complexity;
    }
  }

  return { depth: maxDepth, complexity };
}

Cette règle lit les valeurs littérales de first et last. Les variables doivent encore être bornées dans les resolvers. Pour les clients internes, des trusted documents peuvent être plus stricts qu’un simple texte GraphQL envoyé en production.

Stabiliser erreurs et tests

Les réponses GraphQL tournent autour de data, errors et extensions. La spécification distingue les erreurs avant exécution et les erreurs de champ pendant l’exécution. En production, masquez les exceptions internes et retournez un extensions.code stable avec un requestId.

// src/graphql/schema.ts
import { readFileSync } from "node:fs";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { resolvers } from "./resolvers";

export const schema = makeExecutableSchema({
  typeDefs: readFileSync(new URL("./schema.graphql", import.meta.url), "utf8"),
  resolvers,
});
// src/graphql/execute.ts
import {
  GraphQLError,
  execute,
  parse,
  specifiedRules,
  validate,
  type ExecutionResult,
} from "graphql";
import { createContext, type GraphQLContext } from "./context";
import { depthAndComplexityRule } from "./demandControl";
import { schema } from "./schema";
import type { Viewer } from "./models";

type ExecuteOptions = {
  source: string;
  variables?: Record<string, unknown>;
  viewer?: Viewer;
  requestId?: string;
};

export async function executeGraphQL(options: ExecuteOptions): Promise<ExecutionResult> {
  const context = createContext({ viewer: options.viewer, requestId: options.requestId });
  const document = parse(options.source);
  const validationErrors = validate(schema, document, [
    ...specifiedRules,
    depthAndComplexityRule({ maxDepth: 6, maxComplexity: 300 }),
  ]);

  if (validationErrors.length > 0) {
    return { errors: validationErrors.map((error) => maskError(error, context)) };
  }

  const result = await execute({
    schema,
    document,
    variableValues: options.variables,
    contextValue: context,
  });

  return {
    data: result.data,
    errors: result.errors?.map((error) => maskError(error, context)),
  };
}

function maskError(error: GraphQLError, context: GraphQLContext) {
  const code = typeof error.extensions.code === "string" ? error.extensions.code : "INTERNAL_SERVER_ERROR";
  const message = process.env.NODE_ENV === "production" ? "GraphQL request failed." : error.message;

  return new GraphQLError(message, {
    nodes: error.nodes,
    path: error.path,
    originalError: error.originalError,
    extensions: {
      code,
      requestId: context.requestId,
    },
  });
}
// test/graphql.test.ts
import { describe, expect, it } from "vitest";
import { executeGraphQL } from "../src/graphql/execute";

const viewer = { id: "user_1", role: "user" as const };

describe("GraphQL API", () => {
  it("returns products with reviews through typed resolvers", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `
        query Products {
          products(first: 10) {
            edges {
              node {
                id
                title
                averageRating
                reviews(first: 5) {
                  edges { node { id rating body } }
                }
              }
            }
          }
        }
      `,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data?.products.edges[0].node.title).toBe("Claude Code Playbook");
  });

  it("does not expose a private product to another user", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `query PrivateProduct { product(id: "prod_private") { id title } }`,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data?.product).toBeNull();
  });

  it("requires login for mutations", async () => {
    const result = await executeGraphQL({
      source: `
        mutation CreateReview {
          createReview(input: { productId: "prod_1", rating: 5, body: "Great" }) { id }
        }
      `,
    });

    expect(result.errors?.[0].extensions?.code).toBe("UNAUTHENTICATED");
  });

  it("rejects overly deep operations before execution", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `
        query TooDeep {
          products(first: 50) {
            edges { node { reviews(first: 50) { edges { node { product { reviews(first: 50) { edges { node { id } } } } } } } } }
          }
        }
      `,
    });

    expect(result.errors?.[0].message).toContain("too");
  });
});

Testez aussi les mutations sans login, les données privées, les limites de pagination, les entrées invalides et les extensions.code.

Prompts sûrs pour Claude Code

Un prompt comme “sécurise GraphQL” est trop large. Donnez le périmètre, les règles et la définition de terminé.

Improve the GraphQL implementation in this repository.
Allowed files:
- src/graphql/**
- test/graphql.test.ts

Design rules:
- Schema-first; schema.graphql is the public contract
- Use GraphQL Code Generator's Resolvers type; do not add any `any` types
- Keep authorization in repo/service functions, not scattered across resolvers
- Create DataLoader instances inside createContext for each request
- Clamp list fields to first between 1 and 50 and return connection objects
- Add depth and complexity validation
- Shape errors as message, path, extensions.code, extensions.requestId
- Add Vitest coverage for happy path, unauthenticated mutation, private data, and overly deep queries

At the end, report changed files, commands run, remaining risks, and official docs to re-check.

En équipe, copiez ces règles dans CLAUDE.md best practices pour stabiliser les prochains changements.

Pièges avant publication

Un DataLoader global peut partager du cache privé entre utilisateurs. Une valeur par défaut SDL ne suffit pas pour limiter first. Une autorisation copiée dans plusieurs resolvers finit par diverger. Une limite de profondeur sans complexité laisse passer des requêtes peu profondes mais très larges. Des erreurs internes exposées en production révèlent tables, stack traces ou détails du schéma. Enfin, sans Codegen en CI, schéma et types resolver se séparent.

La qualité GraphQL protège aussi les revenus : contenus premium, templates, liens Gumroad, formulaires B2B et dashboards admin dépendent d’une autorisation fiable et d’un coût API prévisible. Commencez par la checklist gratuite, utilisez produits et templates pour des prompts réutilisables, et passez par formation ou conseil Claude Code pour concevoir schema, autorisation, tests et CI sur un vrai dépôt.

Résultat terrain

Quand Masa a testé ce flux sur une petite API de reviews, le vrai gain n’était pas le snippet DataLoader. Le gain venait du prompt initial : frontières d’autorisation, plafond de first, depth/complexity, forme d’erreur et noms de tests. Avant, Product.reviews était ajouté d’abord et N+1 apparaissait ensuite. Ici, schéma, resolvers, validation et tests avançaient ensemble, donc la revue était plus courte.

Résumé

Avec Claude Code, choisissez explicitement schema-first ou resolver-first, gardez le SDL comme contrat, générez des resolvers typés, créez DataLoader par requête, placez l’autorisation dans la logique métier et limitez profondeur et complexité. Ajoutez ensuite erreurs stables et tests des échecs. GraphQL est flexible, mais cette flexibilité a besoin de garde-fous avant la production.

#Claude Code #GraphQL #API development #TypeScript #DataLoader
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.