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 llegue a llamar a tu API se escribe contra la forma de su JSON, y remodelar ese JSON más adelante es un cambio ruptur por muy menor que parezca internamente. Esta guía cubre las decisiones de diseño de respuesta que son baratas de acertar desde el principio y caras de corregir una vez existen 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 te bloquea para añadir jamás metadatos a nivel de respuesta sin un cambio ruptur. En el momento en que necesites un recuento total, un cursor de página siguiente o un mensaje de aviso junto a la lista, tienes que cambiar el tipo de nivel superior de array a objeto, lo cual rompe a todo cliente que haga `response.map(...)` o `response.length` directamente sobre el cuerpo analizado.

Envuelve las colecciones en un campo con nombre desde el primer día — `{ "users": [...] }` o `{ "data": [...] }` — incluso cuando estés seguro de que nunca necesitarás metadatos extra. Añadir una clave hermana a un objeto es un cambio compatible hacia atrás; convertir un array en objeto no lo es, para ningún cliente, en ningún 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`).

Esta distinción es estructural en la semántica de PATCH en concreto: bajo JSON Merge Patch (RFC 7396), enviar un campo como `null` significa eliminar ese campo del destino, mientras que sencillamente no incluir el campo en el cuerpo del patch significa dejarlo sin cambios. Confundir ambos — o ser inconsistente sobre qué campos pueden legítimamente ser `null` — produce APIs donde los clientes no pueden distinguir con seguridad '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 se mantiene estable aunque los datos subyacentes cambien, y se mantiene barata a cualquier profundidad. El contrapunto es que los clientes no pueden saltar a un número de página arbitrario — solo avanzar (y, si lo soportas, retroceder) 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
  }
}

Por defecto usa paginación por cursor para cualquier cosa respaldada por datos que cambian con frecuencia (feeds, logs, flujos de eventos) o tablas grandes; la paginación por offset está bien para colecciones pequeñas y mayormente estáticas donde saltar a la página N es genuinamente útil (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 realmente accionable para un cliente.
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 la nomenclatura de claves, camelCase y snake_case son ambas elecciones válidas — lo que de verdad duele es mezclarlas entre endpoints de la misma API, lo cual obliga a cada cliente a escribir lógica de mapeo de campos inconsistente según el endpoint al que llame. Los consumidores con mucho JavaScript/TypeScript tienden a preferir camelCase porque encaja con el destructuring nativo; los ecosistemas con mucho Python y Ruby suelen preferir snake_case. Elige uno para toda la API y aplícalo con consistencia 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 por la borda la seguridad de tipos sin beneficio alguno. Casi siempre es señal de que el servidor serializó verbatim una columna blob de la base de datos en lugar de decodificarla antes de enviar la respuesta.

Formatos de fecha inconsistentes — algunos campos como timestamps de Unix, otros como `"08/15/2026"`, otros como ISO 8601 completo — fuerzan a los clientes a escribir análisis de fecha por campo en lugar de un único gestor de fechas compartido. Estandariza en ISO 8601 en UTC con desplazamiento explícito (`"2026-08-15T09:30:00Z"`) en todo.

Filtrar campos internos de la base de datos — devolver artefactos del ORM como `password_hash`, claves foráneas internas destinadas a otro servicio, o campos de contabilidad del ORM (`__v`, `_id`, `created_by_worker_id`) verbatim en una respuesta de API. Mapea siempre tu modelo de base de datos a un DTO de respuesta explícito en lugar de serializar el modelo directamente; es la única forma fiable de garantizar que un cambio de esquema interno no se convierta silenciosamente 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 tener la misma forma de nivel superior con `data: null` y un objeto `error` relleno, en lugar de una estructura completamente distinta. Eso permite que el código de gestión de respuestas de un cliente compruebe una sola cosa — la presencia de `error` — en lugar de ramificar según la forma de la respuesta por código de estado.
camelCase o snake_case — importa realmente cuál elija?
No mucho por sí solo, pero 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 no es la convención en sí, sino una API donde distintos endpoints usan convenciones distintas 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?
Porque tu esquema de base de datos y tu contrato de API pública cambian por razones distintas y en calendarios distintos. Serializar el modelo directamente significa que cada migración, columna añadida o actualización de ORM arriesga cambiar silenciosamente — o filtrar — la forma de tu respuesta pública. Un DTO de respuesta explícito desacopla ambos, al coste de un paso de mapeo extra por endpoint.

Prueba estas herramientas

Artículos relacionados