GraphQL en 2025 : Guide Complet avec Apollo Server Pour APIs Optimisées
Salut HaWkers, GraphQL continue de gagner en adoption en 2025 comme une alternative puissante au REST traditionnel. Si vous n'avez pas encore exploré cette technologie ou souhaitez approfondir vos connaissances, c'est le moment idéal.
Vous êtes-vous déjà frustré avec des APIs REST qui retournent trop ou pas assez de données ? GraphQL résout exactement ce problème, permettant au client de demander uniquement les données dont il a besoin.
Pourquoi GraphQL en 2025
GraphQL n'est pas venu pour remplacer REST complètement, mais offre des avantages significatifs dans des scénarios spécifiques.

Avantages de GraphQL
Pour le Client :
- Demande exactement les données nécessaires
- Une seule requête pour des données liées
- Typage fort et documentation automatique
- Introspection du schema
Pour le Serveur :
- Schema comme contrat d'API
- Validation automatique des queries
- Évolution de l'API sans versioning
- Meilleure observabilité d'usage
Quand Utiliser GraphQL
Utilisez GraphQL quand :
- Différents clients ont besoin de données différentes
- Plusieurs ressources liées sont nécessaires
- La performance réseau est critique (mobile)
- API publique avec beaucoup de consommateurs
Préférez REST quand :
- Opérations CRUD simples
- Le caching HTTP est prioritaire
- L'équipe n'a pas d'expérience avec GraphQL
- Intégration avec des systèmes legacy
Configuration d'Apollo Server
Créons une API GraphQL complète de zéro avec Apollo Server 4 et TypeScript.
Setup Initial
// src/index.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, Context } from './context';
async function startServer() {
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
introspection: process.env.NODE_ENV !== 'production'
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json(),
expressMiddleware(server, {
context: createContext
})
);
const PORT = process.env.PORT || 4000;
await new Promise<void>((resolve) =>
httpServer.listen({ port: PORT }, resolve)
);
console.log(`🚀 Serveur prêt sur http://localhost:${PORT}/graphql`);
}
startServer().catch(console.error);Définition du Schema
// src/schema.ts
export const typeDefs = `#graphql
# Types de base
type Utilisateur {
id: ID!
nom: String!
email: String!
avatar: String
posts: [Post!]!
commentaires: [Commentaire!]!
creeA: String!
miseAJourA: String!
}
type Post {
id: ID!
titre: String!
contenu: String!
publie: Boolean!
auteur: Utilisateur!
categories: [Categorie!]!
commentaires: [Commentaire!]!
vues: Int!
creeA: String!
miseAJourA: String!
}
type Categorie {
id: ID!
nom: String!
slug: String!
posts: [Post!]!
}
type Commentaire {
id: ID!
contenu: String!
auteur: Utilisateur!
post: Post!
creeA: String!
}
# Inputs pour mutations
input CreerUtilisateurInput {
nom: String!
email: String!
motDePasse: String!
avatar: String
}
input MettreAJourUtilisateurInput {
nom: String
avatar: String
}
input CreerPostInput {
titre: String!
contenu: String!
publie: Boolean = false
categorieIds: [ID!]
}
input MettreAJourPostInput {
titre: String
contenu: String
publie: Boolean
categorieIds: [ID!]
}
input FiltrePost {
publie: Boolean
auteurId: ID
categorieId: ID
recherche: String
}
# Pagination
type InfoPagination {
total: Int!
page: Int!
parPage: Int!
totalPages: Int!
aSuivant: Boolean!
aPrecedent: Boolean!
}
type PostsPagines {
donnees: [Post!]!
pagination: InfoPagination!
}
# Queries
type Query {
# Utilisateurs
utilisateurs: [Utilisateur!]!
utilisateur(id: ID!): Utilisateur
utilisateurParEmail(email: String!): Utilisateur
# Posts
posts(
filtre: FiltrePost
page: Int = 1
parPage: Int = 10
): PostsPagines!
post(id: ID!): Post
# Catégories
categories: [Categorie!]!
categorie(slug: String!): Categorie
}
# Mutations
type Mutation {
# Utilisateurs
creerUtilisateur(input: CreerUtilisateurInput!): Utilisateur!
mettreAJourUtilisateur(id: ID!, input: MettreAJourUtilisateurInput!): Utilisateur!
supprimerUtilisateur(id: ID!): Boolean!
# Posts
creerPost(input: CreerPostInput!): Post!
mettreAJourPost(id: ID!, input: MettreAJourPostInput!): Post!
supprimerPost(id: ID!): Boolean!
publierPost(id: ID!): Post!
# Commentaires
creerCommentaire(postId: ID!, contenu: String!): Commentaire!
supprimerCommentaire(id: ID!): Boolean!
}
# Subscriptions (optionnel)
type Subscription {
nouveauCommentaire(postId: ID!): Commentaire!
postMisAJour(id: ID!): Post!
}
`;
Implémentation des Resolvers
// src/resolvers/index.ts
import { utilisateurResolvers } from './utilisateur';
import { postResolvers } from './post';
import { categorieResolvers } from './categorie';
import { commentaireResolvers } from './commentaire';
export const resolvers = {
Query: {
...utilisateurResolvers.Query,
...postResolvers.Query,
...categorieResolvers.Query
},
Mutation: {
...utilisateurResolvers.Mutation,
...postResolvers.Mutation,
...commentaireResolvers.Mutation
},
// Field resolvers
Utilisateur: utilisateurResolvers.Utilisateur,
Post: postResolvers.Post,
Categorie: categorieResolvers.Categorie,
Commentaire: commentaireResolvers.Commentaire
};
// src/resolvers/post.ts
import { Context } from '../context';
import { GraphQLError } from 'graphql';
interface FiltrePost {
publie?: boolean;
auteurId?: string;
categorieId?: string;
recherche?: string;
}
export const postResolvers = {
Query: {
posts: async (
_: unknown,
args: { filtre?: FiltrePost; page?: number; parPage?: number },
{ db, utilisateur }: Context
) => {
const { filtre = {}, page = 1, parPage = 10 } = args;
const offset = (page - 1) * parPage;
// Construire query dynamique
let where: any = {};
if (filtre.publie !== undefined) {
where.publie = filtre.publie;
}
if (filtre.auteurId) {
where.auteurId = filtre.auteurId;
}
if (filtre.recherche) {
where.OR = [
{ titre: { contains: filtre.recherche, mode: 'insensitive' } },
{ contenu: { contains: filtre.recherche, mode: 'insensitive' } }
];
}
const [posts, total] = await Promise.all([
db.post.findMany({
where,
skip: offset,
take: parPage,
orderBy: { creeA: 'desc' }
}),
db.post.count({ where })
]);
const totalPages = Math.ceil(total / parPage);
return {
donnees: posts,
pagination: {
total,
page,
parPage,
totalPages,
aSuivant: page < totalPages,
aPrecedent: page > 1
}
};
},
post: async (_: unknown, { id }: { id: string }, { db }: Context) => {
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new GraphQLError('Post non trouvé', {
extensions: { code: 'NOT_FOUND' }
});
}
// Incrémenter les vues
await db.post.update({
where: { id },
data: { vues: { increment: 1 } }
});
return post;
}
},
Mutation: {
creerPost: async (
_: unknown,
{ input }: { input: any },
{ db, utilisateur }: Context
) => {
if (!utilisateur) {
throw new GraphQLError('Non autorisé', {
extensions: { code: 'UNAUTHORIZED' }
});
}
const { categorieIds, ...donnees } = input;
return db.post.create({
data: {
...donnees,
auteurId: utilisateur.id,
categories: categorieIds
? { connect: categorieIds.map((id: string) => ({ id })) }
: undefined
}
});
},
mettreAJourPost: async (
_: unknown,
{ id, input }: { id: string; input: any },
{ db, utilisateur }: Context
) => {
if (!utilisateur) {
throw new GraphQLError('Non autorisé', {
extensions: { code: 'UNAUTHORIZED' }
});
}
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new GraphQLError('Post non trouvé', {
extensions: { code: 'NOT_FOUND' }
});
}
if (post.auteurId !== utilisateur.id) {
throw new GraphQLError('Pas de permission pour modifier ce post', {
extensions: { code: 'FORBIDDEN' }
});
}
const { categorieIds, ...donnees } = input;
return db.post.update({
where: { id },
data: {
...donnees,
categories: categorieIds
? { set: categorieIds.map((catId: string) => ({ id: catId })) }
: undefined,
miseAJourA: new Date()
}
});
},
publierPost: async (
_: unknown,
{ id }: { id: string },
{ db, utilisateur }: Context
) => {
if (!utilisateur) {
throw new GraphQLError('Non autorisé', {
extensions: { code: 'UNAUTHORIZED' }
});
}
return db.post.update({
where: { id },
data: { publie: true, miseAJourA: new Date() }
});
}
},
// Field resolvers pour les relations
Post: {
auteur: (post: any, _: unknown, { loaders }: Context) => {
return loaders.utilisateur.load(post.auteurId);
},
categories: (post: any, _: unknown, { db }: Context) => {
return db.categorie.findMany({
where: { posts: { some: { id: post.id } } }
});
},
commentaires: (post: any, _: unknown, { db }: Context) => {
return db.commentaire.findMany({
where: { postId: post.id },
orderBy: { creeA: 'desc' }
});
}
}
};
DataLoader : Éviter le N+1
Le problème N+1 est l'un des plus grands ennemis de la performance en GraphQL. DataLoader résout cela avec le batching.
// src/loaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
export function createLoaders(db: PrismaClient) {
return {
utilisateur: new DataLoader(async (ids: readonly string[]) => {
const utilisateurs = await db.utilisateur.findMany({
where: { id: { in: [...ids] } }
});
// Mapper pour maintenir l'ordre
const utilisateurMap = new Map(utilisateurs.map((u) => [u.id, u]));
return ids.map((id) => utilisateurMap.get(id) || null);
}),
post: new DataLoader(async (ids: readonly string[]) => {
const posts = await db.post.findMany({
where: { id: { in: [...ids] } }
});
const postMap = new Map(posts.map((p) => [p.id, p]));
return ids.map((id) => postMap.get(id) || null);
}),
// Loader pour posts par auteur (batch par auteurId)
postsParAuteur: new DataLoader(async (auteurIds: readonly string[]) => {
const posts = await db.post.findMany({
where: { auteurId: { in: [...auteurIds] } }
});
// Grouper par auteurId
const postsParAuteur = new Map<string, any[]>();
posts.forEach((post) => {
const liste = postsParAuteur.get(post.auteurId) || [];
liste.push(post);
postsParAuteur.set(post.auteurId, liste);
});
return auteurIds.map((id) => postsParAuteur.get(id) || []);
}),
// Loader pour comptage de commentaires
comptageCommentaires: new DataLoader(async (postIds: readonly string[]) => {
const comptages = await db.commentaire.groupBy({
by: ['postId'],
where: { postId: { in: [...postIds] } },
_count: { id: true }
});
const comptageMap = new Map(
comptages.map((c) => [c.postId, c._count.id])
);
return postIds.map((id) => comptageMap.get(id) || 0);
})
};
}
// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createLoaders } from './loaders';
import { verifierToken } from './auth';
const db = new PrismaClient();
export interface Context {
db: PrismaClient;
loaders: ReturnType<typeof createLoaders>;
utilisateur: { id: string; email: string } | null;
}
export async function createContext({ req }: { req: any }): Promise<Context> {
const token = req.headers.authorization?.replace('Bearer ', '');
const utilisateur = token ? await verifierToken(token) : null;
return {
db,
loaders: createLoaders(db), // Nouveaux loaders par requête
utilisateur
};
}
Queries et Mutations en Pratique
Voyons comment utiliser l'API côté client.
Queries
# Récupérer liste de posts paginée
query ListerPosts($filtre: FiltrePost, $page: Int) {
posts(filtre: $filtre, page: $page, parPage: 10) {
donnees {
id
titre
contenu
publie
vues
creeA
auteur {
id
nom
avatar
}
categories {
id
nom
slug
}
}
pagination {
total
page
totalPages
aSuivant
aPrecedent
}
}
}
# Récupérer post avec tous les détails
query RecupererPost($id: ID!) {
post(id: $id) {
id
titre
contenu
publie
vues
creeA
miseAJourA
auteur {
id
nom
email
avatar
}
categories {
id
nom
slug
}
commentaires {
id
contenu
creeA
auteur {
id
nom
avatar
}
}
}
}
# Récupérer utilisateur avec ses posts
query ProfilUtilisateur($id: ID!) {
utilisateur(id: $id) {
id
nom
email
avatar
creeA
posts {
id
titre
publie
vues
creeA
}
}
}Mutations
# Créer nouveau post
mutation CreerPost($input: CreerPostInput!) {
creerPost(input: $input) {
id
titre
contenu
publie
creeA
}
}
# Mettre à jour post existant
mutation MettreAJourPost($id: ID!, $input: MettreAJourPostInput!) {
mettreAJourPost(id: $id, input: $input) {
id
titre
contenu
publie
miseAJourA
}
}
# Publier post
mutation PublierPost($id: ID!) {
publierPost(id: $id) {
id
publie
miseAJourA
}
}
# Ajouter commentaire
mutation AjouterCommentaire($postId: ID!, $contenu: String!) {
creerCommentaire(postId: $postId, contenu: $contenu) {
id
contenu
creeA
auteur {
id
nom
}
}
}
Optimisations Avancées
Persisted Queries
Pour la production, les persisted queries améliorent la sécurité et la performance :
// src/plugins/persistedQueries.ts
import { ApolloServerPlugin } from '@apollo/server';
import { createHash } from 'crypto';
const queryStore = new Map<string, string>();
export const persistedQueriesPlugin: ApolloServerPlugin = {
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
if (request.extensions?.persistedQuery) {
const { sha256Hash } = request.extensions.persistedQuery;
// Vérifier si la query est enregistrée
if (!queryStore.has(sha256Hash)) {
if (request.query) {
// Enregistrer nouvelle query
const hash = createHash('sha256')
.update(request.query)
.digest('hex');
if (hash === sha256Hash) {
queryStore.set(sha256Hash, request.query);
}
}
}
}
}
};
}
};Query Complexity
Limiter la complexité des queries évite les abus :
// src/plugins/queryComplexity.ts
import { GraphQLError } from 'graphql';
import {
fieldExtensionsEstimator,
getComplexity,
simpleEstimator
} from 'graphql-query-complexity';
const MAX_COMPLEXITY = 1000;
export function validateQueryComplexity(schema: any, query: any, variables: any) {
const complexity = getComplexity({
schema,
query,
variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
});
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError(
`Query trop complexe : ${complexity}. Maximum autorisé : ${MAX_COMPLEXITY}`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } }
);
}
return complexity;
}Caching avec Redis
// src/plugins/caching.ts
import Redis from 'ioredis';
import { createHash } from 'crypto';
const redis = new Redis(process.env.REDIS_URL);
export async function cacheResolver<T>(
key: string,
ttl: number,
resolver: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const result = await resolver();
await redis.setex(key, ttl, JSON.stringify(result));
return result;
}
// Usage dans les resolvers
posts: async (_: unknown, args: any, { db }: Context) => {
const cacheKey = `posts:${createHash('md5').update(JSON.stringify(args)).digest('hex')}`;
return cacheResolver(cacheKey, 300, async () => {
return db.post.findMany({ /* ... */ });
});
}Gestion des Erreurs
// src/errors.ts
import { GraphQLError } from 'graphql';
export class ErreurValidation extends GraphQLError {
constructor(message: string, champ?: string) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
champ
}
});
}
}
export class ErreurAuthentification extends GraphQLError {
constructor(message = 'Non autorisé') {
super(message, {
extensions: { code: 'UNAUTHENTICATED' }
});
}
}
export class ErreurPermission extends GraphQLError {
constructor(message = 'Pas de permission') {
super(message, {
extensions: { code: 'FORBIDDEN' }
});
}
}
export class ErreurNonTrouve extends GraphQLError {
constructor(ressource: string) {
super(`${ressource} non trouvé`, {
extensions: { code: 'NOT_FOUND' }
});
}
}Si vous voulez en apprendre davantage sur les architectures backend modernes, consultez l'article Architecture Serverless avec JavaScript qui complète très bien GraphQL.

