Forme di risposta JSON che sopravvivono cinque anni
Quasi ogni migrazione dolorosa di un’API risale a una decisione di forma presa in un pomeriggio e congelata dalla prima integrazione.
Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.
Ecco una risposta uscita nella v1 di qualcuno e che esce ancora oggi:
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
Tre anni dopo la collezione è abbastanza grande da richiedere la paginazione, e non c’è posto dove mettere un cursore. Il livello superiore è un array. Avvolgerlo cambia il tipo che ogni client analizza già, quindi il team pubblica /v2/users e mantiene due percorsi di codice per sempre. Nulla di quell’array era sbagliato quando è stato scritto. Semplicemente non aveva spazio per crescere.
L’argomento è tutto qui. Progettare le risposte non riguarda l’eleganza, riguarda quali cambiamenti restano economici.
Involucro o valore nudo
Un involucro è un oggetto di primo livello con il carico sotto una chiave:
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
L’argomento contro è reale: è rumore, e ogni client scrive .data. L’argomento a favore è che un oggetto è estensibile e un array nudo no. Potrai aggiungere più avanti un cursore, un totale, un avviso di deprecazione o un id di traccia senza cambiare il tipo di nulla che già c’è.
Quel che faccio io: involucro per le collezioni, oggetto nudo per una singola risorsa. Una singola risorsa è già un oggetto, quindi ha lo spazio di crescita che un involucro le avrebbe dato. Le collezioni ricevono l’involucro perché sono quelle che prima o poi avranno bisogno di metadati.
Qualunque cosa scegli, scegli una volta. Metà degli endpoint avvolti e metà nudi è peggio di entrambe le scelte. E non mettere un campo chiamato data dentro un campo chiamato data.
I tipi di campo su cui non si torna indietro
Gli ID sono stringhe. Sempre, anche finché sono ancora piccoli interi. Un numero JSON in JavaScript è un double IEEE 754, quindi ogni identificatore sopra 9007199254740991 viene arrotondato in silenzio all’arrivo e ora stai guardando un altro record. Twitter ci ha sbattuto passando agli ID Snowflake a 64 bit nel 2010 e ha spedito id_str accanto a id, e lo schema è rimasto. La meccanica sta in perché i tuoi ID JSON cambiano valore. Il punto di progetto è più stretto: un identificatore non è una quantità. Non ci sommi mai nulla, non lo ordini aritmeticamente, non ne fai la media, quindi un tipo numerico non ti compra niente e ti costa il giorno in cui passi agli UUID.
Il denaro è un intero in unità minori, oppure una stringa decimale. Mai un float.
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005 non è rappresentabile esattamente come double, quindi in JavaScript 1.005 * 100 fa 100.49999999999999, che arrotonda a 100 anziché a 101. Scegli una rappresentazione, porta la valuta accanto, e non lasciare mai entrare nello schema un price: 10.05 nudo, perché toglierlo dopo significa passare in rassegna ogni consumatore che ci fa aritmetica.
Le date sono stringhe RFC 3339 con scostamento esplicito. "2026-09-05T14:30:00Z". Non un timestamp Unix, non "05/09/2026", e soprattutto non un’ora locale senza scostamento, perché quella si analizza benissimo ed è sbagliata di ore. JSON non ha un tipo data, quindi questa convenzione esiste solo se la revisione del codice la fa rispettare. Formati di data e ora in JSON copre il resto.
null, assente e vuoto
Quattro forme, quattro significati:
| Forma | Significato |
|---|---|
"middleName": "Jane" |
Valore noto |
"middleName": null |
Noto come privo di valore |
| chiave assente | Non noto, non caricato o non permesso |
"tags": [] |
Noto come avente zero tag |
L’errore non è scegliere la convenzione sbagliata, è usarle tutte e quattro in modo incoerente, così che un client non riesca a distinguere «questo utente non ha secondo nome» da «hai chiesto una proiezione parziale». Decidi campo per campo e tieni la linea.
Due trappole. JSON.stringify scarta le chiavi il cui valore è undefined ma conserva null, quindi un produttore JavaScript oscilla fra assente e null a seconda che una variabile sia stata assegnata. E il required di JSON Schema afferma che una chiave è presente, non che è non nulla: {"name": null} soddisfa required: ["name"]. Se intendi non nullo, mettilo nel tipo.
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
Genera la prima bozza da un payload reale con il generatore di schemi, poi sistema a mano la nullabilità, perché un generatore vede solo i valori che per caso erano nel tuo campione.
Nomi
Scegli camelCase o snake_case, applicalo a ogni chiave di ogni endpoint, e smetti di riaprire la discussione. Maiuscole miste in un solo documento sono il segnale più chiaro che due squadre hanno scritto due metà senza leggersi, e rompono il trucco economico di mappare le chiavi meccanicamente sui campi di una struct. created_at batte ts. Una chiave che ha bisogno di un commento ha bisogno di un nome migliore.
Errori
Un corpo d’errore ha bisogno di tre cose distinte, e quasi tutti ne spediscono una:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Balance is 320 minor units, transfer requires 1005.",
"code": "INSUFFICIENT_FUNDS",
"pointer": "/transfer/amountMinor"
}
Un codice macchina stabile su cui il client si dirama, e che ti impegni a non cambiare mai. Un messaggio umano che resti libero di riformulare o tradurre, e su cui nessun client dovrebbe fare confronti. Un puntatore al campo incriminato, idealmente un JSON Pointer RFC 6901 così che si risolva meccanicamente sul corpo della richiesta.
La RFC 9457, Problem Details for HTTP APIs, standardizza type, title, status, detail e instance, e ammette esplicitamente membri di estensione, quindi puoi adottarla e portarti comunque dietro il tuo code. Ha reso obsoleta la RFC 7807, il nome con cui la conoscono quasi tutte le implementazioni esistenti. Usarla ti dà una forma che gli strumenti altrui già capiscono, cosa che {"error": "qualcosa è andato storto"} non farà mai. Per i fallimenti di validazione, restituiscili tutti anziché il primo.
I cursori battono gli offset
La paginazione a offset diventa silenziosamente lacunosa nel momento in cui ci sono scritture concorrenti. La pagina uno restituisce le righe da 1 a 50. Una riga viene inserita in cima. La pagina due, offset=50, ora comincia da quella che era la riga 50, quindi il consumatore vede quel record due volte. Le cancellazioni fanno l’opposto e saltano record del tutto. Nulla dà errore; salta fuori settimane dopo come una discrepanza di riconciliazione.
Un cursore codifica una posizione in un ordinamento stabile, di norma la chiave di ordinamento più un id per sciogliere i pari, così che gli inserimenti sopra siano irrilevanti. Documenta il cursore come opaco per poterne cambiare la codifica in seguito, e restituisci un hasMore esplicito anziché far dedurre ai client la fine da una pagina corta. Salta totalCount a meno che qualcuno ne abbia davvero bisogno e tu sia disposto a pagare la seconda query.
L’aggiunta è l’unico cambiamento gratuito
Il contratto che rende possibile l’evoluzione vive dal lato client: i campi sconosciuti vanno ignorati. Se questo regge, aggiungere un campo non rompe nulla e puoi rilasciare in continuo. Se un consumatore valida in modo severo, o genera tipi con additionalProperties: false, ogni aggiunta rompe qualcuno e resti sulla v1 per sempre. Dillo nel primo paragrafo della documentazione.
Tutto il resto è una versione: togliere un campo, rinominarne uno, cambiarne il tipo, cambiare che cosa significa un valore, stringere ciò che accetti, oppure rendere non nullabile un campo nullabile. Far passare il payload d’esempio del rilascio precedente e quello attuale in un diff JSON intercetta il cambio di tipo che nessuno intendeva fare.
Gli array eterogenei costano al consumatore più di quanto ti facciano risparmiare
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
Ora ogni consumatore scrive uno smistamento, e ogni consumatore tipizzato staticamente scrive a mano un’unione etichettata. Se devi mescolare forme, discriminale: un kind obbligatorio con un insieme chiuso e documentato di valori, presente su ogni membro. Allora l’unione diventa meccanica. La versione imperdonabile è il terzo elemento, dove la forma varia senza etichetta e i client vanno a fiuto sulle chiavi. Lo stesso per un campo che a volte è una stringa e a volte un oggetto: ti risparmia un incremento di versione e costa a ogni client una guardia di tipo per sempre.
Quando la risposta diventa grande
Ogni motore ha un tetto rigido sulla lunghezza delle stringhe, ed è più basso di quanto ci si aspetti: su V8 a 64 bit (Chrome e Node) sono 536.870.888 caratteri, quindi una risposta oltre il mezzo gigabyte circa non può nemmeno essere tenuta come stringa, figurarsi analizzata. Altri motori stanno più in alto, ma tutti hanno un tetto, e l’albero di oggetti analizzato costa parecchie volte quanto costava il testo. Molto prima di tutto ciò, un’analisi di più secondi blocca il thread principale.
Tre vie d’uscita, in ordine di quanto disturbano l’API. Paginare più fitto perché nessuna singola risposta sia grande. Trasmettere record delimitati da righe così che il consumatore lavori mentre riceve invece di aspettare una parentesi di chiusura (NDJSON e JSON Lines). Oppure spostare l’esportazione massiva fuori dall’API sincrona del tutto: restituisci un id di job e un URL firmato per il file finito. File JSON di grandi dimensioni copre il lato consumatore.
La lista di controllo
- Involucro per le collezioni, oggetti nudi per le risorse singole, e coerenza.
- Gli ID sono stringhe. Il denaro è in unità minori o in stringa decimale. Le date sono RFC 3339 con scostamento.
- Definisci che cosa significano null, assente e vuoto, campo per campo.
- Una sola convenzione di maiuscole su ogni endpoint.
- Gli errori portano un codice stabile, un messaggio modificabile e un puntatore al campo. Valuta la RFC 9457.
- Paginazione a cursore, cursori opachi, un
hasMoreesplicito. - Di’ ai client di ignorare i campi sconosciuti, poi tieni ogni altro cambiamento dietro una versione.
- Discrimina ogni array eterogeneo con un
kindobbligatorio.
Niente di tutto questo è costoso al primo giorno. Tutto quanto è costoso al millesimo, ed è l’unica ragione per cui vale la pena discuterne adesso.