Arquitectura de software

Diseño de APIs REST: recursos, semántica HTTP, versionado y formatos de error

Intermedio12 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Una API REST es un contrato con el que vas a convivir durante años, y la mayor parte de su coste a largo plazo se decide en la primera semana. Esta guía cubre el modelado de recursos, la semántica HTTP definida en la RFC 9110, el diseño de representaciones y errores con problem details, la paginación y el filtrado, la caché con ETags y las estrategias de versionado que permiten que una API cambie sin romper a sus consumidores.

Lo que aprenderás

  • Modelar recursos y URIs que reflejen el dominio y no la base de datos
  • Aplicar correctamente la semántica HTTP de métodos, códigos de estado y peticiones condicionales
  • Diseñar paginación, filtrado y ordenación que sigan siendo correctos cuando los datos cambian
  • Devolver errores legibles por máquina con los problem details de la RFC 9457
  • Hacer evolucionar una API de forma compatible y elegir una estrategia de versionado sostenible

Una API HTTP es la interfaz más duradera que la mayoría de los sistemas llega a exponer. El servicio que hay detrás se reescribirá, se migrará a otra plataforma y quizá se sustituya por completo, mientras la API sigue sirviendo a clientes construidos por personas que se marcharon hace tiempo. Esa asimetría es la razón para dedicar un esfuerzo de diseño real desde el principio: el código interno se puede refactorizar un martes por la tarde, pero el nombre de un campo publicado es prácticamente permanente.

Diseñar bien una API REST consiste sobre todo en usar HTTP tal y como está especificado, en lugar de hacer pasar por él un protocolo a medida. La RFC 9110 define qué significan los métodos, qué significan los códigos de estado y cómo funcionan las peticiones condicionales, y una API que respeta esas definiciones obtiene gratis la caché, la seguridad al reintentar, el comportamiento correcto de los proxies y el soporte de las librerías cliente. Una API que las ignora tiene que reinventarlo todo, y mal.

Recursos y URIs

Un recurso es algo del dominio que merece tener dirección propia: un pedido, un cliente, un pago, la colección de transacciones de una cuenta. No es una tabla de la base de datos, y modelar una URI por tabla es justo lo que lleva a las APIs a exponer decisiones de esquema que luego ya no se pueden cambiar.

Convenciones que aguantan bien el paso del tiempo:

  • Sustantivos en plural para las colecciones, con el identificador añadido para un elemento: /orders, /orders/8f21c. La coherencia importa más que la elección en sí.
  • Anidar solo cuando el hijo no puede existir por sí mismo. /orders/8f21c/lines es razonable, porque una línea no tiene sentido fuera de su pedido. /customers/42/orders se expresa mejor como /orders?customer=42 en cuanto los pedidos son direccionables por derecho propio, y anidar tres niveles es siempre un error.
  • Nada de verbos en las rutas. El verbo es el método. /orders/8f21c/cancel es una excepción pragmática habitual a la que merece la pena resistirse al menos una vez: la alternativa es un campo de estado que se actualiza con PATCH, o un subrecurso /orders/8f21c/cancellation creado con POST, que además te da un sitio donde registrar quién canceló y por qué.
  • Kebab-case, minúsculas, sin extensiones de fichero y sin barra final, decidido una vez y aplicado en todas partes.
  • Identificadores opacos. Los enteros secuenciales revelan volúmenes e invitan a la enumeración. Los UUID o los identificadores aleatorios cortos no cuestan nada más.

El trasfondo es que las URIs forman parte del contrato público. Si una URI codifica tu jerarquía interna, reorganizar esa jerarquía se convierte en un cambio incompatible.

Semántica de los métodos

La RFC 9110 asigna a cada método dos propiedades que importan muchísimo en la práctica: si es seguro (no cambia el estado) y si es idempotente (repetirlo tiene el mismo efecto que ejecutarlo una vez). Toda la infraestructura depende de ellas —los proxies pueden reintentar peticiones idempotentes, los navegadores pueden precargar las seguras—, así que violarlas provoca bugs que nunca vas a reproducir en local.

MétodoSeguroIdempotenteUso
GETSíSíObtener una representación; nunca cambia el estado, nunca lleva cuerpo
HEADSíSíSolo cabeceras: comprobar tamaño o existencia
POSTNoNoCrear un recurso subordinado o enviar una petición de procesamiento
PUTNoSíCrear o sustituir un recurso en una URI conocida
PATCHNoNoAplicar una modificación parcial
DELETENoSíEliminar un recurso; una segunda llamada devuelve 404 o 204
OPTIONSSíSíIndicar los métodos soportados; lo usa el preflight de CORS

Que POST no sea idempotente es el problema práctico: un cliente que sufre un timeout no puede saber si el pedido se llegó a crear. Se resuelve con una clave de idempotencia: un identificador generado por el cliente, enviado en una cabecera, guardado en el servidor junto con el resultado y devuelto tal cual si la petición se repite. La implementación se explica en patrones de diseño de microservicios.

Para PATCH, elige un media type definido en lugar de inventarte la semántica. application/merge-patch+json envía un objeto con los campos que cambian, usando null para eliminar; application/json-patch+json (RFC 6902) envía una lista ordenada de operaciones y puede apuntar a posiciones de un array. Merge patch cubre la mayoría de los casos; JSON Patch compensa cuando los clientes editan listas.

Códigos de estado

Usa con precisión un conjunto pequeño en lugar de usar a ojo uno grande.

CódigoSignificado en la práctica
200 OKÉxito con cuerpo
201 CreatedRecurso creado; incluye una cabecera Location que apunte a él
202 AcceptedPetición aceptada para procesamiento asíncrono; devuelve una URI de estado
204 No ContentÉxito sin nada que devolver, típicamente en DELETE o PUT
304 Not ModifiedGET condicional cuyo validador sigue coincidiendo
400 Bad RequestSintaxis incorrecta o un cuerpo que no se puede parsear
401 UnauthorizedCredenciales ausentes o inválidas: falló la autenticación
403 ForbiddenAutenticado, pero sin permiso
404 Not FoundEl recurso no existe o se oculta deliberadamente a quien llama
409 ConflictLa petición entra en conflicto con el estado actual: duplicado, versión que no coincide
412 Precondition FailedNo se cumplió el If-Match de una petición condicional
422 Unprocessable ContentSintácticamente válido, semánticamente inválido: fallos de validación
429 Too Many RequestsSe alcanzó el límite de peticiones; incluye Retry-After
500 / 502 / 503 / 504Fallo del servidor, respuesta errónea del upstream, no disponible, timeout del upstream

Dos distinciones causan la mayor parte de la confusión. 401 significa «no sé quién eres»; 403 significa «lo sé y no puedes». Y 400 frente a 422: reserva 400 para los cuerpos que el parser rechaza y usa 422 cuando el JSON se ha parseado bien pero su contenido incumple una regla de negocio. Nunca devuelvas 200 con un objeto de error en el cuerpo: todas las librerías cliente, todos los proxies y todos los sistemas de monitorización tratan 200 como un éxito, y habrás vuelto invisibles los fallos. El catálogo completo está en códigos de estado HTTP.

Errores con los que el cliente pueda actuar

La RFC 9457 define application/problem+json, una estructura estándar y pequeña que evita que cada API tenga que inventar su propio envoltorio de errores.

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "Account FR76…0189 has a balance of 12.40 EUR; the transfer requires 250.00 EUR.",
  "instance": "/transfers/9c1a4b7e",
  "balance": "12.40",
  "currency": "EUR",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

Las reglas que lo hacen útil: type es una URI estable que identifica el tipo de problema y es sobre lo que ramifican los clientes, así que nunca debe cambiar de significado; title es un resumen breve y legible que se mantiene constante para un type dado; detail explica esta ocurrencia concreta y puede variar; los miembros de extensión llevan los datos estructurados que el cliente necesita. Incluye un identificador de traza para poder cruzar un ticket de soporte con una traza distribuida.

En los fallos de validación, devuelve todos los problemas a la vez y no solo el primero, para que un formulario pueda marcar todos los campos incorrectos en un único viaje de ida y vuelta.

{
  "type": "https://api.example.com/problems/validation",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    { "field": "email",     "code": "format",  "message": "must be a valid email address" },
    { "field": "quantity",  "code": "min",     "message": "must be at least 1" }
  ]
}

Nunca filtres stack traces, fragmentos de SQL ni nombres de host internos en un detail. Regístralos en el log con el identificador de traza y devuelve el identificador en su lugar.

Colecciones: paginación, filtrado y ordenación

Cualquier endpoint de colección acabará conteniendo más filas de las que debería llevar una respuesta, así que pagina desde el primer día y nunca devuelvas una lista sin límite.

La paginación por offset (?page=3&size=50 o ?offset=100&limit=50) es sencilla y permite a los clientes saltar a una página cualquiera. Sus debilidades son reales: la base de datos recorre y descarta todo lo anterior al offset, de modo que la página 500 es mucho más lenta que la 1, y las inserciones concurrentes desplazan filas entre peticiones, con lo que se saltan o duplican elementos.

La paginación por cursor devuelve un token opaco que codifica la posición en un orden estable, y el cliente lo envía de vuelta para pedir la siguiente página. Las páginas profundas cuestan lo mismo que las primeras y las escrituras concurrentes no provocan saltos.

GET /orders?status=confirmed&sort=-created_at&limit=50 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [ { "id": "8f21c", "status": "confirmed", "total": "89.90" } ],
  "next":  "/orders?status=confirmed&sort=-created_at&limit=50&cursor=eyJjIjoiMjAyNi0wOS0wN1QwOTo0MSJ9"
}

Elijas la que elijas, aplica la misma disciplina: ordena siempre por algo único (añade la clave primaria como desempate, o la paginación se vuelve no determinista), fija un máximo documentado para limit y usa un vocabulario fijo de parámetros de consulta para los filtros en lugar de aceptar expresiones arbitrarias; una API que acepta una cadena de filtro es una API cuyo planificador de consultas pasa a formar parte de tu contrato público.

Representaciones y negociación de contenido

Los clientes declaran lo que aceptan con Accept; los servidores describen lo que han enviado con Content-Type. Soporta bien un media type principal —casi siempre application/json— y añade otros solo cuando un consumidor real los necesite.

Hay convenciones que conviene fijar pronto, porque cambiarlas después rompe a todo el mundo: un único estilo para los nombres de campo (camelCase o snake_case, elegido una vez); marcas de tiempo RFC 3339 en UTC con un desfase explícito; importes monetarios como cadenas con un campo de moneda explícito, nunca como números de coma flotante; enumeraciones como tokens estables en mayúsculas y no como texto de presentación; y significados distintos y documentados para un campo ausente y uno a null.

No envuelvas cada respuesta en un sobre genérico del tipo {"data": …, "meta": …}. HTTP ya proporciona estado, cabeceras y cuerpo; el sobre añade una capa que los clientes tienen que desenvolver y duplica información que el protocolo ya transporta. Los metadatos que de verdad acompañan a una colección —enlaces de paginación, recuentos totales— son la excepción razonable.

Caché y concurrencia

Las respuestas a GET pueden llevar validadores que permiten a clientes y proxies no volver a transferir datos que no han cambiado, y esos mismos validadores resuelven el problema de las actualizaciones perdidas en las escrituras.

GET /orders/8f21c HTTP/1.1
If-None-Match: "v14"

HTTP/1.1 304 Not Modified
ETag: "v14"
Cache-Control: private, max-age=60

Un ETag es un validador opaco para una representación concreta: un número de versión, un hash del contenido. Un cliente que tiene uno envía If-None-Match y recibe un 304 sin cuerpo si nada ha cambiado. En las escrituras, el cliente envía If-Match: "v14" en su PUT o PATCH; si el recurso ha cambiado entretanto, el servidor devuelve 412 Precondition Failed y el cliente puede recargar y reintentar en lugar de sobrescribir en silencio el cambio de otra persona. Es control de concurrencia optimista implementado en el propio protocolo, y añadirlo no cuesta casi nada.

Configura Cache-Control a conciencia en cada respuesta: private para todo lo que sea específico de un usuario, no-store para todo lo sensible y un max-age moderado para los datos de referencia que leen muchos clientes.

Versionado y compatibilidad

La versión más barata es la que nunca tienes que publicar, así que agota primero la evolución compatible. Añadir un campo opcional, añadir un endpoint, añadir un valor de enumeración que la documentación obliga a los clientes a tolerar, relajar una regla de validación: todo ello es compatible si los clientes ignoran los campos desconocidos, algo que tu documentación debe exigir. Eliminar un campo, renombrarlo, cambiar un tipo, endurecer una validación o cambiar un código de estado son cambios incompatibles.

Cuando la ruptura es inevitable:

EstrategiaEjemploContrapartida
Ruta de la URI/v2/ordersEvidente, fácil de enrutar y cachear, pero la versión queda grabada en cada URI del cliente
Media typeAccept: application/vnd.example.order.v2+jsonPreciso y por recurso; los clientes y las herramientas a menudo lo gestionan mal
Cabecera propiaX-API-Version: 2Mantiene estables las URIs; invisible en los logs y en las pruebas desde el navegador salvo que la busques
Parámetro de consulta/orders?version=2Sencillo; encaja mal con la caché

El versionado en la ruta gana por simplicidad operativa en la mayoría de los equipos, y su principal coste —una granularidad gruesa— es asumible si versionas pocas veces. Elijas la que elijas, versiona la API entera y no endpoints sueltos, mantén en marcha la versión anterior mientras los consumidores migran, publica una cabecera Sunset y una fecha de obsolescencia en la antigua, y mide el tráfico por versión para que la retirada sea una decisión basada en datos y no en la esperanza.

Hipermedia, en su justa medida

La restricción de hipermedia dice que un cliente debería descubrir qué puede hacer a continuación a partir de los enlaces de la respuesta, no de URIs fijas en el código. Aplicada por completo, desacopla para siempre a los clientes de tu estructura de URIs.

Siendo honestos, muy pocos clientes se escriben para navegar dinámicamente, y los formatos hipermedia genéricos añaden una ceremonia que la mayoría de los equipos acaba abandonando. Lo que sí compensa es un uso selectivo: los enlaces de paginación (los clientes de verdad siguen next), los enlaces a recursos relacionados que ahorran una búsqueda y los enlaces que representan las transiciones de estado disponibles, que permiten al servidor decirle a una interfaz que un pedido se puede cancelar pero no reembolsar sin que el cliente tenga que reimplementar esa regla.

{
  "id": "8f21c",
  "status": "CONFIRMED",
  "total": { "amount": "89.90", "currency": "EUR" },
  "_links": {
    "self":    { "href": "/orders/8f21c" },
    "lines":   { "href": "/orders/8f21c/lines" },
    "cancel":  { "href": "/orders/8f21c/cancellation", "method": "POST" }
  }
}

Describir la API

Un documento OpenAPI es el contrato legible por máquina: rutas, operaciones, esquemas, esquemas de seguridad y ejemplos. Genera librerías cliente, alimenta servidores mock, impulsa los tests de contrato y nutre el portal de desarrolladores de una plataforma de API management.

Hay dos enfoques. Design first significa escribir el documento OpenAPI antes que el código, revisarlo con los consumidores y generar los stubs del servidor; es mejor para APIs públicas o de partners, donde el contrato se negocia. Code first genera el documento a partir de la implementación anotada, como hace springdoc, lo que lo mantiene exacto con menos esfuerzo y encaja con las APIs internas. En ambos casos, guarda el documento en el control de versiones, revisa su diff en la code review para que los cambios incompatibles se vean, e incluye ejemplos realistas: el ejemplo es la parte que los desarrolladores de verdad leen.

Ideas clave

  • Diseña las URIs en torno a recursos del dominio, no a tablas, y trata cada ruta y cada nombre de campo publicados como prácticamente permanentes.
  • Respeta las propiedades de seguridad e idempotencia definidas en la RFC 9110; donde POST no pueda ser idempotente, añade una clave de idempotencia.
  • Usa un conjunto pequeño y preciso de códigos de estado, y nunca señales un fallo con 200.
  • Devuelve los errores como problem details de la RFC 9457, con una URI type estable, extensiones estructuradas y un identificador de traza.
  • Pagina todas las colecciones, ordena por una clave única y prefiere los cursores cuando los datos cambian mientras se leen o las páginas se vuelven profundas.
  • Evoluciona de forma compatible añadiendo en lugar de cambiar; cuando la ruptura sea inevitable, versiona la API entera, anuncia una fecha de retirada y mide el uso antes de retirar nada.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo usar PUT o PATCH para las actualizaciones?
PUT sustituye el recurso completo por la representación que envías, así que omitir un campo equivale a borrarlo. PATCH aplica una modificación parcial descrita en el cuerpo de la petición. La mayoría de los clientes reales quieren actualizaciones parciales, por eso PATCH es la opción habitual; define su media type de forma explícita: application/merge-patch+json para actualizar campos simples o application/json-patch+json cuando necesites operar sobre arrays.
¿De verdad es tan mala la paginación por offset?
Funciona bien con conjuntos de datos pequeños y estables, y en interfaces que necesitan saltar a la página 40. A escala tiene dos problemas reales: la base de datos tiene que recorrer y descartar todo lo anterior al offset, de modo que las páginas profundas son cada vez más lentas, y las filas insertadas o borradas entre peticiones hacen que se salten o se repitan elementos. La paginación por cursor basada en una clave de ordenación estable evita ambos.
¿Tengo que implementar HATEOAS para poder llamarlo REST?
Según la definición original, sí: la restricción de hipermedia es lo que permite a los clientes descubrir las transiciones en lugar de llevar las URIs fijas en el código. En la práctica, muy pocos clientes navegan los enlaces dinámicamente, y la hipermedia completa rara vez compensa su coste. El término medio pragmático es incluir enlaces para las transiciones de estado reales y para la paginación, que los clientes sí siguen, y documentar todo lo demás en OpenAPI.
¿Durante cuánto tiempo hay que mantener una versión antigua de la API?
El tiempo suficiente para el consumidor más lento que te importe: en APIs públicas suele significar al menos doce meses y, en clientes móviles, como mínimo lo que dure la versión más antigua de la app que sigas soportando. Anuncia una fecha de retirada cuando salga la nueva versión, publica la cabecera Sunset en los endpoints obsoletos e instrumenta el uso por versión para poder demostrar quién sigue llamando antes de apagar nada.