Avance 16 min de lecture · 3 336 mots

GraphQL : Schema design et resolvers optimisés

Estimated reading time: 17 minutes

Introduction

GraphQL a révolutionné la façon dont nous concevons des APIs en 2025, avec plus de 62% des nouvelles APIs adoptant GraphQL pour sa flexibilité et son efficacité. Contrairement à REST qui retourne des structures de données fixes, GraphQL permet aux clients de demander exactement les données dont ils ont besoin. Ce guide complet couvre la conception de schémas GraphQL performants, l’optimisation des resolvers et les patterns de production éprouvés.

Pourquoi GraphQL?

Comparaison REST vs GraphQL

Problème REST: Over-fetching

// REST: Récupère trop de données
GET /api/users/123
{
  "id": 123,
  "username": "jdupont",
  "email": "j.dupont@exemple.com",
  "bio": "...",
  "avatarurl": "...",
  "followerscount": 1234,
  "followingcount": 567,
  "posts": [...], // Non nécessaire
  "settings": {...}, // Non nécessaire
  "notifications": [...] // Non nécessaire
}

Solution GraphQL: Demander exactement ce dont on a besoin

query {
  user(id: 123) {
    id
    username
    avatarurl
  }
}

# Réponse
{
  "data": {
    "user": {
      "id": 123,
      "username": "jdupont",
      "avatarurl": "https://..."
    }
  }
}

Problème REST: Under-fetching (N+1 requests)

// REST: Nécessite plusieurs requêtes
GET /api/users/123
GET /api/users/123/posts
GET /api/posts/1/comments
GET /api/posts/2/comments
// ...

Solution GraphQL: Une seule requête

query {
  user(id: 123) {
    username
    posts {
      title
      comments {
        content
        author {
          username
        }
      }
    }
  }
}

Schema Design

1. Types de base

# schema.graphql

# Scalaires personnalisés
scalar DateTime
scalar Email
scalar URL
scalar JSON

# Énumérations
enum UserRole {
  ADMIN
  MODERATOR
  USER
  GUEST
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

# Types d'objet
type User {
  id: ID!
  username: String!
  email: Email!
  role: UserRole!
  avatar: URL
  bio: String
  createdAt: DateTime!

  # Relations
  posts(
    first: Int = 10
    offset: Int = 0
    status: PostStatus
  ): PostConnection!

  followers(first: Int = 10): UserConnection!
  following(first: Int = 10): UserConnection!

  # Champs calculés
  postsCount: Int!
  followersCount: Int!
  isFollowing: Boolean!
}

type Post {
  id: ID!
  title: String!
  slug: String!
  content: String!
  excerpt: String
  status: PostStatus!
  publishedAt: DateTime
  createdAt: DateTime!
  updatedAt: DateTime!

  # Relations
  author: User!
  comments(first: Int = 10): CommentConnection!
  tags: [Tag!]!

  # Champs calculés
  commentsCount: Int!
  likesCount: Int!
  viewsCount: Int!
  readingTime: Int!
  isLiked: Boolean!
}

type Comment {
  id: ID!
  content: String!
  createdAt: DateTime!
  updatedAt: DateTime!

  author: User!
  post: Post!
  parent: Comment
  replies(first: Int = 10): CommentConnection!

  repliesCount: Int!
}

type Tag {
  id: ID!
  name: String!
  slug: String!

  posts(first: Int = 10): PostConnection!
  postsCount: Int!
}

# Pagination avec Relay Cursor Connections
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type CommentConnection {
  edges: [CommentEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type CommentEdge {
  node: Comment!
  cursor: String!
}

# Input types pour les mutations
input CreatePostInput {
  title: String!
  content: String!
  excerpt: String
  status: PostStatus = DRAFT
  tagIds: [ID!]
}

input UpdatePostInput {
  title: String
  content: String
  excerpt: String
  status: PostStatus
  tagIds: [ID!]
}

input CreateCommentInput {
  postId: ID!
  content: String!
  parentId: ID
}

# Types de résultat pour les mutations
type CreatePostPayload {
  post: Post
  errors: [Error!]
}

type UpdatePostPayload {
  post: Post
  errors: [Error!]
}

type DeletePostPayload {
  success: Boolean!
  errors: [Error!]
}

type Error {
  field: String
  message: String!
}

# Query root
type Query {
  # Utilisateurs
  user(id: ID!): User
  users(
    first: Int = 10
    offset: Int = 0
    role: UserRole
    search: String
  ): UserConnection!

  me: User

  # Posts
  post(id: ID, slug: String): Post
  posts(
    first: Int = 10
    offset: Int = 0
    status: PostStatus
    authorId: ID
    tagId: ID
    search: String
  ): PostConnection!

  # Tags
  tag(id: ID, slug: String): Tag
  tags(first: Int = 50): [Tag!]!

  # Recherche
  search(
    query: String!
    first: Int = 10
    types: [SearchType!]
  ): SearchResult!
}

enum SearchType {
  USER
  POST
  TAG
}

union SearchResult = User | Post | Tag

# Mutation root
type Mutation {
  # Authentification
  register(
    username: String!
    email: Email!
    password: String!
  ): AuthPayload!

  login(
    email: Email!
    password: String!
  ): AuthPayload!

  logout: Boolean!

  # Posts
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!

  # Comments
  createComment(input: CreateCommentInput!): Comment!
  deleteComment(id: ID!): Boolean!

  # Interactions
  likePost(postId: ID!): Post!
  unlikePost(postId: ID!): Post!
  followUser(userId: ID!): User!
  unfollowUser(userId: ID!): User!
}

type AuthPayload {
  token: String
  user: User
  errors: [Error!]
}

# Subscription root
type Subscription {
  postCreated(authorId: ID): Post!
  commentAdded(postId: ID!): Comment!
  userFollowed(userId: ID!): User!
}

2. Implémentation avec Apollo Server (Node.js)

// Installation
// npm install apollo-server graphql dataloader

const { ApolloServer } = require('apollo-server');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { readFileSync } = require('fs');
const DataLoader = require('dataloader');

// Charger le schéma
const typeDefs = readFileSync('./schema.graphql', 'utf-8');

// Resolvers
const resolvers = {
  // Scalaires personnalisés
  DateTime: require('./scalars/DateTime'),
  Email: require('./scalars/Email'),
  URL: require('./scalars/URL'),
  JSON: require('./scalars/JSON'),

  // Query resolvers
  Query: {
    user: async (, { id }, context) => {
      return context.loaders.user.load(id);
    },

    users: async (, { first, offset, role, search }, context) => {
      const { users, total } = await context.dataSources.userAPI.getUsers({
        limit: first,
        offset,
        role,
        search
      });

      return {
        edges: users.map(user => ({
          node: user,
          cursor: Buffer.from(user:${user.id}).toString('base64')
        })),
        pageInfo: {
          hasNextPage: offset + first < total,
          hasPreviousPage: offset > 0,
          startCursor: users.length > 0
            ? Buffer.from(user:${users[0].id}).toString('base64')
            : null,
          endCursor: users.length > 0
            ? Buffer.from(user:${users[users.length - 1].id}).toString('base64')
            : null
        },
        totalCount: total
      };
    },

    me: async (, , context) => {
      if (!context.user) {
        throw new Error('Not authenticated');
      }
      return context.loaders.user.load(context.user.id);
    },

    post: async (, { id, slug }, context) => {
      if (id) {
        return context.loaders.post.load(id);
      }
      return context.dataSources.postAPI.getPostBySlug(slug);
    },

    posts: async (, args, context) => {
      const { posts, total } = await context.dataSources.postAPI.getPosts(args);

      return {
        edges: posts.map(post => ({
          node: post,
          cursor: Buffer.from(post:${post.id}).toString('base64')
        })),
        pageInfo: {
          hasNextPage: args.offset + args.first < total,
          hasPreviousPage: args.offset > 0,
          startCursor: posts.length > 0
            ? Buffer.from(post:${posts[0].id}).toString('base64')
            : null,
          endCursor: posts.length > 0
            ? Buffer.from(post:${posts[posts.length - 1].id}).toString('base64')
            : null
        },
        totalCount: total
      };
    },

    search: async (, { query, first, types }, context) => {
      return context.dataSources.searchAPI.search(query, first, types);
    }
  },

  // Mutation resolvers
  Mutation: {
    register: async (, { username, email, password }, context) => {
      try {
        const user = await context.dataSources.authAPI.register({
          username,
          email,
          password
        });

        const token = context.dataSources.authAPI.generateToken(user);

        return {
          token,
          user,
          errors: []
        };
      } catch (error) {
        return {
          token: null,
          user: null,
          errors: [{ message: error.message }]
        };
      }
    },

    login: async (, { email, password }, context) => {
      try {
        const user = await context.dataSources.authAPI.login(email, password);
        const token = context.dataSources.authAPI.generateToken(user);

        return {
          token,
          user,
          errors: []
        };
      } catch (error) {
        return {
          token: null,
          user: null,
          errors: [{ message: error.message }]
        };
      }
    },

    createPost: async (, { input }, context) => {
      if (!context.user) {
        return {
          post: null,
          errors: [{ message: 'Not authenticated' }]
        };
      }

      try {
        const post = await context.dataSources.postAPI.createPost({
          ...input,
          authorId: context.user.id
        });

        // Publier pour les subscriptions
        context.pubsub.publish('POSTCREATED', { postCreated: post });

        return {
          post,
          errors: []
        };
      } catch (error) {
        return {
          post: null,
          errors: [{ message: error.message }]
        };
      }
    },

    updatePost: async (, { id, input }, context) => {
      if (!context.user) {
        return {
          post: null,
          errors: [{ message: 'Not authenticated' }]
        };
      }

      try {
        const post = await context.loaders.post.load(id);

        if (post.authorId !== context.user.id && context.user.role !== 'ADMIN') {
          return {
            post: null,
            errors: [{ message: 'Not authorized' }]
          };
        }

        const updatedPost = await context.dataSources.postAPI.updatePost(id, input);

        // Invalider le cache
        context.loaders.post.clear(id);

        return {
          post: updatedPost,
          errors: []
        };
      } catch (error) {
        return {
          post: null,
          errors: [{ message: error.message }]
        };
      }
    },

    deletePost: async (, { id }, context) => {
      if (!context.user) {
        return {
          success: false,
          errors: [{ message: 'Not authenticated' }]
        };
      }

      try {
        const post = await context.loaders.post.load(id);

        if (post.authorId !== context.user.id && context.user.role !== 'ADMIN') {
          return {
            success: false,
            errors: [{ message: 'Not authorized' }]
          };
        }

        await context.dataSources.postAPI.deletePost(id);
        context.loaders.post.clear(id);

        return {
          success: true,
          errors: []
        };
      } catch (error) {
        return {
          success: false,
          errors: [{ message: error.message }]
        };
      }
    },

    likePost: async (, { postId }, context) => {
      if (!context.user) {
        throw new Error('Not authenticated');
      }

      await context.dataSources.postAPI.likePost(postId, context.user.id);
      context.loaders.post.clear(postId);

      return context.loaders.post.load(postId);
    }
  },

  // Type resolvers
  User: {
    posts: async (user, args, context) => {
      const { posts, total } = await context.dataSources.postAPI.getPosts({
        ...args,
        authorId: user.id
      });

      return {
        edges: posts.map(post => ({
          node: post,
          cursor: Buffer.from(post:${post.id}).toString('base64')
        })),
        pageInfo: {
          hasNextPage: args.offset + args.first < total,
          hasPreviousPage: args.offset > 0
        },
        totalCount: total
      };
    },

    followers: async (user, args, context) => {
      return context.dataSources.userAPI.getFollowers(user.id, args);
    },

    following: async (user, args, context) => {
      return context.dataSources.userAPI.getFollowing(user.id, args);
    },

    postsCount: async (user, , context) => {
      return context.loaders.userPostsCount.load(user.id);
    },

    followersCount: async (user, , context) => {
      return context.loaders.userFollowersCount.load(user.id);
    },

    isFollowing: async (user, , context) => {
      if (!context.user) return false;

      return context.loaders.isFollowing.load({
        userId: context.user.id,
        followingId: user.id
      });
    }
  },

  Post: {
    author: async (post, , context) => {
      return context.loaders.user.load(post.authorId);
    },

    comments: async (post, args, context) => {
      return context.dataSources.commentAPI.getComments({
        ...args,
        postId: post.id
      });
    },

    tags: async (post, , context) => {
      return context.loaders.postTags.load(post.id);
    },

    commentsCount: async (post, , context) => {
      return context.loaders.postCommentsCount.load(post.id);
    },

    likesCount: async (post, , context) => {
      return context.loaders.postLikesCount.load(post.id);
    },

    viewsCount: async (post, , context) => {
      return context.loaders.postViewsCount.load(post.id);
    },

    readingTime: (post) => {
      // Calculer le temps de lecture (mots / 200 mots par minute)
      const words = post.content.split(/s+/).length;
      return Math.ceil(words / 200);
    },

    isLiked: async (post, , context) => {
      if (!context.user) return false;

      return context.loaders.postIsLiked.load({
        postId: post.id,
        userId: context.user.id
      });
    }
  },

  Comment: {
    author: async (comment, , context) => {
      return context.loaders.user.load(comment.authorId);
    },

    post: async (comment, , context) => {
      return context.loaders.post.load(comment.postId);
    },

    parent: async (comment, , context) => {
      if (!comment.parentId) return null;
      return context.loaders.comment.load(comment.parentId);
    },

    replies: async (comment, args, context) => {
      return context.dataSources.commentAPI.getComments({
        ...args,
        parentId: comment.id
      });
    },

    repliesCount: async (comment, , context) => {
      return context.loaders.commentRepliesCount.load(comment.id);
    }
  },

  Tag: {
    posts: async (tag, args, context) => {
      return context.dataSources.postAPI.getPosts({
        ...args,
        tagId: tag.id
      });
    },

    postsCount: async (tag, , context) => {
      return context.loaders.tagPostsCount.load(tag.id);
    }
  },

  // Union resolvers
  SearchResult: {
    resolveType(obj) {
      if (obj.username) return 'User';
      if (obj.title) return 'Post';
      if (obj.slug && !obj.title) return 'Tag';
      return null;
    }
  },

  // Subscription resolvers
  Subscription: {
    postCreated: {
      subscribe: (, { authorId }, context) => {
        if (authorId) {
          return context.pubsub.asyncIterator([POSTCREATED${authorId}]);
        }
        return context.pubsub.asyncIterator(['POSTCREATED']);
      }
    },

    commentAdded: {
      subscribe: (, { postId }, context) => {
        return context.pubsub.asyncIterator([COMMENTADDED${postId}]);
      }
    }
  }
};

module.exports = { typeDefs, resolvers };

3. DataLoader pour résoudre le problème N+1

// loaders/index.js
const DataLoader = require('dataloader');

// Batch function pour charger plusieurs utilisateurs
const batchUsers = async (ids, { db }) => {
  const users = await db.users.findAll({
    where: { id: ids }
  });

  // Maintenir l'ordre des IDs
  const userMap = new Map(users.map(u => [u.id, u]));
  return ids.map(id => userMap.get(id) || null);
};

// Batch function pour charger plusieurs posts
const batchPosts = async (ids, { db }) => {
  const posts = await db.posts.findAll({
    where: { id: ids }
  });

  const postMap = new Map(posts.map(p => [p.id, p]));
  return ids.map(id => postMap.get(id) || null);
};

// Batch function pour charger les tags d'un post
const batchPostTags = async (postIds, { db }) => {
  const postTags = await db.query(`
    SELECT pt.postid, t.
    FROM posttags pt
    JOIN tags t ON t.id = pt.tagid
    WHERE pt.postid IN (?)
  `, [postIds]);

  // Grouper par postid
  const tagsByPost = new Map();
  postIds.forEach(id => tagsByPost.set(id, []));

  postTags.forEach(({ postid, ...tag }) => {
    tagsByPost.get(postid).push(tag);
  });

  return postIds.map(id => tagsByPost.get(id));
};

// Batch function pour compter
const batchPostsCount = async (userIds, { db }) => {
  const counts = await db.query(`
    SELECT authorid, COUNT() as count
    FROM posts
    WHERE authorid IN (?)
    GROUP BY authorid
  `, [userIds]);

  const countMap = new Map(counts.map(c => [c.authorid, c.count]));
  return userIds.map(id => countMap.get(id) || 0);
};

const batchFollowersCount = async (userIds, { db }) => {
  const counts = await db.query(`
    SELECT followingid, COUNT(*) as count
    FROM userfollows
    WHERE followingid IN (?)
    GROUP BY followingid
  `, [userIds]);

  const countMap = new Map(counts.map(c => [c.followingid, c.count]));
  return userIds.map(id => countMap.get(id) || 0);
};

const batchIsFollowing = async (pairs, { db }) => {
  // pairs = [{ userId, followingId }, ...]
  const conditions = pairs.map(p =>
    (userid = ${p.userId} AND followingid = ${p.followingId})
  ).join(' OR ');

  const follows = await db.query(`
    SELECT userid, followingid
    FROM userfollows
    WHERE ${conditions}
  `);

  const followMap = new Set(
    follows.map(f => ${f.userid}:${f.followingid})
  );

  return pairs.map(p =>
    followMap.has(${p.userId}:${p.followingId})
  );
};

// Créer les loaders
const createLoaders = (context) => ({
  user: new DataLoader(ids => batchUsers(ids, context), {
    cacheKeyFn: (key) => key.toString()
  }),

  post: new DataLoader(ids => batchPosts(ids, context), {
    cacheKeyFn: (key) => key.toString()
  }),

  comment: new DataLoader(ids => batchComments(ids, context)),

  postTags: new DataLoader(ids => batchPostTags(ids, context)),

  userPostsCount: new DataLoader(ids => batchPostsCount(ids, context)),

  userFollowersCount: new DataLoader(ids => batchFollowersCount(ids, context)),

  postCommentsCount: new DataLoader(ids => batchCommentsCount(ids, context)),

  postLikesCount: new DataLoader(ids => batchLikesCount(ids, context)),

  postViewsCount: new DataLoader(ids => batchViewsCount(ids, context)),

  isFollowing: new DataLoader(pairs => batchIsFollowing(pairs, context), {
    cacheKeyFn: (pair) => ${pair.userId}:${pair.followingId}
  }),

  postIsLiked: new DataLoader(pairs => batchPostIsLiked(pairs, context), {
    cacheKeyFn: (pair) => ${pair.postId}:${pair.userId}
  })
});

module.exports = createLoaders;

4. Configuration du serveur Apollo

// server.js
const { ApolloServer } = require('apollo-server-express');
const { createServer } = require('http');
const express = require('express');
const { execute, subscribe } = require('graphql');
const { SubscriptionServer } = require('subscriptions-transport-ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PubSub } = require('graphql-subscriptions');
const jwt = require('jsonwebtoken');

const { typeDefs, resolvers } = require('./schema');
const createLoaders = require('./loaders');
const dataSources = require('./dataSources');
const db = require('./config/database');

const pubsub = new PubSub();

// Créer le schéma
const schema = makeExecutableSchema({ typeDefs, resolvers });

// Context function
const context = async ({ req, connection }) => {
  // Pour les WebSocket (subscriptions)
  if (connection) {
    return {
      ...connection.context,
      pubsub
    };
  }

  // Pour les requêtes HTTP
  let user = null;

  const token = req.headers.authorization?.replace('Bearer ', '');
  if (token) {
    try {
      const decoded = jwt.verify(token, process.env.JWTSECRET);
      user = await db.users.findByPk(decoded.sub);
    } catch (error) {
      console.error('Token verification failed:', error);
    }
  }

  return {
    user,
    db,
    loaders: createLoaders({ db, user }),
    dataSources: dataSources({ db }),
    pubsub
  };
};

// Créer l'application Express
const app = express();

// Créer Apollo Server
const apolloServer = new ApolloServer({
  schema,
  context,
  dataSources,
  formatError: (error) => {
    console.error('GraphQL Error:', error);

    return {
      message: error.message,
      code: error.extensions?.code,
      path: error.path
    };
  },
  plugins: [
    {
      async requestDidStart() {
        const start = Date.now();

        return {
          async willSendResponse({ response }) {
            const duration = Date.now() - start;
            console.log(Query executed in ${duration}ms);

            // Ajouter des métriques
            if (duration > 1000) {
              console.warn('Slow query detected:', duration);
            }
          }
        };
      }
    }
  ],
  introspection: process.env.NODEENV !== 'production',
  playground: process.env.NODEENV !== 'production'
});

// Appliquer le middleware Apollo
await apolloServer.start();
apolloServer.applyMiddleware({ app, path: '/graphql' });

// Créer le serveur HTTP
const httpServer = createServer(app);

// Configuration des subscriptions
SubscriptionServer.create(
  {
    schema,
    execute,
    subscribe,
    onConnect: async (connectionParams) => {
      const token = connectionParams.authorization?.replace('Bearer ', '');

      if (token) {
        try {
          const decoded = jwt.verify(token, process.env.JWTSECRET);
          const user = await db.users.findByPk(decoded.sub);

          return { user };
        } catch (error) {
          throw new Error('Invalid token');
        }
      }

      return {};
    },
    onDisconnect: () => {
      console.log('Client disconnected');
    }
  },
  {
    server: httpServer,
    path: '/graphql'
  }
);

const PORT = process.env.PORT || 4000;

httpServer.listen(PORT, () => {
  console.log(🚀 Server ready at http://localhost:${PORT}${apolloServer.graphqlPath});
  console.log(🚀 Subscriptions ready at ws://localhost:${PORT}${apolloServer.graphqlPath});
});

Optimisations avancées

1. Query Complexity Analysis

// plugins/queryComplexity.js
const { getComplexity, simpleEstimator, fieldExtensionsEstimator } = require('graphql-query-complexity');

const queryComplexityPlugin = {
  requestDidStart: () => ({
    didResolveOperation({ request, document, schema }) {
      const complexity = getComplexity({
        schema,
        operationName: request.operationName,
        query: document,
        variables: request.variables,
        estimators: [
          fieldExtensionsEstimator(),
          simpleEstimator({ defaultComplexity: 1 })
        ]
      });

      const maxComplexity = 1000;

      if (complexity > maxComplexity) {
        throw new Error(
          Query is too complex: ${complexity}. Maximum allowed complexity: ${maxComplexity}
        );
      }

      console.log('Query Complexity:', complexity);
    }
  })
};

// Dans le schéma, définir la complexité
const typeDefs = `
  type Query {
    posts(first: Int = 10): [Post!]! @complexity(value: 10, multipliers: ["first"])
    user(id: ID!): User @complexity(value: 1)
  }

  type User {
    posts: [Post!]! @complexity(value: 10)
  }
`;

2. Persisted Queries

// Côté client: générer un hash de la query
const crypto = require('crypto');

const query = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      username
      email
    }
  }
`;

const queryHash = crypto
  .createHash('sha256')
  .update(query)
  .digest('hex');

// Envoyer uniquement le hash
fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    extensions: {
      persistedQuery: {
        version: 1,
        sha256Hash: queryHash
      }
    },
    variables: { id: '123' }
  })
});

// Côté serveur
const { ApolloServerPluginPersistedQueries } = require('apollo-server-core');
const { createHash } = require('crypto');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginPersistedQueries({
      cache: new Map(), // ou Redis en production
      generateHash: ({ documentString }) => {
        return createHash('sha256').update(documentString).digest('hex');
      }
    })
  ]
});

3. Caching avec Redis

// plugins/caching.js
const Redis = require('ioredis');
const { createHash } = require('crypto');

const redis = new Redis();

const cachePlugin = {
  requestDidStart: () => ({
    async willSendResponse({ request, response, context }) {
      // Ne cacher que les queries (pas les mutations)
      if (request.operationName && !request.query.includes('mutation')) {
        const cacheKey = createHash('sha256')
          .update(JSON.stringify({
            query: request.query,
            variables: request.variables
          }))
          .digest('hex');

        await redis.setex(
          graphql:${cacheKey},
          300, // 5 minutes
          JSON.stringify(response.data)
        );
      }
    },

    async executionDidStart({ request, context }) {
      if (request.operationName && !request.query.includes('mutation')) {
        const cacheKey = createHash('sha256')
          .update(JSON.stringify({
            query: request.query,
            variables: request.variables
          }))
          .digest('hex');

        const cached = await redis.get(graphql:${cacheKey});

        if (cached) {
          return {
            data: JSON.parse(cached)
          };
        }
      }
    }
  })
};

Tests

// tests/graphql.test.js
const { createTestClient } = require('apollo-server-testing');
const { ApolloServer } = require('apollo-server');
const { typeDefs, resolvers } = require('../schema');

describe('GraphQL API', () => {
  let server;
  let query, mutate;

  beforeAll(() => {
    server = new ApolloServer({
      typeDefs,
      resolvers,
      context: () => ({
        user: { id: 1, role: 'ADMIN' },
        db: mockDatabase,
        loaders: createLoaders({ db: mockDatabase })
      })
    });

    const testClient = createTestClient(server);
    query = testClient.query;
    mutate = testClient.mutate;
  });

  describe('Queries', () => {
    it('devrait récupérer un utilisateur', async () => {
      const GETUSER = `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            username
            email
          }
        }
      `;

      const { data, errors } = await query({
        query: GETUSER,
        variables: { id: '1' }
      });

      expect(errors).toBeUndefined();
      expect(data.user).toMatchObject({
        id: '1',
        username: expect.any(String),
        email: expect.any(String)
      });
    });

    it('devrait récupérer des posts avec pagination', async () => {
      const GETPOSTS = `
        query GetPosts($first: Int!, $offset: Int!) {
          posts(first: $first, offset: $offset) {
            edges {
              node {
                id
                title
                author {
                  username
                }
              }
            }
            pageInfo {
              hasNextPage
              hasPreviousPage
            }
            totalCount
          }
        }
      `;

      const { data } = await query({
        query: GETPOSTS,
        variables: { first: 10, offset: 0 }
      });

      expect(data.posts.edges).toHaveLength(10);
      expect(data.posts.totalCount).toBeGreaterThan(0);
      expect(data.posts.pageInfo.hasNextPage).toBeDefined();
    });
  });

  describe('Mutations', () => {
    it('devrait créer un post', async () => {
      const CREATEPOST = `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) {
            post {
              id
              title
              content
            }
            errors {
              message
            }
          }
        }
      `;

      const { data } = await mutate({
        mutation: CREATEPOST,
        variables: {
          input: {
            title: 'Test Post',
            content: 'Test content'
          }
        }
      });

      expect(data.createPost.post).toMatchObject({
        title: 'Test Post',
        content: 'Test content'
      });
      expect(data.createPost.errors).toHaveLength(0);
    });
  });

  describe('Authorization', () => {
    it('devrait rejeter sans authentification', async () => {
      const serverNoAuth = new ApolloServer({
        typeDefs,
        resolvers,
        context: () => ({ user: null })
      });

      const { query } = createTestClient(serverNoAuth);

      const GETME = `
        query {
          me {
            id
          }
        }
      `;

      const { errors } = await query({ query: GETME });

      expect(errors).toBeDefined();
      expect(errors[0].message).toContain('Not authenticated');
    });
  });
});

Client GraphQL (React + Apollo Client)

// Installation: npm install @apollo/client graphql

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
  useMutation,
  gql
} from '@apollo/client';

// Configuration du client
const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            keyArgs: ['status', 'authorId'],
            merge(existing = { edges: [] }, incoming) {
              return {
                ...incoming,
                edges: [...existing.edges, ...incoming.edges]
              };
            }
          }
        }
      }
    }
  }),
  headers: {
    authorization: Bearer ${localStorage.getItem('token')}
  }
});

// Queries
const GETPOSTS = gql`
  query GetPosts($first: Int!, $offset: Int!) {
    posts(first: $first, offset: $offset) {
      edges {
        node {
          id
          title
          excerpt
          author {
            username
            avatar
          }
          commentsCount
          likesCount
        }
      }
      pageInfo {
        hasNextPage
      }
      totalCount
    }
  }
`;

const CREATEPOST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      post {
        id
        title
        content
      }
      errors {
        field
        message
      }
    }
  }
`;

// Composant React
function PostList() {
  const { loading, error, data, fetchMore } = useQuery(GETPOSTS, {
    variables: { first: 10, offset: 0 }
  });

  if (loading) return 
Loading...
; if (error) return
Error: {error.message}
; return (
{data.posts.edges.map(({ node: post }) => (
## {post.title}

{post.excerpt}

By {post.author.username}

))} {data.posts.pageInfo.hasNextPage && ( )}
); } function CreatePost() { const [createPost, { loading, error }] = useMutation(CREATE
POST, { refetchQueries: [{ query: GET_POSTS, variables: { first: 10, offset: 0 } }] }); const handleSubmit = async (e) => { e.preventDefault(); const { data } = await createPost({ variables: { input: { title: e.target.title.value, content: e.target.content.value } } }); if (data.createPost.errors.length === 0) { alert('Post created!'); } }; return (
{error &&
Error: {error.message}
}
); } function App() { return ( ); }

Conclusion

GraphQL offre une flexibilité inégalée pour les APIs modernes en 2025. Les points clés:

  • Schema-first design: Définir le schéma avant l’implémentation
  • DataLoader: Résoudre le problème N+1
  • Pagination: Utiliser Relay Cursor Connections
  • Caching: Implémenter plusieurs niveaux de cache
  • Complexity analysis: Protéger contre les requêtes abusives
  • Type safety: Typage fort avec TypeScript/GraphQL
  • Subscriptions: Temps réel avec WebSockets
  • Testing: Tests exhaustifs des resolvers
  • Ressources supplémentaires

  • GraphQL Specification
  • Apollo Server Documentation
  • DataLoader
  • GraphQL Best Practices

Mots-clés: GraphQL, Apollo Server, DataLoader, schema design, resolvers, N+1 problem, pagination, subscriptions, query complexity, GraphQL optimization, GraphQL caching, React Apollo Client

Une remarque, un retour ?

Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.