Puntos clave para diseñar contratos de datos de API JSON
En resumen: Seis cosas son muy difíciles de cambiar una vez publicadas: los envoltorios de respuesta, los formatos de error, la forma de la paginación, la representación de IDs y tiempo, la semántica de null frente a ausente, y la evolución de versiones.

Conclusión: fija en el contrato el envoltorio de respuesta, el formato de error, la representación de IDs y tiempo, y la semántica de null frente a ausente antes de debatir detalles de paginación y nomenclatura; envuelve siempre el nivel superior en un envoltorio data para que la paginación y los IDs de trazabilidad puedan añadirse después sin romper a los consumidores existentes.
En el diseño de API no hay respuestas estándar, pero sí algunas decisiones "difíciles de retirar una vez tomadas" que conviene pensar antes de escribir la primera línea de código.
1. Envolver la respuesta o no
Tres estilos:
// Estilo A: respuesta desnuda
{ "id": 1, "name": "张三" }
// Estilo B: envoltorio data
{ "data": { "id": 1, "name": "张三" } }
// Estilo C: data + metadatos
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Estilo | Ventajas | Inconvenientes |
|---|---|---|
| Respuesta desnuda | Lo más simple, una capa menos para el frontend | No se pueden adjuntar metadatos; un array en el nivel superior acarrea riesgos de secuestro de JSON y de evolución |
| Envoltorio data | Fácil añadir meta / errors / links |
Un nivel más de anidamiento, el frontend debe escribir res.data.data |
| Data + metadatos | La mejor observabilidad | Requiere un acuerdo estricto sobre qué campos pertenecen a meta |
Recomendación: usa un envoltorio data de forma uniforme tanto para listas como para detalles, y nunca hagas que el nivel superior sea un array. Así, añadir paginación o IDs de trazabilidad después nunca rompe el contrato.
2. Formato de error: copia RFC 9457
Inventar tu propia estructura de error es una fuente enorme de coste de colaboración. RFC 9457 (que sustituye a RFC 7807) define 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"
}
| Campo | Significado | Obligatorio |
|---|---|---|
type |
URI estable del tipo de problema | Recomendado |
title |
Resumen corto legible por humanos | Recomendado |
status |
Código de estado HTTP | Recomendado |
detail |
Explicación concreta de lo que ocurrió esta vez | Opcional |
instance |
La instancia del recurso que tuvo el problema | Opcional |
| Campos de extensión | Campos de negocio arbitrarios | Opcional |
Los campos de extensión (como balance / required arriba) están permitidos por la especificación, y el frontend puede usarlos para mensajes precisos en lugar de analizar el texto de detail.
3. Paginación: cursor gana a offset
// Paginación por offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Paginación por cursor
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Dimensión | Paginación por offset | Paginación por cursor |
|---|---|---|
| Dificultad de implementación | Baja (LIMIT/OFFSET) |
Media (requiere una clave de ordenación estable) |
| Rendimiento en páginas profundas | Malo (cuanto mayor el offset, más lento) | Bueno |
| Cuando cambian los datos | Duplicados o elementos que faltan | Estable |
| Salto a una página | Posible | No posible |
| Necesita un recuento total | Requiere un count extra | No requerido |
Recomendación: usa paginación por offset para tablas de administración (necesitan saltos de página y totales), y paginación por cursor para feeds infinitos orientados al usuario.
4. IDs y tiempo
4.1 Usa siempre cadenas para los IDs
El razonamiento se cubrió en el artículo sobre tipos: en el lado JavaScript, los valores por encima de 2^53 pierden precisión silenciosamente.
Incluso si el ID actual es un entero autoincremental pequeño, usar cadenas deja margen para cambiar después a IDs snowflake.
{ "id": "1000000000000000001" }
4.2 Usa cadenas RFC 3339 para el tiempo
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- No uses números de timestamp en milisegundos: son difíciles de leer, y la unidad (segundos/milisegundos) se confunde con frecuencia;
- Especifica la zona horaria explícitamente: con
Zo con un offset; nunca uses "hora local sin zona horaria"; - Nombra los campos de forma uniforme como
xxxAtpara que se vea a simple vista que es un punto en el tiempo.
5. null y ausente
Debes elegir una opción y escribirla en la documentación:
| Enfoque | Expresar "sin valor" | Expresar "no modificar" |
|---|---|---|
| Enfoque A | El valor del campo es null |
Omitir el campo en la petición |
| Enfoque B | El campo no existe | Omitir el campo en la petición |
El problema del Enfoque A es que "omitido" y "enviado como null" significan cosas distintas en un PATCH, lo que se malinterpreta fácilmente;
el problema del Enfoque B es que el JSON.stringify del frontend descarta los campos cuyo valor es undefined, lo que resulta coincidir con la semántica.
Recomendación práctica:
- Usa el Enfoque A para cuerpos de respuesta (
nullsignifica claramente sin valor, y el frontend no necesita distinguirundefined); - Usa el Enfoque B para cuerpos de petición (especialmente
PATCH), combinado con Merge Patch o JSON Patch para hacer explícita la semántica.
6. Nomenclatura de campos y estructura
| Decisión | Opciones comunes | Advertencia |
|---|---|---|
| Estilo de nomenclatura | camelCase (ecosistema JS) / snake_case (Python, bases de datos) |
Uniforme en todo el proyecto, no mezclar |
| Valores de enum | Minúsculas con guiones bajos, "pending_review" |
No usar números; son difíciles de leer |
| Campos booleanos | isActive / enabled |
No usar status: 0/1 |
| Campos de array | Sustantivos en plural, items |
Devolver [] para arrays vacíos, nunca null |
| Profundidad de anidamiento | No más de 3 niveles | Más profundo que eso, divídelo o ofrece un parámetro expand |
Devolver [] en lugar de null para arrays vacíos importa mucho: data.items.map(...) en el frontend no fallará por un null.
7. Estrategia de evolución
| Cambio | Rompe la compatibilidad | Cómo gestionarlo |
|---|---|---|
| Añadir un campo opcional | No | Publicarlo directamente |
| Añadir un campo obligatorio | Sí | Añadir un valor por defecto, o publicar por versión |
| Eliminar un campo | Sí | Marcarlo primero como obsoleto, observar a los consumidores y luego eliminarlo |
| Cambiar el tipo de un campo | Sí | Añadir un nombre de campo nuevo y escritura dual durante la transición |
| Cambiar el conjunto de valores de enum | Depende | El cliente debe tener un fallback para valores desconocidos |
| Cambiar la semántica de un campo | Sí | Tratarlo como un campo nuevo |
Tres reglas de disciplina:
- Los clientes deben ser tolerantes con campos desconocidos (no te encierres con
additionalProperties: false); - Los clientes deben tener una rama de fallback para valores de enum desconocidos;
- Mantén los campos obsoletos al menos una versión mayor, y márcalos con
Deprecationen las cabeceras de respuesta o en la documentación.
8. Un ejemplo completo
{
"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 }
}
Esta respuesta refleja todas las convenciones anteriores: envoltorio data, IDs en cadena, importes como cadenas, marcas de tiempo con zona horaria,
null significando ausente, array vacío, paginación por cursor, y meta para información de observabilidad.
9. Lista de comprobación
- [ ] El nivel superior no es un array; usa un envoltorio
datade forma uniforme; - [ ] Las respuestas de error siguen RFC 9457 e incluyen un
typeestable; - [ ] La forma de la paginación se ajusta al escenario de negocio;
- [ ] Los IDs son cadenas, y el tiempo usa RFC 3339;
- [ ] La semántica de
nully ausente está escrita en la documentación; - [ ] El estilo de nomenclatura es uniforme en todo el proyecto, y los enums usan cadenas en minúsculas;
- [ ] El cliente tiene fallbacks para campos desconocidos y valores de enum desconocidos;
- [ ] Los cambios incompatibles pasan por versionado o por una transición de escritura dual.
FAQ
¿Cómo debo diseñar una API JSON evolucionable?
Envuelve listas y detalles de forma uniforme en un envoltorio data y nunca hagas que el nivel superior sea un array, para que la paginación y los IDs de trazabilidad puedan añadirse sin romper el contrato. Usa IDs en cadena y marcas de tiempo RFC 3339, y escribe en la documentación la semántica de null frente a ausente. Mantén los campos nuevos como opcionales y semánticamente independientes, y da a los clientes fallbacks para campos y valores de enum desconocidos.
¿Por qué no inventar un formato de error personalizado?
Inventar tu propia estructura de error es una fuente enorme de coste de colaboración. Copia application/problem+json de RFC 9457, identifica los problemas con un type estable y permite campos de extensión como balance y required, para que el frontend pueda construir mensajes precisos sin analizar el texto de detail.
¿Todos los endpoints deben usar paginación por cursor?
No. Las tablas de administración necesitan saltos de página y totales, así que la paginación por offset encaja mejor; los feeds infinitos orientados al usuario deben usar paginación por cursor, que se mantiene estable en páginas profundas y evita duplicados o huecos cuando cambian los datos. Elige según si el escenario necesita saltos de página y totales.