API Design: de REST à GraphQL, quand basculer?
GraphQL brille sur les UIs riches et variées, mais impose une gouvernance de schéma. Nous présentons des cas concrets, des avantages mesurés et les pièges à éviter côté caching.
L'évolution des besoins API en 2024
Constats sur nos projets clients
- **Complexité croissante des interfaces
- **
- Applications web + mobile natives + PWA + IoT
- Besoins en données différents par plateforme
- Over-fetching et under-fetching fréquents avec REST
- Multiplication des endpoints pour chaque cas d'usage
- **Notre expérience comparative
- **
- 15 APIs REST conçues et maintenues
- 8 APIs GraphQL en production
- 4 migrations REST → GraphQL complétées
- 3 retours GraphQL → REST (cas spécifiques)
Cette diversité nous donne une vision pragmatique des forces et limites de chaque approche.
REST : les forces et limites révélées
Quand REST excelle encore
- **Simplicité et standards
- **
// API REST claire et prévisible
GET /api/v1/users // Liste users
GET /api/v1/users/123 // User spécifique
POST /api/v1/users // Créer user
PUT /api/v1/users/123 // Modifier user
DELETE /api/v1/users/123 // Supprimer user
// Réponses standardisées
{
"data": { "id": 123, "name": "John Doe" },
"meta": { "timestamp": "2024-10-22T10:00:00Z" },
"links": {
"self": "/api/v1/users/123",
"posts": "/api/v1/users/123/posts"
}
}- **Cache HTTP natif
- **
// Headers de cache optimisés
app.get('/api/v1/users/:id', (req, res) => {
const user = getUserById(req.params.id)
res.set({
'Cache-Control': 'public, max-age=300', // 5min
'ETag': `"${user.updatedAt}"`,
'Last-Modified': new Date(user.updatedAt).toUTCString()
})
// Cache conditionnel
if (req.headers['if-none-match'] === `"${user.updatedAt}"`) {
return res.sendStatus(304) // Not Modified
}
res.json(user)
})Limites REST révélées à l'échelle
- **Over-fetching chronique
- **
// Mobile a besoin de: id, name, avatar
// Desktop a besoin de: tout + permissions + stats
// Widget a besoin de: juste name, status
// REST retourne toujours tout
GET /api/v1/users/123
{
"id": 123,
"name": "John Doe",
"email": "john@example.com", // Pas utilisé mobile
"address": {...}, // Pas utilisé mobile
"preferences": {...}, // Pas utilisé mobile
"permissions": [...], // Gros objet inutile mobile
"stats": {...} // Calculs coûteux non utilisés
}- **Multiplication endpoints
- **
// Explosion d'endpoints spécialisés
GET /api/v1/users/123/mobile // Version mobile
GET /api/v1/users/123/widget // Version widget
GET /api/v1/users/123/dashboard // Version dashboard
GET /api/v1/users/123/profile // Version profil
// Maintenance cauchemardesque
// Documentation dispersée
// Versionning complexeGraphQL : révolution ou complexité ?
Les promesses tenues
- **Requêtes sur-mesure
- **
# Mobile: données minimales
query MobileUser($id: ID!) {
user(id: $id) {
id
name
avatar
isOnline
}
}
# Dashboard: données complètes
query DashboardUser($id: ID!) {
user(id: $id) {
id
name
email
avatar
stats {
postsCount
followersCount
lastActive
}
permissions {
canPost
canModerate
canAdmin
}
recentActivity {
id
type
createdAt
}
}
}
# Widget: juste l'essentiel
query WidgetUser($id: ID!) {
user(id: $id) {
name
status
}
}- **Relations complexes simplifiées
- **
# Une seule requête pour données interconnectées
query UserWithContent($userId: ID!) {
user(id: $userId) {
name
avatar
posts(first: 10) {
edges {
node {
title
excerpt
publishedAt
comments(first: 3) {
edges {
node {
content
author {
name
avatar
}
}
}
}
}
}
}
followers(first: 5) {
edges {
node {
name
avatar
mutualFriends {
count
}
}
}
}
}
}Architecture GraphQL moderne
- **Schéma bien structuré
- **
# Schema définition avec bonnes pratiques
type User {
id: ID!
name: String!
email: String
avatar: String
# Relations avec pagination
posts(
first: Int = 10
after: String
orderBy: PostOrderBy = CREATED_AT_DESC
): PostConnection!
# Champs calculés
fullName: String!
isOnline: Boolean!
# Métadonnées
createdAt: DateTime!
updatedAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
# Relations complexes
comments(first: Int = 10): CommentConnection!
tags: [Tag!]!
# État
status: PostStatus!
publishedAt: DateTime
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}- **Resolvers optimisés
- **
// Resolvers avec DataLoader pour éviter N+1
import DataLoader from 'dataloader'
const userLoader = new DataLoader(async (userIds) => {
const users = await User.findByIds(userIds)
return userIds.map(id => users.find(user => user.id === id))
})
const resolvers = {
Query: {
user: (_, { id }) => userLoader.load(id),
users: async (_, { first, after }) => {
const result = await User.paginate({ first, after })
return {
edges: result.users.map(user => ({
node: user,
cursor: user.id
})),
pageInfo: result.pageInfo,
totalCount: result.totalCount
}
}
},
User: {
posts: (user, { first, after }) =>
Post.findByUser(user.id, { first, after }),
followers: (user, { first }) =>
Relationship.getFollowers(user.id, { first }),
isOnline: (user) =>
OnlineStatus.isUserOnline(user.id),
fullName: (user) =>
`${user.firstName} ${user.lastName}`
},
Post: {
author: (post) => userLoader.load(post.authorId),
comments: (post, { first }) =>
Comment.findByPost(post.id, { first }),
tags: (post) =>
TagLoader.loadMany(post.tagIds)
}
}Performance et optimisation
- **Query complexity limiting
- **
import { createComplexityLimitRule } from 'graphql-query-complexity'
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000) // Limite complexité
],
plugins: [
{
requestDidStart() {
return {
didResolveOperation(requestContext) {
const complexity = getComplexity({
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ maximumComplexity: 1000 })
],
query: requestContext.document,
variables: requestContext.request.variables
})
if (complexity > 1000) {
throw new Error(`Query too complex: ${complexity}`)
}
}
}
}
}
]
})- **Cache intelligent multi-niveaux
- **
// Cache avec Redis et DataLoader
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
const createCachedLoader = (batchLoadFn, keyFn, ttl = 300) => {
return new DataLoader(async (keys) => {
// 1. Vérifier cache Redis
const cacheKeys = keys.map(key => `loader:${keyFn(key)}`)
const cached = await redis.mget(cacheKeys)
const uncachedIndices = []
const uncachedKeys = []
cached.forEach((value, index) => {
if (value === null) {
uncachedIndices.push(index)
uncachedKeys.push(keys[index])
}
})
// 2. Charger données manquantes
let freshData = []
if (uncachedKeys.length > 0) {
freshData = await batchLoadFn(uncachedKeys)
// 3. Mettre en cache
const pipeline = redis.pipeline()
freshData.forEach((item, index) => {
const cacheKey = `loader:${keyFn(uncachedKeys[index])}`
pipeline.setex(cacheKey, ttl, JSON.stringify(item))
})
await pipeline.exec()
}
// 4. Reconstituer résultat ordonné
const result = new Array(keys.length)
let freshIndex = 0
cached.forEach((value, index) => {
if (value !== null) {
result[index] = JSON.parse(value)
} else {
result[index] = freshData[freshIndex++]
}
})
return result
})
}
// Usage
const userLoader = createCachedLoader(
(ids) => User.findByIds(ids),
(id) => `user:${id}`,
600 // 10min cache
)Migration progressive REST → GraphQL
Stratégie BFF (Backend for Frontend)
// Gateway GraphQL devant APIs REST existantes
const { RESTDataSource } = require('apollo-datasource-rest')
class UserAPI extends RESTDataSource {
constructor() {
super()
this.baseURL = 'https://api.internal.com/v1/'
}
willSendRequest(request) {
request.headers.set('Authorization', this.context.token)
}
async getUser(id) {
const data = await this.get(`users/${id}`)
return this.userReducer(data)
}
async getUserPosts(userId, { first, after }) {
const params = { limit: first, offset: after }
const data = await this.get(`users/${userId}/posts`, params)
return this.postsReducer(data)
}
userReducer(user) {
return {
id: user.id,
name: user.full_name, // Mapping champs
email: user.email_address,
avatar: user.profile_image_url
}
}
}
// Resolvers utilisant APIs REST
const resolvers = {
Query: {
user: (_, { id }, { dataSources }) =>
dataSources.userAPI.getUser(id)
},
User: {
posts: (user, args, { dataSources }) =>
dataSources.userAPI.getUserPosts(user.id, args)
}
}Coexistence REST + GraphQL
// Router intelligent selon client
app.use('/api', (req, res, next) => {
const clientType = req.headers['x-client-type']
const acceptsGraphQL = req.headers.accept?.includes('application/graphql')
if (clientType === 'mobile-v2' || acceptsGraphQL) {
return graphqlHandler(req, res, next)
}
return restHandler(req, res, next)
})
// Headers pour guider les clients
app.use((req, res, next) => {
res.set({
'X-API-Version': 'v1',
'X-GraphQL-Endpoint': '/graphql',
'X-Rest-Endpoint': '/api/v1'
})
next()
})Cas d'usage : quand choisir quoi ?
GraphQL recommandé pour :
- **Applications riches avec besoins variés
- **
// Exemple: Dashboard admin avec widgets configurables
query DashboardData($widgets: [WidgetType!]!) {
user {
name
permissions
}
# Chargement conditionnel selon configuration
stats @include(if: { widgets: { contains: STATS } }) {
usersCount
postsCount
revenue
}
recentUsers(first: 10) @include(if: { widgets: { contains: USERS } }) {
edges {
node {
name
createdAt
status
}
}
}
systemHealth @include(if: { widgets: { contains: MONITORING } }) {
cpu
memory
diskSpace
}
}- **Applications multi-plateformes
- **
- Mobile : données minimales, optimisation réseau
- Web : données complètes, relations complexes
- IoT/widgets : données très spécifiques
REST recommandé pour :
- **APIs publiques et partenaires
- **
// API claire pour partenaires externes
GET /api/v1/products?category=electronics&limit=20
{
"data": [...],
"pagination": { "next": "/api/v1/products?page=2" },
"meta": { "total": 1234, "page": 1 }
}
// Documentation OpenAPI/Swagger native
// Cache HTTP standard
// Rate limiting par endpoint simple- **CRUD simples
- **
- Backoffice administratif
- APIs internes microservices
- Intégrations webhook
- Systèmes legacy
Performance : benchmarks réels
Cas client : Application e-commerce
- **Avant (REST)
- **
// Page produit = 7 requêtes
GET /api/products/123 // Produit
GET /api/products/123/images // Images
GET /api/products/123/reviews // Avis
GET /api/categories/456 // Catégorie
GET /api/brands/789 // Marque
GET /api/users/321 // Vendeur
GET /api/products/related // Produits liés
// Total: 7 round-trips, 2.1s chargement mobile- **Après (GraphQL)
- **
# Une seule requête optimisée
query ProductPage($id: ID!) {
product(id: $id) {
name
price
description
images { url, alt }
category { name, slug }
brand { name, logo }
seller { name, rating, verified }
reviews(first: 5) {
edges {
node {
rating
comment
author { name }
createdAt
}
}
totalCount
}
relatedProducts(first: 4) {
id
name
price
image
}
}
}
# Résultat: 1 requête, 0.8s chargement mobile (-62%)Métriques comparatives mesurées :
| Métrique | REST | GraphQL | Amélioration |
|----------|------|---------|--------------|
| Requêtes/page | 5-12 | 1-2 | -75% |
| Données transférées | 100% | 40% | -60% |
| Time to Interactive | 2.1s | 0.8s | -62% |
| Cache hit rate | 45% | 78% | +33% |
Pièges GraphQL à éviter
N+1 Problem classique
// ❌ Problème N+1 (1 + N requêtes)
const resolvers = {
User: {
// Appelé pour chaque user dans une liste
posts: (user) => Post.findByUserId(user.id) // ⚠️ 1 query par user
}
}
// Query liste users = 1 + 100 requêtes si 100 users
// ✅ Solution avec DataLoader
const postsByUserLoader = new DataLoader(async (userIds) => {
const posts = await Post.findByUserIds(userIds) // 1 seule query
// Grouper par userId pour retour ordonné
const postsByUser = userIds.map(userId =>
posts.filter(post => post.userId === userId)
)
return postsByUser
})
const resolvers = {
User: {
posts: (user) => postsByUserLoader.load(user.id)
}
}Query depth bombing
# Attaque par requête trop profonde
query MaliciousQuery {
user(id: "1") {
posts {
author {
posts {
author {
posts {
author {
posts {
# ... 50 niveaux de profondeur
}
}
}
}
}
}
}
}
}- **Protection
- **
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // Max 10 niveaux
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
scalars: {
String: 1,
Int: 1,
Float: 2,
},
createError: (max, actual) => {
return new Error(`Query cost ${actual} exceeds maximum cost ${max}`)
}
})
]
})Outils et écosystème 2024
GraphQL Stack recommandée
- **Serveur
- **
- Apollo Server : Feature-complete, communauté
- Yoga GraphQL : Léger, performant
- Mercurius : Fastify, très rapide
- **Clients
- **
- Apollo Client : Complet, cache intelligent
- Relay : Facebook, optimisations poussées
- URQL : Léger, moderne, flexible
- **Tooling
- **
- GraphQL Codegen : Types TypeScript auto
- GraphiQL : Playground intégré
- Apollo Studio : Observabilité production
Migration toolkit
# Génération types TypeScript depuis schema
npm install -D @graphql-codegen/cli @graphql-codegen/typescript
# Configuration codegen.yml
schema: "src/schema.graphql"
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-resolvers
# Test compatibilité breaking changes
npm install -D @graphql-inspector/cli
graphql-inspector diff old-schema.graphql new-schema.graphqlNotre recommandation 2024
GraphQL pour 60% des nouveaux projets
- **Avantages mesurés
- **
- -60% données transférées
- -75% requêtes réseau
- +40% vitesse développement frontend
- Cache plus efficace
- **Mais attention à
- **
- Complexité opérationnelle +30%
- Courbe d'apprentissage équipe
- Debugging plus complexe
REST garde sa place pour :
- APIs publiques (documentation, standards)
- CRUD simples et backoffices
- Équipes junior ou legacy
- Contraintes cache HTTP strict
Notre processus de décision
- **Audit technique (1 semaine)
- **
- Analyse besoins clients (web/mobile/IoT)
- Évaluation équipe et expertise
- Cartographie données et relations
- POC comparatif REST vs GraphQL
- **Recommandation basée sur
- **
- Complexité interface (simple → REST, riche → GraphQL)
- Multi-plateforme (homogène → REST, varié → GraphQL)
- Équipe (junior → REST, senior → GraphQL)
- Performance requise (standard → REST, optimisée → GraphQL)
Hésitez entre REST et GraphQL pour votre projet ? Nos architectes peuvent analyser vos besoins et vous recommander l'approche optimale. Contactez-nous pour un audit API gratuit et choisissez la bonne technologie dès le départ.

