Skip to content
jsonforge.app
Retour au blog
Guide9 min de lecture

Conception des réponses JSON d'API REST : bonnes pratiques et anti-patterns

Les octets réellement renvoyés par une API REST comptent autant que les routes et les codes de statut — chaque client qui appelle votre API est écrit contre la forme de son JSON, et remodeler ce JSON plus tard est un changement cassant, quel que soit le degré de gravité interne. Ce guide couvre les décisions de conception de réponse qui sont bon marché à bien prendre au départ et coûteuses à corriger une fois que des clients existent.

Choisissez une forme d'enveloppe et ne vous en écartez jamais

Une enveloppe est l'enveloppe de haut niveau cohérente que chaque réponse utilise, quel que soit le point de terminaison : un ensemble connu de clés comme `data`, `error` et `meta` que la couche HTTP d'un client peut traiter génériquement au lieu d'écrire une analyse spécifique par point de terminaison. Sans enveloppe, chaque point de terminaison qui renvoie une forme légèrement différente oblige chaque client à écrire un analyseur légèrement différent.

Une enveloppe cohérente : succès et échec partagent la même forme de haut niveau.
json
// Success
{
  "data": { "id": 42, "name": "Ada Lovelace" },
  "meta": { "requestId": "a1b2c3" }
}

// Failure
{
  "data": null,
  "error": { "code": "NOT_FOUND", "message": "User 42 does not exist" },
  "meta": { "requestId": "a1b2c4" }
}

Les noms de clés précis importent moins que la cohérence : quoi que vous choisissiez, chaque point de terminaison le renvoie, `data` est toujours là où vit la charge utile, et un client peut écrire une seule fonction `unwrap(response)` pour toute l'API au lieu d'une par ressource.

Ne renvoyez jamais un tableau (ou un scalaire) nu au niveau supérieur

`GET /users` renvoyant un tableau JSON nu — `[{...}, {...}]` — paraît naturel, mais cela vous interdit à jamais d'ajouter des métadonnées au niveau réponse sans changement cassant. Dès que vous avez besoin d'un total, d'un curseur de page suivante ou d'un message d'avertissement à côté de la liste, vous devez changer le type de haut niveau de tableau vers objet, ce qui casse tous les clients qui font `response.map(...)` ou `response.length` directement sur le corps analysé.

Enveloppez les collections dans un champ nommé dès le premier jour — `{ "users": [...] }` ou `{ "data": [...] }` — même si vous êtes sûr de ne jamais avoir besoin de métadonnées supplémentaires. Ajouter une clé sœur à un objet est un changement rétrocompatible ; transformer un tableau en objet ne l'est pas, pour aucun client, dans aucun langage.

Null contre omis : ce ne sont pas les mêmes signaux

Un champ présent avec la valeur `null` et un champ totalement absent de la réponse se ressemblent à première vue (« pas de valeur »), mais ils signifient des choses différentes et les clients doivent pouvoir les distinguer. `null` dit : ce champ est un concept réel et connu pour cette ressource, et sa valeur actuelle est explicitement rien (un utilisateur sans `middleName`). Omis dit : ce champ ne s'applique pas, n'a pas été chargé ou n'a pas été demandé (une requête à champs épars n'ayant demandé que `id` et `name`).

Cette distinction est porteuse de sens en particulier dans la sémantique PATCH : sous JSON Merge Patch (RFC 7396), envoyer un champ à `null` signifie supprimer ce champ de la cible, tandis que ne pas inclure le champ dans le corps du patch signifie le laisser inchangé. Confondre les deux — ou être incohérent sur les champs pouvant légitimement être `null` — produit des API où les clients ne peuvent pas distinguer sans risque « effacer cette valeur » de « je n'ai pas envoyé de mise à jour pour ce champ ».

Pagination : curseur contre décalage, et leur forme en JSON

La pagination par décalage/limite (`?offset=40&limit=20`, ou `?page=3&pageSize=20`) est la plus simple à implémenter et à raisonner, mais elle a un vrai problème de correction : si des lignes sont insérées ou supprimées entre les requêtes de pages, les décalages bougent sous les pieds du client, provoquant des lignes sautées ou dupliquées. Elle devient aussi coûteuse en SQL à grande profondeur, puisque la base doit quand même parcourir et écarter toutes les lignes sautées.

La pagination par curseur remet au client un jeton opaque pointant vers « la ligne après la dernière vue » plutôt qu'une position numérique, donc elle reste stable même quand les données sous-jacentes changent, et reste bon marché à n'importe quelle profondeur. La contrepartie : les clients ne peuvent pas sauter à un numéro de page arbitraire — seulement avancer (et, si vous le prenez en charge, reculer) depuis un curseur.

Une réponse paginée par curseur — pas de math de décalage, juste un jeton opaque de page suivante.
json
{
  "data": [ { "id": 101 }, { "id": 102 } ],
  "meta": {
    "nextCursor": "eyJpZCI6MTAyfQ==",
    "hasMore": true
  }
}

Privilégiez la pagination par curseur pour tout ce qui repose sur des données changeant fréquemment (flux, journaux, flux d'événements) ou de grandes tables ; la pagination par décalage convient aux petites collections majoritairement statiques où le saut à la page N est réellement utile (un tableau d'administration avec sélecteur de page).

Objets d'erreur et conventions de nommage

Une réponse d'erreur devrait porter plus qu'un code de statut HTTP et une chaîne : un `code` stable et lisible par une machine sur lequel un client peut brancher sans risque (`"VALIDATION_ERROR"`, pas une phrase), un `message` lisible par l'humain destiné aux journaux et aux développeurs plutôt qu'à l'analyse, et pour les échecs de validation, un tableau `details` nommant exactement quels champs ont échoué et pourquoi.

Une forme d'erreur réellement exploitable pour un client.
json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request failed validation",
    "details": [
      { "field": "email", "message": "must be a valid email address" },
      { "field": "age", "message": "must be at least 0" }
    ]
  }
}

Pour le nommage des clés, camelCase et snake_case sont tous deux de bons choix — ce qui fait vraiment mal, c'est de les mélanger au sein d'une même API, ce qui force chaque client à écrire une logique de correspondance de champs incohérente selon le point de terminaison appelé. Les consommateurs très JavaScript/TypeScript préfèrent généralement camelCase car il correspond à la déstructuration native ; les écosystèmes très Python et Ruby préfèrent souvent snake_case. Choisissez-en un pour toute l'API et appliquez-le aussi de façon cohérente aux booléens (`isActive`/`hasAccess`, pas un mélange de `active` et `has_access`).

Anti-patterns courants

Du JSON sous forme de chaîne dans un champ JSON — `"metadata": "{\"plan\":\"pro\"}"` au lieu de `"metadata": { "plan": "pro" }` — force chaque client à analyser deux fois et sacrifie la sûreté de type sans bénéfice. C'est presque toujours le signe que le serveur a sérialisé telle quelle une colonne blob de base de données au lieu de la décoder avant d'envoyer la réponse.

Des formats de dates incohérents — certains champs en horodatages Unix, d'autres en `"08/15/2026"`, d'autres en ISO 8601 complet — forcent les clients à écrire une analyse de date par champ au lieu d'un seul gestionnaire partagé. Standardisez sur ISO 8601 en UTC avec un décalage explicite (`"2026-08-15T09:30:00Z"`) partout.

La fuite de champs internes de base de données — renvoyer verbatim dans une réponse d'API des artefacts d'ORM comme `password_hash`, des clés étrangères internes destinées à un autre service, ou des champs de comptabilité d'ORM (`__v`, `_id`, `created_by_worker_id`). Mappez toujours votre modèle de base de données vers un DTO de réponse explicite plutôt que de sérialiser le modèle directement ; c'est la seule façon fiable de garantir qu'un changement de schéma interne ne devient pas silencieusement un changement d'API public.

FAQ

Les réponses d'erreur doivent-elles utiliser la même enveloppe que les réponses de succès ?
Oui. Si les succès sont `{ "data": ..., "meta": ... }`, les erreurs devraient avoir la même forme de haut niveau avec `data: null` et un objet `error` rempli, plutôt qu'une structure entièrement différente. Cela permet au code de traitement des réponses d'un client de vérifier une seule chose — la présence de `error` — au lieu de se brancher sur la forme de la réponse par code de statut.
camelCase ou snake_case — le choix importe-t-il vraiment ?
Peu en soi, mais la cohérence compte énormément. Choisissez une convention pour toute l'API en fonction de l'écosystème de vos principaux consommateurs, documentez-la et appliquez-la partout — y compris les préfixes booléens et les clés d'objets imbriqués. Le coût n'est pas la convention elle-même, c'est une API où différents points de terminaison utilisent des conventions différentes et où chaque client a besoin d'une logique de correspondance spécifique.
Une nouvelle API doit-elle utiliser la pagination par curseur ou par décalage ?
Privilégiez la pagination par curseur, sauf si vous devez spécifiquement permettre aux utilisateurs de sauter à un numéro de page arbitraire sur des données petites et majoritairement statiques. La pagination par curseur reste correcte quand des lignes sont ajoutées ou supprimées en cours de pagination et reste bon marché à toute profondeur ; la pagination par décalage se dégrade en correction comme en performance à mesure que le jeu de données grossit ou change.
Pourquoi ne pas sérialiser directement mon modèle de base de données comme réponse d'API ?
Parce que votre schéma de base de données et votre contrat d'API public évoluent pour des raisons différentes et selon des calendriers différents. Sérialiser le modèle directement signifie que chaque migration, colonne ajoutée ou mise à niveau d'ORM risque de changer silencieusement — ou de faire fuiter — la forme de votre réponse publique. Un DTO de réponse explicite découple les deux, au prix d'une étape de correspondance supplémentaire par point de terminaison.

Essayez ces outils

Articles associés