Formas de respuesta JSON que sobreviven cinco años
Casi toda migración dolorosa de API se remonta a una decisión de forma tomada en una tarde y congelada por la primera integración.
Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.
Esta es una respuesta que salió en la v1 de alguien y sigue saliendo hoy:
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
Tres años después la colección es lo bastante grande como para necesitar paginación, y no hay dónde poner un cursor. El nivel superior es un array. Envolverlo cambia el tipo que todo cliente ya parsea, así que el equipo publica /v2/users y mantiene dos rutas de código para siempre. Nada de ese array estaba mal cuando se escribió. Simplemente no tenía sitio para crecer.
De eso va todo el asunto. El diseño de respuestas no va de elegancia, va de qué cambios siguen siendo baratos.
Envoltorio o valor desnudo
Un envoltorio es un objeto de nivel superior con el payload bajo una clave:
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
El argumento en contra es real: es ruido, y todo cliente escribe .data. El argumento a favor es que un objeto es extensible y un array desnudo no. Puedes añadir después un cursor, un total, un aviso de obsolescencia o un id de traza sin cambiar el tipo de nada de lo que ya está.
Lo que yo hago: envolver las colecciones, devolver el objeto desnudo para un recurso único. Un recurso único ya es un objeto, así que tiene el espacio para crecer que le habría dado un envoltorio. Las colecciones llevan envoltorio porque son las que acaban necesitando metadatos.
Elijas lo que elijas, elige una vez. La mitad de tus endpoints envueltos y la otra mitad desnudos es peor que cualquiera de las dos. Y no metas un campo llamado data dentro de un campo llamado data.
Los tipos de campo que no se pueden deshacer
Los IDs son cadenas. Siempre, incluso mientras siguen siendo enteros pequeños. Un número JSON en JavaScript es un double IEEE 754, así que cualquier identificador por encima de 9007199254740991 se redondea en silencio al llegar y estás mirando otro registro. Twitter se topó con esto al pasar a IDs Snowflake de 64 bits en 2010 y publicó id_str junto a id, y el patrón cuajó. La mecánica está en por qué tus IDs de JSON cambian de valor. El punto de diseño es más estrecho: un identificador no es una cantidad. Nunca le sumas, ni lo ordenas aritméticamente, ni lo promedias, así que un tipo numérico no te aporta nada y te cuesta el día que pases a UUIDs.
El dinero es un entero en unidades menores, o una cadena decimal. Nunca un float.
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005 no se representa exactamente como double, así que en JavaScript 1.005 * 100 es 100.49999999999999, que se redondea a 100 en vez de a 101. Elige una representación, lleva la moneda al lado, y no dejes entrar nunca un price: 10.05 desnudo en el esquema, porque sacarlo después significa auditar a todos los consumidores que hacen aritmética con él.
Las fechas son cadenas RFC 3339 con desplazamiento explícito. "2026-09-05T14:30:00Z". Ni una marca Unix, ni "05/09/2026", y sobre todo nada de hora local sin desplazamiento, porque eso parsea bien y está equivocado por horas. JSON no tiene tipo fecha, así que esta convención solo existe si la revisión de código la hace cumplir. Formatos de fecha y hora en JSON cubre el resto.
null, ausente y vacío
Cuatro formas, cuatro significados:
| Forma | Significado |
|---|---|
"middleName": "Jane" |
Valor conocido |
"middleName": null |
Se sabe que no hay valor |
| clave ausente | No se sabe, no se cargó o no está permitido |
"tags": [] |
Se sabe que hay cero etiquetas |
El error no es elegir la convención equivocada, es usar las cuatro de forma inconsistente, de modo que un cliente no puede distinguir «este usuario no tiene segundo nombre» de «pediste una proyección parcial». Decide por campo y mantén la línea.
Dos trampas. JSON.stringify descarta las claves cuyo valor es undefined pero conserva null, así que un productor en JavaScript alterna entre ausente y null según si una variable fue asignada. Y el required de JSON Schema afirma que una clave está presente, no que no sea null: {"name": null} satisface required: ["name"]. Si lo que quieres decir es no nulo, ponlo en el tipo.
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
Genera el primer borrador a partir de un payload real con el generador de esquemas, y luego arregla la nulabilidad a mano, porque un generador solo ve los valores que casualmente había en tu muestra.
Nomenclatura
Elige camelCase o snake_case, aplícalo a todas las claves de todos los endpoints y deja de tener esa conversación. Mezclar estilos en un mismo documento es la señal más clara de que dos equipos escribieron dos mitades y ninguno leyó al otro, y rompe el truco barato del cliente de mapear claves mecánicamente sobre campos de estructura. created_at es mejor que ts. Una clave que necesita un comentario necesita un nombre mejor.
Errores
Un cuerpo de error necesita tres cosas separadas, y la mayoría envía 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 código máquina estable por el que el cliente ramifica, y que te comprometes a no cambiar nunca. Un mensaje humano que puedes reformular o traducir libremente, y con el que ningún cliente debería hacer coincidencias. Y un puntero al campo ofensor, idealmente un JSON Pointer de la RFC 6901 para que se resuelva mecánicamente contra el cuerpo de la petición.
La RFC 9457, Problem Details for HTTP APIs, estandariza type, title, status, detail e instance, y admite explícitamente miembros de extensión, así que puedes adoptarla y seguir llevando tu propio code. Dejó obsoleta a la RFC 7807, el nombre con el que la conoce la mayoría de implementaciones existentes. Usarla te da una forma que el instrumental de otra gente ya entiende, cosa que {"error": "algo salió mal"} nunca hará. Para fallos de validación, devuélvelos todos en vez del primero.
Los cursores ganan a los desplazamientos
La paginación por desplazamiento pierde datos en silencio en cuanto hay escrituras concurrentes. La página uno devuelve las filas 1 a 50. Se inserta una fila cerca de arriba. La página dos, offset=50, empieza ahora en lo que era la fila 50, así que el consumidor ve ese registro dos veces. Los borrados lo hacen al revés y se saltan registros del todo. Nada da error; aflora semanas más tarde como un descuadre en una conciliación.
Un cursor codifica una posición en un orden estable, normalmente la clave de orden más un id de desempate, así que las inserciones por encima son irrelevantes. Documenta el cursor como opaco para poder cambiar su codificación después, y devuelve un hasMore explícito en vez de hacer que los clientes deduzcan el final de una página corta. Sáltate totalCount salvo que alguien lo necesite de verdad y estés dispuesto a pagar la segunda consulta.
El cambio aditivo es el único cambio gratis
El contrato que hace posible la evolución vive en el lado del cliente: los campos desconocidos deben ignorarse. Si eso se cumple, añadir un campo no rompe nada y puedes publicar de forma continua. Si un consumidor valida estrictamente, o genera tipos con additionalProperties: false, cada añadido rompe a alguien y te quedas en la v1 para siempre. Dilo en el primer párrafo de tu documentación.
Todo lo demás es una versión: quitar un campo, renombrar uno, cambiar su tipo, cambiar qué significa un valor, endurecer lo que aceptas o hacer no nulable un campo nulable. Pasar el payload de ejemplo de la última entrega y el de esta por un diff de JSON caza el cambio de tipo que nadie pretendía hacer.
Los arrays heterogéneos cuestan al consumidor más de lo que te ahorran
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
Todo consumidor escribe ahora un despacho, y todo consumidor tipado estáticamente escribe a mano una unión etiquetada. Si tienes que mezclar formas, discrimínalas: un kind obligatorio con un conjunto cerrado y documentado de valores, presente en cada miembro. Entonces la unión es mecánica. La versión imperdonable es el tercer elemento, donde la forma varía sin etiqueta y los clientes olfatean claves. Lo mismo para un campo que a veces es una cadena y a veces un objeto: te ahorra un salto de versión y le cuesta a cada cliente un guard de tipos para siempre.
Cuando la respuesta se hace grande
Todo motor tiene un techo duro de longitud de cadena, y es más bajo de lo que la gente espera: en V8 de 64 bits (Chrome y Node) son 536.870.888 caracteres, así que una respuesta por encima de aproximadamente medio gigabyte no puede ni sostenerse como cadena, ya no digamos parsearse. Otros motores están más arriba, pero todos tienen techo, y el árbol de objetos parseado cuesta varias veces lo que costó el texto. Mucho antes de todo eso, un parseo de varios segundos bloquea el hilo principal.
Tres salidas, por orden de cuánto alteran la API. Paginar más fino para que ninguna respuesta sea grande. Transmitir registros delimitados por líneas para que el consumidor trabaje según recibe en vez de esperar a una llave de cierre (NDJSON y JSON Lines). O sacar la exportación masiva de la API síncrona por completo: devuelve un id de trabajo y una URL firmada para el archivo terminado. Archivos JSON grandes cubre el lado del consumidor.
La lista de comprobación
- Envuelve las colecciones, devuelve objetos desnudos para recursos únicos, y sé consistente.
- Los IDs son cadenas. El dinero, unidades menores o cadena decimal. Las fechas, RFC 3339 con desplazamiento.
- Define qué significan null, ausente y vacío, campo por campo.
- Una sola convención de nomenclatura en todos los endpoints.
- Los errores llevan un código estable, un mensaje mutable y un puntero al campo. Considera la RFC 9457.
- Paginación por cursor, cursores opacos, un
hasMoreexplícito. - Dile a los clientes que ignoren los campos desconocidos, y luego deja todo otro cambio detrás de una versión.
- Discrimina todo array heterogéneo con un
kindobligatorio.
Nada de esto es caro el primer día. Todo esto es caro el día mil, que es la única razón por la que merece la pena discutirlo ahora.