Skip to content
jsonforge.app
Volver al blog
Guía9 min de lectura

Diseño de respuestas JSON en APIs REST: buenas prácticas y antipatrones

Los bytes que devuelve una API REST importan tanto como las rutas y los códigos de estado. Cada cliente que llame a tu API se escribe contra la forma de su JSON, y cambiar esa forma más adelante rompe a los clientes, por pequeño que parezca el cambio por dentro. Esta guía cubre las decisiones de diseño de respuesta que cuestan poco acertar al principio y mucho corregir cuando ya hay clientes.

Elige una forma de sobre y no te desvíes nunca

Un sobre es la envoltura consistente de nivel superior que usa cada respuesta, sin importar el endpoint: un conjunto conocido de claves como `data`, `error` y `meta` que la capa HTTP de un cliente puede gestionar de forma genérica en lugar de escribir análisis a medida por endpoint. Sin uno, cada endpoint que devuelve una forma ligeramente distinta obliga a cada cliente a escribir un parser ligeramente distinto.

Un sobre consistente: las respuestas de éxito y de error comparten la misma forma de nivel superior.
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" }
}

Los nombres concretos de las claves importan menos que la consistencia: elijas lo que elijas, cada endpoint lo devuelve, `data` siempre es donde vive la carga útil, y un cliente puede escribir una única función `unwrap(response)` para toda la API en lugar de una por recurso.

Nunca devuelvas un array (o escalar) desnudo en el nivel superior

Que `GET /users` devuelva un array JSON desnudo (`[{...}, {...}]`) parece natural, pero ya no podrás añadir metadatos a nivel de respuesta sin romper la compatibilidad. En cuanto necesites un recuento total, un cursor de página siguiente o un aviso junto a la lista, tendrás que cambiar el tipo de nivel superior de array a objeto, y eso rompe a todo cliente que haga `response.map(...)` o `response.length` sobre el cuerpo analizado.

Envuelve las colecciones en un campo con nombre desde el primer día (`{ "users": [...] }` o `{ "data": [...] }`), aunque estés seguro de que nunca necesitarás metadatos extra. Añadir una clave hermana a un objeto mantiene la compatibilidad hacia atrás. Convertir un array en objeto rompe a cualquier cliente en cualquier lenguaje.

Null frente a omitido: no son la misma señal

Un campo presente con valor `null` y un campo ausente de la respuesta por completo parecen ambos 'sin valor' a primera vista, pero significan cosas distintas y los clientes necesitan poder diferenciarlos. `null` dice: este campo es un concepto real y conocido para este recurso, y su valor actual es explícitamente nada (un usuario sin `middleName`). Omitido dice: este campo o no aplica, o no se cargó, o no se solicitó (una petición de sparse fieldset que solo pidió `id` y `name`).

La semántica de PATCH depende de esta distinción. En JSON Merge Patch (RFC 7396), enviar un campo como `null` lo elimina del destino, mientras que omitir el campo en el cuerpo del patch lo deja sin cambios. Si confundes ambos, o no dejas claro qué campos pueden ser `null`, los clientes no pueden distinguir 'borra este valor' de 'no envié una actualización para esto'.

Paginación: cursor frente a offset, y cómo se ve cada una en JSON

La paginación por offset/limit (`?offset=40&limit=20`, o `?page=3&pageSize=20`) es la más simple de implementar y razonar, pero tiene un problema real de corrección: si se insertan o eliminan filas entre peticiones de página, los offsets se desplazan bajo los pies del cliente, causando filas saltadas o duplicadas. También se encarece en SQL en offsets profundos, ya que la base de datos sigue teniendo que escanear y descartar todas las filas saltadas.

La paginación por cursor entrega al cliente un token opaco que apunta a 'la fila siguiente a la última que viste' en lugar de una posición numérica, así que los resultados se mantienen estables aunque cambien los datos y las consultas siguen siendo baratas a cualquier profundidad. A cambio, los clientes no pueden saltar a un número de página arbitrario. Solo avanzan (y, si lo soportas, retroceden) desde un cursor.

Una respuesta con paginación por cursor: sin aritmética de offsets, solo un token opaco de página siguiente.
json
{
  "data": [ { "id": 101 }, { "id": 102 } ],
  "meta": {
    "nextCursor": "eyJpZCI6MTAyfQ==",
    "hasMore": true
  }
}

Usa por defecto paginación por cursor para datos que cambian a menudo (feeds, logs, flujos de eventos) o tablas grandes. La paginación por offset sirve para colecciones pequeñas y casi estáticas donde saltar a la página N ayuda (una tabla de administración con selector de página).

Objetos de error y convenciones de nomenclatura

Una respuesta de error debería llevar más que un código de estado HTTP y una cadena: un `code` estable y legible por máquinas sobre el que un cliente pueda ramificar con seguridad (`"VALIDATION_ERROR"`, no una frase), un `message` legible por humanos pensado para logs y desarrolladores más que para analizarse, y para fallos de validación, un array `details` que nombre exactamente qué campos fallaron y por qué.

Una forma de error con la que un cliente puede actuar.
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" }
    ]
  }
}

Para nombrar claves, camelCase y snake_case funcionan igual de bien. El daño viene de mezclarlas entre endpoints de la misma API, porque cada cliente necesita entonces lógica de mapeo distinta según el endpoint. Los consumidores de JavaScript/TypeScript suelen preferir camelCase porque encaja con el destructuring nativo; los ecosistemas de Python y Ruby suelen preferir snake_case. Elige una para toda la API y aplícala también a los booleanos (`isActive`/`hasAccess`, no una mezcla de `active` y `has_access`).

Antipatrones comunes

JSON serializado como cadena dentro de un campo JSON (`"metadata": "{\"plan\":\"pro\"}"` en lugar de `"metadata": { "plan": "pro" }`) obliga a cada cliente a analizar dos veces y tira la seguridad de tipos sin ningún beneficio. Suele indicar que el servidor serializó tal cual una columna blob de la base de datos en vez de decodificarla antes de responder.

Los formatos de fecha inconsistentes (unos campos como timestamps de Unix, otros como `"08/15/2026"`, otros como ISO 8601 completo) obligan a los clientes a analizar fechas campo por campo en lugar de usar un único gestor compartido. Estandariza en ISO 8601 en UTC con desplazamiento explícito (`"2026-08-15T09:30:00Z"`) en todas partes.

Filtrar campos internos de la base de datos significa devolver artefactos del ORM como `password_hash`, claves foráneas internas destinadas a otro servicio, o campos de contabilidad (`__v`, `_id`, `created_by_worker_id`) tal cual en una respuesta de API. Mapea tu modelo de base de datos a un DTO de respuesta explícito en lugar de serializar el modelo. Un DTO es la forma fiable de impedir que un cambio de esquema interno se convierta en un cambio de la API pública.

FAQ

¿Deberían las respuestas de error usar el mismo sobre que las de éxito?
Sí. Si las respuestas de éxito son `{ "data": ..., "meta": ... }`, las de error deberían mantener esa forma de nivel superior con `data: null` y un objeto `error` relleno. Así el código de gestión de respuestas del cliente comprueba una sola cosa, la presencia de `error`, en vez de ramificar según la forma de la respuesta por código de estado.
camelCase o snake_case: ¿importa cuál elija?
La convención en sí importa poco; la consistencia importa mucho. Elige una convención para toda la API según el ecosistema de tus consumidores principales, documéntala y aplícala en todas partes, incluidos prefijos booleanos y claves de objetos anidados. El coste aparece en una API donde cada endpoint usa una convención distinta y cada cliente necesita lógica de mapeo específica por endpoint.
¿Debería una API nueva usar paginación por cursor o por offset?
Por defecto, paginación por cursor salvo que específicamente necesites permitir a los usuarios saltar a un número de página arbitrario en datos pequeños y mayormente estáticos. La paginación por cursor se mantiene correcta cuando se añaden o eliminan filas a mitad de paginación y barata a cualquier profundidad; la de offset se degrada en corrección y rendimiento a medida que el conjunto de datos crece o cambia.
¿Por qué no serializar directamente mi modelo de base de datos como respuesta de la API?
Tu esquema de base de datos y tu contrato de API pública cambian por razones distintas y a ritmos distintos. Si serializas el modelo directamente, cualquier migración, columna añadida o actualización de ORM puede cambiar o filtrar la forma de tu respuesta pública sin que nadie lo note. Un DTO de respuesta explícito desacopla ambos, al coste de un paso de mapeo extra por endpoint.

Prueba estas herramientas

Artículos relacionados