Points clés pour concevoir des contrats de données d'API JSON
L’essentiel : Six choses sont très difficiles à changer une fois livrées : les enveloppes de réponse, les formats d'erreur, la forme de pagination, la représentation des identifiants et du temps, la sémantique de null contre absent, et l'évolution de version.

L'essentiel : verrouillez l'enveloppe de réponse, le format d'erreur, la représentation des identifiants et du temps, et la sémantique null-contre-absent dans le contrat avant de débattre des détails de pagination et de nommage ; enveloppez toujours le niveau supérieur dans une enveloppe data pour que pagination et identifiants de traçage puissent être ajoutés plus tard sans casser les appelants existants.
Il n'y a pas de réponses standard en conception d'API, mais il y a des décisions « difficiles à reprendre une fois prises » qu'il vaut mieux avoir pesées avant d'écrire la première ligne de code.
1. Envelopper la réponse ou non
Trois styles :
// Style A: bare response
{ "id": 1, "name": "张三" }
// Style B: data envelope
{ "data": { "id": 1, "name": "张三" } }
// Style C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Style | Avantages | Inconvénients |
|---|---|---|
| Réponse nue | Le plus simple, une couche de moins pour le frontend | Impossible d'attacher des métadonnées ; un tableau au niveau supérieur porte des risques de détournement JSON et d'évolution |
| Enveloppe data | Facile d'ajouter meta / errors / links |
Un niveau d'imbrication de plus, le frontend doit écrire res.data.data |
| Data + métadonnées | Meilleure observabilité | Exige un accord strict sur les champs qui appartiennent à meta |
Recommandation : utilisez uniformément une enveloppe data pour les listes et les détails, et ne mettez jamais le niveau supérieur en tableau. Ainsi, ajouter plus tard de la pagination ou des identifiants de traçage ne casse jamais le contrat.
2. Format d'erreur : copiez la RFC 9457
Inventer sa propre structure d'erreur est une énorme source de coût de collaboration. La RFC 9457 (qui remplace la RFC 7807) définit application/problem+json :
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Champ | Signification | Obligatoire |
|---|---|---|
type |
URI stable du type de problème | Recommandé |
title |
Court résumé lisible par l'humain | Recommandé |
status |
Code de statut HTTP | Recommandé |
detail |
Explication spécifique de ce qui s'est passé cette fois | Facultatif |
instance |
L'instance de ressource qui a eu le problème | Facultatif |
| Champs d'extension | Champs métier arbitraires | Facultatif |
Les champs d'extension (comme balance / required ci-dessus) sont autorisés par la spécification, et le frontend peut les utiliser pour des messages précis au lieu de parser le texte de detail.
3. Pagination : le curseur bat l'offset
// Offset pagination
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Cursor pagination
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Dimension | Pagination par offset | Pagination par curseur |
|---|---|---|
| Difficulté d'implémentation | Faible (LIMIT/OFFSET) |
Moyenne (nécessite une clé de tri stable) |
| Performance sur les pages profondes | Mauvaise (plus l'offset est grand, plus c'est lent) | Bonne |
| Quand les données changent | Doublons ou éléments manquants | Stable |
| Saut à une page | Possible | Impossible |
| Nécessite un total | Exige un comptage supplémentaire | Non requis |
Recommandation : utilisez la pagination par offset pour les tables d'administration (elles ont besoin de sauts de page et de totaux), et la pagination par curseur pour les fils infinis destinés aux utilisateurs.
4. Identifiants et temps
4.1 Utilisez toujours des chaînes pour les identifiants
Le raisonnement a été couvert dans l'article sur les types : côté JavaScript, les valeurs au-delà de 2^53 perdent silencieusement de la précision.
Même si l'identifiant actuel est un petit entier auto-incrémenté, l'utilisation de chaînes laisse la place de passer plus tard à des identifiants snowflake.
{ "id": "1000000000000000001" }
4.2 Utilisez des chaînes RFC 3339 pour le temps
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- N'utilisez pas de nombres d'horodatage en millisecondes : ils sont difficiles à lire, et l'unité (secondes/millisecondes) est fréquemment confondue ;
- Spécifiez explicitement le fuseau horaire : soit avec
Z, soit avec un offset ; n'utilisez jamais « heure locale sans fuseau » ; - Nommez les champs uniformément en
xxxAtpour qu'on voie au premier coup d'œil qu'il s'agit d'un instant.
5. null et absent
Vous devez choisir et l'écrire dans la documentation :
| Approche | Exprimer « pas de valeur » | Exprimer « ne pas modifier » |
|---|---|---|
| Approche A | La valeur du champ est null |
Omettre le champ dans la requête |
| Approche B | Le champ n'existe pas | Omettre le champ dans la requête |
Le problème de l'approche A est qu'« omis » et « envoyé comme null » signifient des choses différentes dans un PATCH, ce qui est facile à mal interpréter ;
le problème de l'approche B est que le JSON.stringify du frontend supprime les champs dont la valeur est undefined, ce qui correspond justement à la sémantique.
Recommandation pratique :
- Utilisez l'approche A pour les corps de réponse (
nullsignifie clairement pas de valeur, et le frontend n'a pas besoin de distinguerundefined) ; - Utilisez l'approche B pour les corps de requête (surtout
PATCH), combinée avec Merge Patch ou JSON Patch pour rendre la sémantique explicite.
6. Nommage et structure des champs
| Décision | Choix courants | Mise en garde |
|---|---|---|
| Style de nommage | camelCase (écosystème JS) / snake_case (Python, bases de données) |
Uniforme dans le projet, ne pas mélanger |
| Valeurs d'énumération | Minuscules avec underscores, "pending_review" |
N'utilisez pas de nombres ; ils sont difficiles à lire |
| Champs booléens | isActive / enabled |
N'utilisez pas status: 0/1 |
| Champs de tableau | Noms pluriels, items |
Retournez [] pour les tableaux vides, jamais null |
| Profondeur d'imbrication | Pas plus de 3 niveaux | Au-delà, découpez ou proposez un paramètre expand |
Retourner [] au lieu de null pour les tableaux vides compte beaucoup : data.items.map(...) côté frontend ne plantera pas à cause d'un null.
7. Stratégie d'évolution
| Changement | Rupture de compatibilité | Comment gérer |
|---|---|---|
| Ajouter un champ facultatif | Non | Livrer directement |
| Ajouter un champ obligatoire | Oui | Ajouter une valeur par défaut, ou livrer par version |
| Supprimer un champ | Oui | Le marquer d'abord déprécié, observer les appelants, puis supprimer |
| Changer le type d'un champ | Oui | Ajouter un nouveau nom de champ et écrire les deux pendant la transition |
| Changer l'ensemble des valeurs d'énumération | Dépend | Le client doit avoir un repli pour les valeurs inconnues |
| Changer la sémantique d'un champ | Oui | Le traiter comme un nouveau champ |
Trois règles de discipline :
- Les clients doivent tolérer les champs inconnus (ne vous enfermez pas avec
additionalProperties: false) ; - Les clients doivent avoir une branche de repli pour les valeurs d'énumération inconnues ;
- Conservez les champs dépréciés pendant au moins une version majeure, et marquez-les avec
Deprecationdans les en-têtes de réponse ou la documentation.
8. Un exemple complet
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Cette réponse reflète toutes les conventions ci-dessus : enveloppe data, identifiants en chaînes, montants en chaînes, horodatages avec fuseau horaire,
null signifiant absent, tableau vide, pagination par curseur, et meta pour les informations d'observabilité.
9. Liste de vérification
- [ ] Le niveau supérieur n'est pas un tableau ; utilisez uniformément une enveloppe
data; - [ ] Les réponses d'erreur suivent la RFC 9457 et incluent un
typestable ; - [ ] La forme de pagination correspond au scénario métier ;
- [ ] Les identifiants sont des chaînes, et le temps utilise la RFC 3339 ;
- [ ] La sémantique de
nullet d'absent est écrite dans la documentation ; - [ ] Le style de nommage est uniforme dans le projet, et les énumérations utilisent des chaînes minuscules ;
- [ ] Le client a des replis pour les champs inconnus et les valeurs d'énumération inconnues ;
- [ ] Les changements cassants passent par une version ou une transition à double écriture.
FAQ
Comment concevoir une API JSON évolutive ?
Enveloppez uniformément listes et détails dans une enveloppe data et ne mettez jamais le niveau supérieur en tableau, pour que pagination et identifiants de traçage puissent être ajoutés sans casser le contrat. Utilisez des identifiants en chaînes et des horodatages RFC 3339, et écrivez la sémantique null-contre-absent dans la documentation. Gardez les nouveaux champs facultatifs et sémantiquement indépendants, et donnez aux clients des replis pour les champs inconnus et les valeurs d'énumération.
Pourquoi ne pas inventer un format d'erreur personnalisé ?
Inventer sa propre structure d'erreur est une énorme source de coût de collaboration. Copiez application/problem+json de la RFC 9457, identifiez les problèmes avec un type stable, et autorisez des champs d'extension comme balance et required, pour que le frontend puisse construire des messages précis sans parser le texte de detail.
Faut-il utiliser la pagination par curseur pour tous les points de terminaison ?
Non. Les tables d'administration ont besoin de sauts de page et de totaux, donc la pagination par offset convient mieux ; les fils infinis destinés aux utilisateurs devraient utiliser la pagination par curseur, qui reste stable sur les pages profondes et évite doublons ou lacunes quand les données changent. Choisissez selon que le scénario a besoin de sauts de page et de totaux.