Intégrez Apollo Angular à une API GraphQL : queries et mutations typées, codegen automatique, cache normalisé, pagination et subscriptions en temps réel.
GraphQL face à REST : ce qui change pour Angular
Avec REST, chaque écran Angular multiplie souvent les appels HTTP pour assembler ses données : un appel pour l'utilisateur, un autre pour ses commandes, un troisième pour les produits associés. GraphQL inverse la logique — le client décrit exactement les champs dont il a besoin dans une seule requête, et le serveur répond avec une structure JSON qui correspond point par point à cette demande.
Ce changement de paradigme a un impact direct sur l'architecture Angular. Les services qui, en REST, orchestraient plusieurs appels HTTP avec forkJoin ou des switchMap imbriqués se simplifient : une seule query GraphQL remplace souvent trois ou quatre requêtes REST chaînées. Le code d'agrégation manuelle — qui était une source fréquente de bugs de synchronisation et de race conditions — disparaît purement et simplement, puisque c'est le serveur GraphQL qui résout le graphe de données en une seule passe.
Cette approche déplace aussi la responsabilité du modelage des données. En REST, c'est l'équipe backend qui décide de la forme des réponses ; en GraphQL, c'est le frontend Angular qui décide, requête par requête, des champs à récupérer. Pour une équipe qui livre plusieurs clients (application web Angular, application mobile, tableau de bord interne), cela évite de multiplier les endpoints REST « sur-mesure » (/api/orders/summary, /api/orders/detail…) qui finissent par diverger avec le temps.
/graphql), avec la query et les variables dans le corps de la requête.
Concrètement, une vue « détail commande » qui nécessitait 3 appels REST devient une seule requête GraphQL :
query GetOrderDetail($orderId: ID!) {
order(id: $orderId) {
id
status
total
customer {
name
email
}
items {
productName
quantity
unitPrice
}
}
}
Ce modèle réduit l'over-fetching (récupérer des champs inutilisés) et l'under-fetching (devoir enchaîner plusieurs appels). Voici les différences clés à connaître avant d'intégrer GraphQL dans une app Angular existante :
| Critère | REST | GraphQL |
|---|---|---|
| Endpoints | Multiples (un par ressource) | Unique (/graphql) |
| Forme de la réponse | Fixe côté serveur | Définie par le client |
| Sur-fetching / sous-fetching | Fréquent | Éliminé par design |
| Typage bout-en-bout | Manuel (DTO, OpenAPI) | Généré depuis le schéma (codegen) |
| Cache HTTP navigateur | Natif (GET + ETag) | Géré par Apollo (InMemoryCache) |
Dans une app Angular, l'intégration se fait via Apollo Angular, le client officiel qui expose des Observable — s'intégrant naturellement avec RxJS, les async pipes et, plus récemment, les Signals via toSignal().
Installer et configurer Apollo Angular
L'installation se fait en trois paquets : le client Apollo lui-même, le binding Angular, et la librairie GraphQL de référence pour le parsing des documents.
# Installer Apollo Angular et ses dépendances
npm install apollo-angular @apollo/client graphql
Depuis Angular 15+, la configuration se fait sans NgModule, directement dans app.config.ts via ApplicationConfig :
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { InMemoryCache } from '@apollo/client/core';
import { provideApollo } from 'apollo-angular';
import { provideHttpLink, HttpLink } from 'apollo-angular/http';
import { inject } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// Angular a besoin de HttpClient pour transporter les requêtes GraphQL
provideHttpClient(),
// Expose HttpLink, le transport HTTP utilisé par Apollo
provideHttpLink(),
// Configure le client Apollo lui-même
provideApollo(() => {
const httpLink = inject(HttpLink);
return {
// Endpoint unique du serveur GraphQL
link: httpLink.create({ uri: 'https://api.mon-site.com/graphql' }),
// Cache normalisé — voir section dédiée plus bas
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
// Retourne le cache en attendant la réponse réseau
fetchPolicy: 'cache-and-network'
}
}
};
})
]
};
Pour ajouter l'authentification (token JWT dans les headers), on compose un second lien avec ApolloLink :
// Ajouter le header Authorization à chaque requête GraphQL
import { ApolloLink } from '@apollo/client/core';
import { setContext } from '@apollo/client/link/context';
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('access_token');
return {
headers: {
...headers,
// Le serveur GraphQL lit ce header pour authentifier la requête
authorization: token ? `Bearer ${token}` : ''
}
};
});
// Dans provideApollo : chaîner authLink avant httpLink
link: authLink.concat(httpLink.create({ uri: 'https://api.mon-site.com/graphql' }))
HttpInterceptorFn pour REST, l'authentification GraphQL se configure via ApolloLink, une chaîne de middlewares propre à Apollo Client — les deux mécanismes ne se substituent pas l'un à l'autre.
Générer des types TypeScript avec GraphQL Code Generator
Écrire les interfaces TypeScript à la main pour chaque query est source d'erreurs et de désynchronisation avec le schéma serveur. GraphQL Code Generator lit le schéma distant et vos fichiers .graphql, puis génère automatiquement des types stricts.
# Installer le CLI et le preset client Apollo
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset
Le fichier de configuration pointe vers le schéma distant et le dossier contenant vos opérations GraphQL :
// codegen.ts — configuration à la racine du projet
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
// Introspection du schéma depuis l'API GraphQL en dev
schema: 'https://api.mon-site.com/graphql',
// Où chercher les documents GraphQL (queries/mutations)
documents: ['src/**/*.graphql'],
generates: {
'./src/app/graphql/generated/': {
// Preset client : génère types + hooks typés en une passe
preset: 'client',
plugins: []
}
}
};
export default config;
Vous écrivez ensuite vos opérations dans des fichiers .graphql dédiés, séparés du code TypeScript :
-- src/app/graphql/get-order-detail.graphql
query GetOrderDetail($orderId: ID!) {
order(id: $orderId) {
id
status
total
customer {
name
email
}
}
}
# Lancer la génération (à ajouter dans package.json scripts)
npx graphql-codegen --config codegen.ts
# Mode watch pendant le développement
npx graphql-codegen --config codegen.ts --watch
GetOrderDetailQuery reflétant exactement la forme de la réponse. Si un champ est renommé côté serveur, le build Angular échoue immédiatement à la compilation — plus besoin d'attendre un bug en production.
Queries typées avec Apollo et RxJS
Le service Apollo injectable expose watchQuery(), qui retourne un Observable se réactualisant automatiquement à chaque changement du cache — pas besoin de recharger manuellement après une mutation liée.
// src/app/services/order.service.ts
import { Injectable, inject } from '@angular/core';
import { Apollo } from 'apollo-angular';
import { map } from 'rxjs/operators';
import { GetOrderDetailDocument, GetOrderDetailQuery } from '../graphql/generated/graphql';
@Injectable({ providedIn: 'root' })
export class OrderService {
private apollo = inject(Apollo);
// Retourne un Observable typé grâce au document généré par codegen
getOrderDetail(orderId: string) {
return this.apollo
.watchQuery({
query: GetOrderDetailDocument,
variables: { orderId },
// Réutilise le cache immédiatement, revalide en arrière-plan
fetchPolicy: 'cache-and-network'
})
.valueChanges.pipe(
// result.data est déjà typé — autocomplétion complète dans l'IDE
map(result => result.data.order)
);
}
}
Côté composant, on consomme l'Observable comme n'importe quel flux RxJS — via async pipe ou converti en Signal avec toSignal() :
// src/app/components/order-detail/order-detail.component.ts
import { Component, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OrderService } from '../../services/order.service';
@Component({
selector: 'app-order-detail',
standalone: true,
template: `
@if (order(); as o) {
Commande #{{ o.id }}
Statut : {{ o.status }} — Total : {{ o.total }}€
Client : {{ o.customer.name }} ({{ o.customer.email }})
} @else {
Chargement…
}
`
})
export class OrderDetailComponent {
private orderService = inject(OrderService);
orderId = input.required();
// Convertit l'Observable Apollo en Signal — réactif sans souscription manuelle
order = toSignal(this.orderService.getOrderDetail(this.orderId()));
}
Pour une lecture ponctuelle sans abonnement continu (ex : chargement initial d'un formulaire), préférez query() qui retourne une Promise unique plutôt que watchQuery() :
// Lecture ponctuelle — pas de mise à jour automatique via le cache
async loadOnce(orderId: string) {
const result = await firstValueFrom(
this.apollo.query({
query: GetOrderDetailDocument,
variables: { orderId }
})
);
return result.data.order;
}
Fragments GraphQL : réutiliser les sélections de champs
Dès qu'un même type (comme Order ou Customer) apparaît dans plusieurs queries — la liste des commandes, le détail d'une commande, l'historique client — dupliquer la sélection de champs à chaque fois devient vite ingérable. Les fragments GraphQL résolvent ce problème en définissant un ensemble de champs nommé et réutilisable, un peu comme un composant Angular réutilisable pour une portion d'UI.
-- src/app/graphql/fragments/order-summary.graphql
fragment OrderSummary on Order {
id
status
total
createdAt
}
Ce fragment s'insère ensuite dans n'importe quelle query avec la syntaxe ...NomDuFragment :
-- src/app/graphql/list-orders-with-fragment.graphql
#import "./fragments/order-summary.graphql"
query ListOrdersWithFragment {
orders(first: 20) {
edges {
node {
...OrderSummary
customer {
name
}
}
}
}
}
Avec graphql-codegen, chaque fragment génère aussi son propre type TypeScript exportable (OrderSummaryFragment), que l'on peut utiliser pour typer des fonctions utilitaires ou des composants Angular partagés sans dépendre de la query complète qui les englobe :
// src/app/components/order-card/order-card.component.ts
import { Component, input } from '@angular/core';
import { OrderSummaryFragment } from '../../graphql/generated/graphql';
@Component({
selector: 'app-order-card',
standalone: true,
template: `
#{{ order().id }}
{{ order().status }}
{{ order().total }}€
`
})
export class OrderCardComponent {
// Le composant ne connaît que la forme du fragment, pas la query parente
order = input.required();
}
Mutations : écrire et mettre à jour le cache
Une mutation GraphQL modifie des données côté serveur. Sans intervention, le cache Apollo local ne sait pas qu'une entité a changé — il faut soit relancer une query, soit mettre à jour le cache manuellement pour éviter un aller-retour réseau superflu.
// src/app/graphql/update-order-status.graphql
mutation UpdateOrderStatus($orderId: ID!, $status: OrderStatus!) {
updateOrderStatus(orderId: $orderId, status: $status) {
id
status
}
}
// src/app/services/order.service.ts (suite)
import { UpdateOrderStatusDocument, UpdateOrderStatusMutation } from '../graphql/generated/graphql';
updateStatus(orderId: string, status: string) {
return this.apollo.mutate({
mutation: UpdateOrderStatusDocument,
variables: { orderId, status },
// Met à jour immédiatement l'UI avant la réponse serveur (optimistic UI)
optimisticResponse: {
updateOrderStatus: {
__typename: 'Order',
id: orderId,
status
}
},
// Fusionne le résultat dans le cache normalisé — pas de refetch réseau
update: (cache, { data }) => {
if (!data) return;
cache.modify({
id: cache.identify({ __typename: 'Order', id: orderId }),
fields: {
status: () => data.updateOrderStatus.status
}
});
}
});
}
Trois stratégies existent pour synchroniser le cache après une mutation, à choisir selon le contexte :
| Stratégie | Quand l'utiliser |
|---|---|
update() + cache.modify() |
Champ simple à corriger — le plus performant, zéro appel réseau |
refetchQueries |
Effet de bord complexe côté serveur (calculs dérivés, agrégats) |
cache.evict() + cache.gc() |
Suppression d'une entité (ex : mutation deleteOrder) |
// Suppression : évincer l'entité du cache normalisé
deleteOrder(orderId: string) {
return this.apollo.mutate({
mutation: DeleteOrderDocument,
variables: { orderId },
update: (cache) => {
// Retire l'entité du cache sans requête supplémentaire
cache.evict({ id: cache.identify({ __typename: 'Order', id: orderId }) });
// Nettoie les références orphelines restantes
cache.gc();
}
});
}
Cache normalisé : InMemoryCache et politiques
Contrairement à un cache HTTP classique qui stocke des réponses entières, InMemoryCache d'Apollo normalise les données : chaque entité (identifiée par __typename + id) est stockée une seule fois, quel que soit le nombre de queries qui la référencent.
// Configuration du cache avec des politiques de type
import { InMemoryCache } from '@apollo/client/core';
const cache = new InMemoryCache({
typePolicies: {
Order: {
// Clé de cache personnalisée si l'ID n'est pas suffisant
keyFields: ['id'],
fields: {
// Champ calculé côté client, jamais renvoyé par le serveur
displayTotal: {
read(_, { readField }) {
const total = readField('total') ?? 0;
return `${total.toFixed(2)} €`;
}
}
}
},
Query: {
fields: {
// Politique de fusion pour la pagination (voir section suivante)
orders: {
keyArgs: ['filter'],
merge(existing = [], incoming) {
return [...existing, ...incoming];
}
}
}
}
}
});
Cette normalisation explique pourquoi une mutation sur une commande affichée dans une liste met aussi à jour instantanément sa vue détail : les deux queries pointent vers la même entrée de cache, identifiée par Order:42.
id sur un type, Apollo ne peut pas normaliser l'entité — elle est alors stockée en tant qu'objet imbriqué non partagé, avec les incohérences de cache que cela implique. Exposez toujours un id stable sur chaque type métier.
Le comportement de lecture du cache dépend aussi de la fetchPolicy choisie sur chaque query. C'est un réglage à connaître finement, car il conditionne l'équilibre entre fraîcheur des données et nombre d'appels réseau :
fetchPolicy |
Comportement |
|---|---|
cache-first (défaut) |
Retourne le cache si présent, sinon interroge le réseau |
cache-and-network |
Retourne le cache immédiatement, puis revalide en arrière-plan |
network-only |
Ignore le cache en lecture, mais le met à jour avec la réponse |
no-cache |
Ignore le cache en lecture ET en écriture |
cache-only |
Ne lit que le cache local, jamais le réseau |
Pour un écran de type tableau de bord Angular affiché fréquemment, cache-and-network offre le meilleur compromis : l'utilisateur voit une donnée immédiatement (même périmée de quelques secondes), pendant que la requête réseau rafraîchit silencieusement le cache et met à jour la vue dès que la réponse arrive.
Pour inspecter le cache en développement, l'extension navigateur Apollo Client DevTools affiche l'arbre normalisé en temps réel et permet d'exécuter des queries directement depuis l'inspecteur.
Pagination cursor-based et subscriptions temps réel
La pagination GraphQL suit généralement le pattern Relay cursor connections : chaque page expose des edges, un cursor par élément et un pageInfo.hasNextPage.
// src/app/graphql/list-orders.graphql
query ListOrders($after: String) {
orders(first: 20, after: $after) {
edges {
cursor
node {
id
status
total
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
// Charger la page suivante avec fetchMore()
loadNextPage(queryRef: QueryRef, endCursor: string) {
return queryRef.fetchMore({
variables: { after: endCursor }
// La fonction merge() du typePolicy (section précédente)
// s'occupe de concaténer les résultats automatiquement
});
}
Pour les données temps réel (ex : suivi de commande en direct), Apollo Angular supporte les subscriptions via WebSocket, avec le même modèle de typage généré par codegen :
// src/app/graphql/order-status-changed.graphql
subscription OrderStatusChanged($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
id
status
updatedAt
}
}
// Configurer le transport WebSocket (graphql-ws)
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { split } from '@apollo/client/core';
import { getMainDefinition } from '@apollo/client/utilities';
const wsLink = new GraphQLWsLink(
createClient({ url: 'wss://api.mon-site.com/graphql' })
);
// Route les subscriptions vers WebSocket, le reste vers HTTP
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
wsLink,
httpLink.create({ uri: 'https://api.mon-site.com/graphql' })
);
// Consommer la subscription dans un service Angular
watchOrderStatus(orderId: string) {
return this.apollo
.subscribe({
query: OrderStatusChangedDocument,
variables: { orderId }
})
.pipe(map(result => result.data?.orderStatusChanged));
}
takeUntilDestroyed()) quand le composant qui affiche le statut en temps réel est détruit, pour éviter les fuites de connexions côté serveur.
Gestion des erreurs, retries et tests
GraphQL renvoie toujours un statut HTTP 200, même en cas d'erreur métier — les erreurs apparaissent dans un tableau errors à côté de data. Il faut donc les intercepter explicitement, pas via un simple catchError HTTP.
// Middleware ApolloLink pour centraliser la gestion des erreurs
import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
// Erreurs métier renvoyées par le resolver GraphQL
graphQLErrors.forEach(({ message, extensions }) => {
console.error(`Erreur GraphQL: ${message}`, extensions);
if (extensions?.['code'] === 'UNAUTHENTICATED') {
// Rediriger vers le login si le token est expiré
window.location.href = '/login';
}
});
}
if (networkError) {
// Coupure réseau, timeout, 5xx serveur
console.error(`Erreur réseau: ${networkError.message}`);
}
});
// Chaîner avant httpLink dans la configuration provideApollo
link: errorLink.concat(authLink).concat(httpLink.create({ uri: '...' }))
Côté composant, l'Observable de watchQuery() expose aussi un champ errors directement dans chaque émission :
this.orderService.getOrderDetailRaw(orderId).subscribe({
next: (result) => {
if (result.errors?.length) {
// Erreurs partielles — certains champs ont pu être résolus quand même
this.errorMessage = result.errors[0].message;
}
this.order = result.data.order;
},
error: (err) => {
// Erreur fatale (réseau, parsing) interceptée par errorLink en amont
this.errorMessage = 'Impossible de charger la commande.';
}
});
Pour les tests unitaires, apollo-angular/testing fournit un contrôleur de requêtes GraphQL analogue à HttpTestingController pour REST :
// order.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ApolloTestingModule, ApolloTestingController } from 'apollo-angular/testing';
import { OrderService } from './order.service';
import { GetOrderDetailDocument } from '../graphql/generated/graphql';
describe('OrderService', () => {
let service: OrderService;
let controller: ApolloTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
providers: [OrderService]
});
service = TestBed.inject(OrderService);
controller = TestBed.inject(ApolloTestingController);
});
it('devrait récupérer le détail d\'une commande', (done) => {
service.getOrderDetail('42').subscribe(order => {
expect(order?.status).toBe('SHIPPED');
done();
});
// Intercepte la query et répond avec des données mockées
const op = controller.expectOne(GetOrderDetailDocument);
op.flush({
data: {
order: { id: '42', status: 'SHIPPED', total: 89.9, customer: { name: 'Ana', email: 'a@x.com' } }
}
});
controller.verify();
});
});
Checklist avant mise en production
- Types générés via
graphql-codegenintégrés au pipeline CI (fail si désynchronisés du schéma) -
errorLinkcentralisé pour intercepter erreurs métier et réseau -
typePoliciesdéfinies pour chaque type exposant une pagination - Subscriptions désabonnées via
takeUntilDestroyed() -
optimisticResponseutilisée uniquement sur des mutations à faible risque de conflit - Tests avec
ApolloTestingControllercouvrant queries, mutations et erreurs - Champ
idexposé sur chaque type métier pour garantir la normalisation du cache