16 - Relations entre ressources : embarquer, lier ou les deux
Ce que tu vas apprendre
- Quand utiliser des sub-resources et quand utiliser des identifiants
- Le pattern expand/include pour charger les relations a la demande
- La différence entre embedding et linking
- Le problème N+1 au niveau API et comment l'éviter
- Les sparse fieldsets pour ne retourner que les champs nécessaires
Prerequisites
Avoir lu l'article sur l'upload de fichiers. Connaitre les bases du design de ressources REST (articles 1 a 5 de cette serie).
Tu as un endpoint /orders/42 qui retourne une commande. La commande a un client, des produits, une adresse de livraison, un moyen de paiement. Est-ce que tu embarques tout dans la réponse ? Est-ce que tu retournes juste les IDs et tu laisses le front faire 5 requêtes supplementaires ? Les deux approches ont des problèmes. La bonne réponse est entre les deux.
Sub-resources
Une sub-resource est une ressource qui n'a de sens que dans le contexte de son parent :
GET /orders/42/items -- les items de la commande 42
GET /users/7/addresses -- les adresses du user 7
POST /products/13/reviews -- ajouter un avis au produit 13
La regle : si la ressource enfant n'existe pas indépendamment du parent, c'est une sub-resource. Un item de commande sans commande n'a pas de sens. Par contre, un produit existe indépendamment d'une commande. Donc /orders/42/products est discutable, /products/13 est mieux.
Ne descends pas a plus de deux niveaux. /users/7/orders/42/items/3/options est un signal que ton design a besoin d'etre repense. Utilise des top-level resources : /order-items/3.
Embedding vs linking
Linking : tu retournes les identifiants et le client fait des requêtes supplementaires.
json{
"id": 42,
"customer_id": 7,
"item_ids": [101, 102, 103]
}
Embedding : tu inclus les donnees directement dans la réponse.
json{
"id": 42,
"customer": {
"id": 7,
"name": "Alice"
},
"items": [
{ "id": 101, "product": "Burger", "quantity": 2 },
{ "id": 102, "product": "Frites", "quantity": 1 }
]
}
Le linking est leger mais généré le problème N+1. L'embedding est complet mais peut devenir énorme si les relations sont profondes. Sur paltemps.fr, j'utilise l'embedding par défaut pour les relations directes et le linking pour le reste.
Le problème N+1 au niveau API
Avec le linking, un front qui affiche une liste de 20 commandes fait :
1 requete : GET /orders (20 commandes)
20 requetes : GET /users/{id} (un par commande)
20 requetes : GET /orders/{id}/items (items par commande)
41 requêtes pour afficher une page. C'est le problème N+1, mais au niveau HTTP au lieu de SQL. La latence s'accumule, surtout sur mobile.
Le pattern expand/include
La solution : laisser le client choisir quelles relations embarquer via un query parameter.
GET /orders?expand=customer,items
Sans expand, tu retournes les IDs. Avec expand, tu embarques les objets complets :
json{
"data": [
{
"id": 42,
"customer": { "id": 7, "name": "Alice", "email": "alice@example.com" },
"items": [
{ "id": 101, "product_id": 13, "quantity": 2, "price": 1200 }
]
}
]
}
L'implementation cote serveur :
typescriptapp.get("/orders", async (req, res) => {
const expands = (req.query.expand as string)?.split(",") || [];
let query = db.select().from(orders);
if (expands.includes("customer")) {
query = query.leftJoin(customers, eq(orders.customerId, customers.id));
}
if (expands.includes("items")) {
// Charge les items en une seule requete, pas en N requetes
}
const result = await query;
res.json({ data: result });
});
Limite les relations expandables. Ne laisse pas le client faire expand=customer.orders.items.product.reviews. Definis une liste blanche et une profondeur max.
Sparse fieldsets
Parfois tu n'as pas besoin de toute la ressource. Un select sur les champs :
GET /users?fields=id,name,avatar
GET /orders?fields=id,total,status&expand=customer&fields[customer]=id,name
JSON:API formalise ca avec fields[type] :
GET /orders?include=customer&fields[orders]=id,total&fields[customers]=id,name
Ca réduit drastiquement la taille des réponses. Sur une liste de 100 users, retourner seulement id, name et avatar au lieu de 20 champs peut diviser la taille par 5.
typescriptapp.get("/users", async (req, res) => {
const fields = (req.query.fields as string)?.split(",") || ["*"];
const allowedFields = ["id", "name", "email", "avatar", "created_at"];
const selectedFields = fields.filter(f => allowedFields.includes(f));
const users = await db
.select(selectedFields.map(f => users[f]))
.from(users);
res.json({ data: users });
});
Batch endpoints
Pour résoudre le N+1 sans expand, tu peux proposer un endpoint batch :
GET /users?ids=7,12,34,56
Le front récupéré ses 20 commandes, collecte les customer_id uniques, et fait une seule requête batch. Deux requêtes au total au lieu de 21.
typescriptapp.get("/users", async (req, res) => {
const ids = (req.query.ids as string)?.split(",").map(Number);
if (ids && ids.length > 100) {
return res.status(400).json({
detail: "Maximum 100 IDs par requete batch."
});
}
const users = await db.select().from(usersTable).where(inArray(usersTable.id, ids));
res.json({ data: users });
});
Quelle stratégie choisir
| Situation | Approche |
|---|---|
| Relation toujours nécessaire | Embedding par défaut |
| Relation parfois nécessaire | expand/include |
| Liste avec beaucoup de champs | Sparse fieldsets |
| Front qui agrege plusieurs ressources | Batch endpoints |
| Relation profonde ou circulaire | Linking avec IDs |
Résumé
- Les sub-resources modelisent les relations parent-enfant (max 2 niveaux)
- Le pattern expand/include laisse le client choisir les relations a embarquer
- Les sparse fieldsets reduisent la taille des réponses en selectionnant les champs
- Les batch endpoints resolvent le N+1 en permettant de charger plusieurs ressources en une requête
- Combine ces approches selon le cas d'usage plutot que d'en choisir une seule
Article précédent : Upload de fichiers Article suivant : HATEOAS