API Styles: REST · HATEOAS · gRPC · GraphQL · JSON-RPC
El estilo de API es el contrato de comunicación entre servicios/clientes; elegirlo es un trade-off deliberado (acoplamiento, latencia, cacheabilidad, evolución del schema, quién consume), no una moda.
Teoría
Qué es un API style y por qué importa
Un API style define cuatro decisiones a la vez: cómo se modela el dominio (recursos vs procedimientos vs grafo), cómo se serializa (JSON texto vs binario protobuf), cómo se transporta (HTTP/1.1, HTTP/2, WebSocket) y cómo se evoluciona el contrato sin romper consumidores. Cambiar de estilo cambia las cuatro a la vez, por eso la decisión es arquitectónica y difícil de revertir.
Resumen operativo de los cuatro estilos:
- REST: recursos sobre verbos/status HTTP. Texto, cacheable, curl-able, ubicuo. Default para APIs públicas.
- gRPC: RPC binario sobre HTTP/2 con contrato
protobufy codegen. Interno, baja latencia, streaming. No browser-nativo. - GraphQL: un endpoint con lenguaje de query; el cliente pide exactamente los campos. Ideal para front-ends heterogéneos; mueve la complejidad al server.
- JSON-RPC: un endpoint con
methoddispatch. Para acciones que no modelan bien como recursos (RPC puro).
La API es el contrato entre tu backend y el mundo. Un contrato mal diseñado es difícil de versionar sin romper clientes, genera N+1 por mal modelado de recursos, no escala sin paginación ni rate limiting, y confunde a los consumidores por inconsistencia. El nivel senior no es “sé escribir endpoints”, es “justifico cada decisión de contrato y sé qué cuesta cada una”.
Richardson Maturity Model y HATEOAS
El Richardson Maturity Model (RMM) gradúa qué tan RESTful es una API en cuatro niveles. No es una escala de correctitud que haya que escalar hasta arriba: es un mapa de cuánto acoplamiento y descubribilidad quieres.
| Nivel | Nombre | Qué hace | Ejemplo |
|---|---|---|---|
| L0 | The Swamp of POX | Un solo URI, todo por POST, RPC-sobre-HTTP | POST /api con {"action":"getOrder"} |
| L1 | Resources | Múltiples URIs, uno por recurso, pero un solo verbo | POST /orders/199 para todo |
| L2 | HTTP Verbs | Verbos correctos (GET/POST/PUT/DELETE) + status codes semánticos | GET /orders/199 → 200, DELETE → 204 |
| L3 | Hypermedia (HATEOAS) | Las respuestas embeben links que dicen las acciones disponibles a continuación | ver abajo |
L2 es el sweet spot práctico. Stripe, GitHub y la mayoría de APIs públicas exitosas viven en L2 y están perfectamente bien. La ceremonia de L3 rara vez se justifica.
HATEOAS (Hypermedia As The Engine Of Application State) es L3: el cliente no hardcodea URIs ni conoce la máquina de estados; sigue links que el server provee según el estado actual del recurso.
GET /v1/orders/199 → 200 OK
{
"id": "199",
"status": "pending",
"total": 4200,
"_links": {
"self": { "href": "/v1/orders/199" },
"cancel": { "href": "/v1/orders/199", "method": "DELETE" },
"pay": { "href": "/v1/orders/199/payment", "method": "POST" }
}
}
Cuando la orden pasa a paid, el server deja de emitir el link pay y emite refund. El cliente no necesita saber las reglas de transición: solo renderiza las acciones que llegaron. Formatos estándar: HAL (_links), JSON:API, Siren.
Cuándo HATEOAS paga: APIs long-lived con muchos clientes que no controlas, donde quieres reestructurar endpoints o gatear acciones por estado sin desplegar cambios en los clientes. Cuándo es overkill: pocos clientes, tightly-coupled, generados desde un spec (OpenAPI/protobuf) — el beneficio de descubribilidad no se usa y el impuesto de payload/complejidad sí es real. Framing senior: el nivel de madurez es una decisión de costo/beneficio sobre acoplamiento y evolvability, no un ladder de correctitud.
REST best practices — idempotencia
Idempotente = ejecutar la operación N veces produce el mismo estado del servidor que ejecutarla una vez. Distinto de safe (sin efectos secundarios: GET/HEAD/OPTIONS).
| Método | Safe | Idempotente | Por qué |
|---|---|---|---|
GET/HEAD | Sí | Sí | Leer no cambia estado |
OPTIONS | Sí | Sí | Metadata, sin efectos |
PUT | No | Sí | Reemplaza el recurso completo → mismo resultado |
DELETE | No | Sí | Borrar algo ya borrado = no-op |
POST | No | No | Crear 2 veces = 2 recursos (doble cobro) |
PATCH | No | Depende | set name=X sí; increment counter no |
En sistemas distribuidos los requests se duplican (retry del cliente, timeout de red que reintenta, at-least-once de una cola). Si tu POST no es idempotente, un retry cobra dos veces. Solución: Idempotency-Key.
from fastapi import FastAPI, Header, HTTPException
from uuid import UUID
app = FastAPI()
@app.post("/v1/orders", status_code=201)
async def create_order(
body: OrderCreate,
idempotency_key: UUID = Header(...),
):
key = str(idempotency_key)
# Atomic "insert-if-absent": el UNIQUE index sobre idempotency_key
# es lo que evita la race de dos retries concurrentes ejecutando ambos.
cached = await store.get(key)
if cached is not None:
if cached.request_fingerprint != fingerprint(body):
# Mismo key, body distinto: rechazar, no devolver lo viejo.
raise HTTPException(422, "Idempotency-Key reused with different body")
if cached.status == "in_flight":
raise HTTPException(409, "Request already in progress")
return cached.response # replay: misma respuesta, sin re-ejecutar
await store.put(key, status="in_flight", fingerprint=fingerprint(body)) # TTL 24h
order = await order_service.create(body)
await store.put(key, status="done", response=order)
return order
Claves senior que separan un if key in dict de una solución real:
- Store durable con TTL (Redis/Postgres), no memoria del proceso.
- Insert-if-absent atómico (
UNIQUEindex /INSERT ... ON CONFLICT/SELECT ... FOR UPDATE) para la race de retries concurrentes. - Fingerprint del request: mismo key con body distinto →
422, no devolver silenciosamente lo viejo. - Estado in-flight: mientras la primera ejecución corre, el duplicado recibe
409/Retry-After. - Idempotencia (retries de red) ≠ deduplicación (nivel de negocio).
REST best practices — paginación (cursor vs offset)
Offset (LIMIT/OFFSET) es simple y permite saltar a “página 7”, pero tiene dos defectos que lo descalifican a escala:
SELECT * FROM orders ORDER BY created_at DESC OFFSET 999980 LIMIT 20;
-- La DB LEE y DESCARTA 999980 filas para darte 20 → costo O(n).
-- Y si se inserta/borra mientras paginas → filas saltadas o duplicadas entre páginas.
Cursor (keyset) pagina por el último valor visto sobre un índice; costo O(page size) y consistente bajo escrituras:
SELECT * FROM orders
WHERE (created_at, id) < (:c_ts, :c_id) -- tiebreaker id: created_at rara vez es único
ORDER BY created_at DESC, id DESC
LIMIT 20; -- siempre rápido, usa el índice (created_at, id)
from base64 import urlsafe_b64encode, urlsafe_b64decode
import json
def encode_cursor(ts, id_) -> str:
raw = json.dumps({"ts": ts.isoformat(), "id": str(id_)}).encode()
return urlsafe_b64encode(raw).decode() # opaco: el cliente no lo parsea
@app.get("/v1/orders")
async def list_orders(cursor: str | None = None, size: int = Query(20, ge=1, le=100)):
tuple_ = _decode(cursor) if cursor else None
rows = await order_service.page(after=tuple_, size=size)
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if len(rows) == size else None
return {"data": rows, "next_cursor": next_cursor, "size": size} # end ⇒ next_cursor null
| Escenario | Estrategia | Por qué |
|---|---|---|
| Admin panel, datos pequeños/estáticos | Offset | UX “ir a página 5” importa; O(n) irrelevante |
| Feed infinito, timeline, catálogo | Cursor | Datos cambian; O(page) y sin saltos |
| API pública | Cursor | No controlas cuántas páginas piden |
| Exportación de datasets grandes | Cursor | Offset se vuelve imposible |
Senior: el cursor debe ser opaco (base64, idealmente firmado) para poder cambiar el sort sin romper clientes, debe incluir tiebreaker (id) porque el sort primario rara vez es único, y debe apoyarse en un índice que cubra (sort_key, id) (index-only scan). Señaliza fin con next_cursor: null, nunca dependas de count.
REST best practices — versioning
| Estrategia | Ejemplo | Pro | Contra |
|---|---|---|---|
| URL path | /v2/orders | Visible, cacheable, curl-able, trivial de documentar | Duplica rutas |
| Header / media-type | Accept: application/vnd.api.v2+json | URIs estables, “REST puro” | Invisible, difícil de testear/cachear |
| Query param | /orders?version=2 | Simple | Feo, ensucia cache keys |
Regla senior: versiona en el borde, evoluciona aditivamente dentro de una versión. La mayoría de cambios NO deben crear una versión nueva:
- Aditivo = no-breaking: agregar campos opcionales, agregar endpoints, tolerar campos desconocidos (Postel: liberal en lo que aceptas, conservador en lo que emites).
- Breaking: renombrar/eliminar/retipar un campo, o cambiar el significado de uno existente. Nunca repurposes un campo; agrega uno nuevo y deprecia el viejo.
- Al deprecar:
Deprecation+Sunsetheaders + changelog + ventana de solapamiento (v1 y v2 corriendo en paralelo). - Consumer-driven contract tests (Pact) para saber qué rompes, no adivinar.
from fastapi import APIRouter
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
@v1.get("/orders") # formato legacy
async def list_v1(): ...
@v2.get("/orders") # formato nuevo (cursor pagination, envelope distinto)
async def list_v2(): ...
Para APIs públicas, URL path gana por visibilidad y cacheabilidad. gRPC obtiene versioning barato vía field numbers de protobuf (solo agregas, nunca reusas un número).
REST best practices — error model (RFC 9457 / 7807)
No inventes un formato de error por endpoint. Usa Problem Details for HTTP APIs: RFC 7807, actualizado y obsoletado por RFC 9457 (2023), mismo media type application/problem+json.
from fastapi.responses import JSONResponse
def problem(status, title, detail, type_="about:blank", **ext):
return JSONResponse(
status_code=status,
media_type="application/problem+json",
content={"type": type_, "title": title, "status": status,
"detail": detail, "instance": ext.pop("instance", None), **ext},
)
@app.exception_handler(ValidationError)
async def on_validation(request, exc):
return problem(
422, "Validation Error", "One or more fields failed validation",
type_="https://api.example.com/errors/validation",
instance=str(request.url),
errors=[{"field": e["loc"][-1], "message": e["msg"]} for e in exc.errors()],
)
Campos estándar: type (URI que identifica el tipo de error), title (frase corta estable), status (repite el código HTTP), detail (explicación humana de esta ocurrencia), instance (URI de la ocurrencia). Puedes agregar campos de extensión (errors, code, balance).
Anti-patrón crítico: devolver 200 OK con el error en el body. Rompe caching HTTP, load balancers, retries automáticos y todo el tooling estándar. Usa el status code correcto:
2xx 200 OK · 201 Created (POST) · 204 No Content (DELETE)
4xx 400 Bad Request · 401 Unauthorized (no autenticado) · 403 Forbidden (sin permiso)
404 Not Found · 409 Conflict (email ya existe) · 422 Unprocessable (bien formado, semánticamente inválido)
429 Too Many Requests (rate limit)
5xx 500 Internal · 502 Bad Gateway · 503 Unavailable · 504 Gateway Timeout
Detalle relacionado: rate limiting (429 + Retry-After + X-RateLimit-*). Token Bucket permite bursts hasta el límite (default para APIs generales); Leaky Bucket fuerza velocidad constante (procesamiento de pagos). En producción, el contador vive en Redis para ser distribuido, no en memoria del worker.
REST best practices — OpenAPI como contrato
OpenAPI (antes Swagger) es la descripción machine-readable del contrato REST. No es documentación decorativa: es el artefacto del que se generan clients tipados, mocks, validación de requests y contract tests. En FastAPI el schema se deriva de los type hints y modelos Pydantic — el código es la fuente de verdad:
class OrderCreate(BaseModel):
customer_id: UUID
items: list[OrderItemCreate] = Field(min_length=1)
class OrderResponse(BaseModel):
id: UUID
status: str
total: float
created_at: datetime
@app.post("/v1/orders", status_code=201, response_model=OrderResponse,
responses={422: {"description": "Validation error (problem+json)"}})
async def create_order(body: OrderCreate, idempotency_key: UUID = Header(...)):
...
FastAPI expone el OpenAPI JSON en /openapi.json y UIs en /docs (Swagger UI) y /redoc. Contrato senior: spec-first o code-first, pero un solo source of truth; generar el spec a mano y a la vez el código diverge y miente. Con OpenAPI generas SDKs de cliente, validas contra el spec en CI, y publicas un contrato que los consumidores pueden codegenerar.
gRPC vs REST
gRPC = RPC sobre HTTP/2 con contrato protobuf (IDL) y codegen de clientes/servidores en múltiples lenguajes.
syntax = "proto3";
service Orders {
rpc Get(GetReq) returns (Order); // unary
rpc Watch(WatchReq) returns (stream OrderEvent); // server-streaming
rpc Import(stream Row) returns (ImportSummary); // client-streaming
rpc Chat(stream Msg) returns (stream Msg); // bidi
}
message GetReq { string id = 1; } // los field numbers (=1) SON el contrato de wire
| Dimensión | REST | gRPC |
|---|---|---|
| Wire | JSON texto | protobuf binario (compacto) |
| Transporte | HTTP/1.1+ | HTTP/2 (multiplexado, sin head-of-line a nivel request) |
| Contrato | OpenAPI (opcional) | protobuf (obligatorio, codegen) |
| Streaming | SSE/WebSocket aparte | nativo (server/client/bidi) |
| Cache HTTP | Sí (intermediarios) | No |
| Browser | Nativo | Requiere gRPC-Web + proxy (Envoy) |
| Debug/ops | curl, legible | opaco, necesita tooling (grpcurl) |
gRPC gana en comunicación interna service-to-service, alto throughput, baja latencia, entornos polyglot (Go ↔ Python ↔ Node) donde el codegen tipado elimina una clase de bugs de integración, y cuando necesitas streaming. Costos: sin cache HTTP intermediario, wire opaco, load-balancing necesita infra L7-aware (las conexiones HTTP/2 long-lived derrotan a un LB L4 naive), y el browser necesita gRPC-Web.
Backward compat en protobuf es disciplina dura: nunca reuses ni renumeres un field number; solo append; reserve los números/nombres eliminados. Respuesta senior común: ambos — gRPC interno, gateway REST/GraphQL en el borde (via transcoding gRPC-JSON).
GraphQL
Un endpoint (POST /graphql), un lenguaje de query donde el cliente pide exactamente los campos, colapsando múltiples round-trips en uno:
query { order(id: "199") { id total items { sku qty } } } # sin over/under-fetching
mutation { cancelOrder(id: "199") { id status } }
Elimina over-fetching (traer campos que no usas) y under-fetching (N requests para armar una vista). Ideal para front-ends con necesidades de datos heterogéneas (mobile + web + widgets) sobre un dominio graph-shaped. Pero traslada toda la complejidad al server, y ese es el tema de entrevista:
- N+1: cada resolver puede disparar su propio fetch → mitigar con DataLoader (batch + cache por request).
- Cache HTTP perdido: todo es un
POSTidéntico → restaurar con persisted queries / APQ (y de paso lock-down de operaciones permitidas). - Query cost ilimitado: el cliente puede pedir queries profundas/anchas → DoS. Mitigar con depth limit + cost/complexity analysis + timeouts.
- Authorization por campo: cualquier field es un posible leak → authz en resolvers, no solo en el gateway.
- Paginación: spec Relay Connections (edges/nodes/cursors).
Framing senior: no es “REST vs GraphQL org-wide”. A menudo GraphQL es un BFF (Backend for Frontend) que agrega servicios REST/gRPC internos. Elige GraphQL cuando tienes muchos clientes diversos y dominio graph-shaped; quédate en REST cuando el dominio es resource-shaped, cache-heavy o público con consumidores no confiables.
JSON-RPC
Protocolo mínimo: un endpoint, dispatch por method, sin la ceremonia de recursos REST.
POST /rpc
{ "jsonrpc": "2.0", "method": "orders.cancel", "params": { "id": "199" }, "id": 7 }
→ { "jsonrpc": "2.0", "result": { "id": "199", "status": "cancelled" }, "id": 7 }
→ { "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 7 }
id correlaciona request/response (permite batching y pipelining); omitirlo = notification (fire-and-forget). Los error codes están estandarizados (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal).
Cuándo: acciones que no modelan bien como recursos — RPC puro tipo estimateGas, subscribe, eth_call. Común en blockchain (Ethereum JSON-RPC), Language Server Protocol (LSP), y tools internos donde no quieres la semántica REST. Es transport-agnóstico (HTTP, WebSocket, stdio). Costo vs REST: pierdes la cacheabilidad HTTP, los status codes semánticos y la descubribilidad de recursos.
Cómo seleccionar el enfoque según requisitos
El estilo lo dictan el consumidor y la topología, no la moda. Árbol de decisión senior:
- ¿El consumidor es un browser público / terceros que no controlas? → REST (ubicuidad, cache HTTP, curl-ability, tooling). Default seguro para un SaaS externo.
- ¿Comunicación interna service-to-service, polyglot, baja latencia, alto volumen o streaming? → gRPC (contrato fuerte, codegen, HTTP/2).
- ¿Muchos front-ends con formas de datos distintas y dominio graph-shaped, y el over/under-fetching de REST duele? → GraphQL (probablemente como BFF).
- ¿Acciones que no son recursos (RPC puro), o un protocolo interno simple? → JSON-RPC.
- ¿API long-lived, muchos clientes que no controlas, con máquina de estados que evoluciona y quieres desacoplarlos de URIs/workflow? → REST + HATEOAS (L3). Si no, quédate en L2.
Trade-off central en una frase: REST maximiza descubribilidad, cacheabilidad y bajo acoplamiento a costa de over/under-fetching y payloads verbosos; gRPC maximiza eficiencia y contrato fuerte a costa de opacidad y hostilidad al browser; GraphQL mueve el poder de decisión al cliente a costa de trasladar toda la complejidad de performance, caching y authz al server; JSON-RPC minimiza ceremonia a costa de perder la riqueza semántica de HTTP. No es una respuesta única: en un sistema real conviven — gRPC en el mesh interno, un gateway REST/GraphQL en el borde.
Ejercicios
- Idempotencia con race de retries. Tienes
POST /v1/payments. Dos retries del mismoIdempotency-Keyllegan simultáneamente a dos workers. Diseña el mecanismo de store que garantice que solo se cobra una vez, y describe qué pasa con el segundo request mientras el primero está in-flight.
Solución
Store durable (Postgres/Redis) con UNIQUE index sobre idempotency_key. Cada worker intenta un insert atómico INSERT ... ON CONFLICT DO NOTHING con estado in_flight antes de ejecutar el cobro. Solo uno gana el insert; el otro recibe conflicto y consulta el registro existente. Si el registro está in_flight, el segundo request responde 409 Conflict con Retry-After (el cliente reintenta y eventualmente encuentra el registro done y recibe la respuesta cacheada). Guardar además un fingerprint del body: si el mismo key llega con un body distinto → 422 (reuso incorrecto del key, no devolver el resultado viejo). TTL de 24h. La clave es que la atomicidad la da el UNIQUE index, no la lógica de aplicación — un if key in dict tiene una race window entre el check y el insert.
- Diseña la paginación de un feed público de alto volumen. El endpoint
GET /v1/eventssirve un timeline que recibe inserts constantes. Elige estrategia, escribe la query SQL con su índice, y explica cómo evitas que el cliente pueda romperte cambiando el sort.
Solución
Cursor (keyset) pagination, no offset — el volumen y los inserts constantes descalifican offset (O(n) + saltos/duplicados). Índice: CREATE INDEX ON events (created_at DESC, id DESC). Query:
SELECT * FROM events
WHERE (created_at, id) < (:c_ts, :c_id)
ORDER BY created_at DESC, id DESC
LIMIT :size;
El id es tiebreaker obligatorio porque created_at no es único. El cursor se emite opaco (base64 de {ts, id}, idealmente firmado con HMAC): el cliente no puede fabricar un cursor arbitrario ni depende del formato interno, así que puedes cambiar el sort key en el futuro sin romper clientes. next_cursor: null señaliza el fin. El sort del cursor debe coincidir con el índice para lograr un index-only scan.
- Un consumidor te pide “convertir la API a gRPC porque es más rápido”. El consumidor es una SPA en el browser. Responde con la decisión correcta y la justificación.
Solución
“Más rápido” sin contexto es un red flag. gRPC no es browser-nativo: la SPA necesitaría gRPC-Web + un proxy (Envoy) que traduzca, y perderías cache HTTP intermediario y payloads legibles para debug. Para un consumidor browser, REST (o GraphQL si el fetching es el problema real) es la elección correcta. gRPC brilla en el mesh interno service-to-service polyglot, no en el borde hacia un browser. Respuesta senior: si internamente ya hay gRPC entre servicios, expón un gateway REST/GraphQL en el borde (transcoding gRPC-JSON) — obtienes lo mejor de ambos sin forzar gRPC-Web en el cliente. La ganancia de latencia de gRPC además es marginal frente al costo de red pública; el cuello de botella de una SPA rara vez es el framing binario.
- Evoluciona un contrato sin romper clientes. La respuesta actual tiene
"amount": 4200(centavos, integer). Negocio pide soportar múltiples monedas con decimales. Describe cómo lo haces sin versionar y qué harías si tuvieras que versionar.
Solución
Aditivo primero: no cambies el tipo ni el significado de amount. Agrega campos nuevos opcionales: "currency": "COP" y, si necesitas decimales, un "amount_decimal": "42.00" como string (evita floats en dinero) o un objeto "money": {"value": "42.00", "currency": "COP"}. Los clientes viejos ignoran los campos nuevos (deben tolerar campos desconocidos — Postel). Depreca amount con headers Deprecation + Sunset y changelog. Solo si el cambio es genuinamente breaking (ej. debes eliminar amount porque su semántica en centavos es incompatible) creas /v2, corres v1 y v2 en paralelo durante una ventana, y usas contract tests (Pact) para confirmar qué consumidores dependen del campo viejo. Regla: versiona en el borde, evoluciona aditivamente dentro de una versión.
- Diseña el error model. Un
PUT /v1/orders/{id}/statuspuede fallar por: orden inexistente, transición de estado inválida (delivered → pending), y payload malformado. Define el status code y el cuerpoproblem+jsonde cada caso.
Solución
Todos con media type application/problem+json (RFC 9457):
- Orden inexistente →
404 Not Found:{"type": ".../errors/not-found", "title": "Order not found", "status": 404, "detail": "No order with id 199", "instance": "/v1/orders/199/status"}. - Transición inválida →
409 Conflict(el recurso existe pero su estado no permite la operación):{"type": ".../errors/invalid-transition", "title": "Invalid status transition", "status": 409, "detail": "Cannot move from delivered to pending", "current_state": "delivered", "requested_state": "pending"}. (422también es defendible;409comunica mejor “conflicto con el estado actual del recurso”.) - Payload malformado (falta
statuso tipo inválido) →422 Unprocessable Entitycon arrayerrorsde campo/mensaje.
Nunca devolver 200 con el error en el body. Los campos de extensión (current_state, errors) son legítimos en problem+json.
Preguntas tipo entrevista (EN)
Q1: Which HTTP methods are idempotent, and how do you make a non-idempotent POST safe to retry?
- ❌ Wrong / trap: “GET, POST, PUT and DELETE are all idempotent” — fails: POST is NOT idempotent; retrying it can create duplicate resources / double-charge. Conflating “safe” (GET/HEAD, no side effects) with “idempotent” (same result on N calls) is the classic trap.
- ✅ Correct: GET, HEAD, PUT, DELETE and OPTIONS are idempotent by spec; POST and PATCH are not. To make POST retry-safe, accept a client-supplied
Idempotency-Keyheader, persist the key + response, and on replay return the stored result instead of re-executing. - ⭐ Optimal (senior): Idempotency needs a durable store keyed by
(key, request-fingerprint)with a TTL, atomic “insert-if-absent” (unique index /SELECT … FOR UPDATE) to avoid a race where two concurrent retries both execute, and a defined behavior for in-flight duplicates (return409/retry-after while the first is processing). Distinguish idempotency (network retries) from deduplication (business-level). PUT is idempotent only if you send the full representation to a known URI;PATCHusually isn’t. Also handle key reuse with a different body → reject with422rather than silently returning the old result.
Q2: Offset vs cursor pagination — when does offset break, and what are cursor’s downsides?
- ❌ Wrong / trap: “Just use
LIMIT/OFFSET, it’s simpler and lets users jump to any page” — fails: deep offsets (OFFSET 100000) force the DB to scan+discard rows (O(n) cost), and concurrent inserts/deletes cause skipped or duplicated rows between pages. - ✅ Correct: Cursor (keyset) pagination uses a stable sort key (
WHERE (created_at, id) > (:c1, :c2) ORDER BY … LIMIT n) so cost is O(page size) and results stay consistent under writes. Trade-off: no random page access, cursor must be opaque and encode the sort tuple. - ⭐ Optimal (senior): Choose by access pattern: offset is fine for small, mostly-static admin tables where “page 7” UX matters; cursor is mandatory for large, write-heavy, or infinite-scroll feeds. The cursor must be opaque (base64 of the tuple, ideally signed) so you can change the underlying sort without breaking clients, and must include a tiebreaker (
id) because the primary sort key is rarely unique. Always paginate on an indexed column matching the sort; a covering index on(sort_key, id)keeps it index-only. Returnnext_cursor: nullto signal end, never rely oncount.
Q3: How do you version a REST API and evolve it without breaking existing clients?
- ❌ Wrong / trap: “Bump the version and change the field types” or “just add
?v=2and rewrite the response” — fails: breaking a live contract. Renaming/removing/retyping a field is a breaking change regardless of where the version lives. - ✅ Correct: Prefer additive, backward-compatible changes (add optional fields, add endpoints, tolerate unknown fields). When you must break, expose an explicit version — URL (
/v2/…) or header (Accept: application/vnd.api.v2+json) — and run old + new in parallel with a deprecation window. - ⭐ Optimal (senior): URL versioning wins on visibility, cacheability and curl-ability (public APIs); header/media-type versioning is “purer” REST and keeps URIs stable but is harder to debug and to cache. Rule of thumb: version at the boundary, evolve additively within a version. Never repurpose a field’s meaning; add a new one and deprecate the old with
Sunset/Deprecationheaders + changelog. Treat consumers as untrusted: be liberal in what you accept, conservative in what you emit (Postel), and use consumer-driven contract tests (Pact) so you know what breaks. gRPC gives this cheaply via protobuf field numbers (never reuse a number, only add).
Q4: When would you choose gRPC over REST, and what do you give up?
- ❌ Wrong / trap: “gRPC is faster, so always use gRPC” — fails: ignores that gRPC isn’t natively browser-consumable (needs gRPC-Web + a proxy like Envoy), loses HTTP caching and human-readable payloads, and adds protobuf/codegen build friction. “Faster” without context is a red flag.
- ✅ Correct: Pick gRPC for internal, high-throughput, low-latency service-to-service calls in polyglot environments where a strongly-typed contract + generated clients and native streaming pay off. Pick REST for public/browser-facing APIs where ubiquity, caching, and debuggability matter.
- ⭐ Optimal (senior): gRPC’s wins: HTTP/2 multiplexing (no head-of-line blocking at the request level), compact binary framing, first-class streaming (server/client/bidi), and schema-driven codegen that eliminates a class of integration bugs. Costs: no intermediary HTTP caching, opaque wire (harder ops/debugging), load-balancing needs L7-aware infra (HTTP/2 long-lived connections defeat naive L4 LBs), and browsers need gRPC-Web. Backward compat is a discipline: never reuse or renumber protobuf fields, only append; reserve removed numbers/names. A common senior answer is both: gRPC internally, a REST/GraphQL gateway at the edge (e.g. via transcoding).
Q5: GraphQL trades REST’s rigidity for flexibility — what does that cost you on the server?
- ❌ Wrong / trap: “GraphQL is strictly better than REST because clients get exactly what they need and there’s one endpoint” — fails: it silently trades client convenience for server-side N+1 queries, lost HTTP caching, per-field authorization, and unbounded query cost that becomes a DoS vector.
- ✅ Correct: GraphQL removes over/under-fetching and collapses round-trips, but each resolver can trigger its own data fetch (N+1), HTTP-level caching mostly disappears (single
POST /graphql), authorization must be enforced per field, and clients can craft expensive deep/wide queries. - ⭐ Optimal (senior): Mitigations are the interview gold: DataLoader-style batching+caching per request to kill N+1; persisted queries / APQ to restore edge caching and lock down allowed operations; query depth + cost/complexity limits and timeouts to bound the blast radius; field-level authz in resolvers (not just at the gateway) because any field is a potential leak; and pagination via the Relay connection spec. Decide GraphQL when you have many diverse clients and a graph-shaped domain; stay REST when the domain is resource-shaped, cache-heavy, or public with untrusted consumers. It’s not REST-vs-GraphQL org-wide — often GraphQL is a BFF aggregating REST/gRPC services.
Q6: What is HATEOAS / the Richardson Maturity Model, and is level 3 worth it?
- ❌ Wrong / trap: “You’re not really RESTful unless you implement HATEOAS, so every API must return hypermedia links” — fails: purist dogma. Full HATEOAS is rarely justified; most successful “REST” APIs (Stripe, GitHub) sit at level 2 and are pragmatically fine.
- ✅ Correct: The Richardson Maturity Model grades REST-ness: L0 = single URI/RPC-over-HTTP (POX), L1 = resources, L2 = proper HTTP verbs + status codes, L3 = hypermedia controls (HATEOAS) where responses embed links telling the client the next available actions.
- ⭐ Optimal (senior): L2 is the practical sweet spot and what most public APIs target. HATEOAS pays off when you want to decouple clients from URI structure and workflow state (client follows server-provided links, so you can restructure endpoints or gate actions by state without client changes) — valuable for long-lived, many-client, evolving APIs or hypermedia-driven UIs. It’s overkill when clients are few, tightly coupled, and code-generated from a spec, because the discoverability benefit goes unused and the payload/complexity tax is real. The senior framing: maturity level is a cost/benefit choice about coupling and evolvability, not a correctness ladder you must climb.
Q7: Why is returning 200 OK with an error object in the body an anti-pattern, and how should errors be modeled?
- ❌ Wrong / trap: “It’s fine, the client just checks a
success: falsefield in the JSON” — fails: it breaks every layer that reads the HTTP status — caches, load balancers, retry/circuit-breaker logic, monitoring, and generic client libraries all see200and assume success. - ✅ Correct: Use the semantically correct status code (
404,409,422,429,5xx) and a consistent error body. The standard is Problem Details for HTTP APIs (RFC 7807, updated/obsoleted by RFC 9457) with media typeapplication/problem+jsonand fieldstype,title,status,detail,instance. - ⭐ Optimal (senior): The status code is part of the contract that non-application infrastructure depends on:
5xxtriggers retries/circuit breakers,429triggers backoff,4xxmust not be retried, and caches key on status. Beyond correctness: distinguish401(unauthenticated) from403(authorized-but-forbidden),400(malformed) from422(well-formed but semantically invalid), and409(conflict with current resource state) from422. Add extension members (errors[],code, domain fields) to problem+json rather than inventing a new envelope per endpoint, and keeptypea stable, dereferenceable URI so clients can branch on error type without string-matchingtitle.
Q8: When is JSON-RPC the right choice over REST, and what do you lose?
- ❌ Wrong / trap: “JSON-RPC is just an older/worse REST, always prefer REST” — fails: they solve different shapes. JSON-RPC is a procedure-call protocol, not a resource protocol; dismissing it ignores domains (blockchain nodes, LSP, internal tooling) where actions genuinely aren’t resources.
- ✅ Correct: JSON-RPC 2.0 is a minimal transport-agnostic protocol: one endpoint,
method+paramsdispatch, anidto correlate request/response (omit it for fire-and-forget notifications), standardized error codes, and batch support. Pick it when operations are pure RPC (eth_call,estimateGas,textDocument/completion) that don’t model well as CRUD resources. - ⭐ Optimal (senior): JSON-RPC decouples from HTTP semantics, which is both its strength (works over HTTP, WebSocket, stdio — hence LSP and Ethereum use it) and its cost: you lose HTTP caching, semantic status codes, content negotiation, and resource discoverability. There’s no built-in idempotency/verb semantics, so retry-safety and authorization are entirely your responsibility. Use it for internal or specialized action-oriented protocols; reach for REST when the domain is resource-shaped, needs HTTP caching, or is a public API where the ecosystem’s tooling (curl, proxies, gateways) is a feature. gRPC is the more common modern answer when you want RPC and performance/streaming/codegen — JSON-RPC wins on simplicity and human-readability.
Referencias
- Richardson Maturity Model — Martin Fowler: https://martinfowler.com/articles/richardsonMaturityModel.html
- REST / HATEOAS — Roy Fielding, “REST APIs must be hypertext-driven”: https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
- HTTP method idempotency/safety — MDN: https://developer.mozilla.org/en-US/docs/Glossary/Idempotent
- Stripe API idempotency (Idempotency-Key): https://docs.stripe.com/api/idempotent_requests
- Problem Details for HTTP APIs — RFC 9457 (obsoletes RFC 7807): https://www.rfc-editor.org/rfc/rfc9457
- API versioning & evolution best practices — Postel’s law, Sunset header (RFC 8594): https://www.rfc-editor.org/rfc/rfc8594
- OpenAPI Specification: https://spec.openapis.org/oas/latest.html
- FastAPI — OpenAPI / automatic docs: https://fastapi.tiangolo.com/tutorial/metadata/
- gRPC Core Concepts (streaming, contract): https://grpc.io/docs/what-is-grpc/core-concepts/
- Protobuf — updating/evolving messages (backward compat rules): https://protobuf.dev/programming-guides/proto3/
- GraphQL best practices (pagination, N+1, caching): https://graphql.org/learn/best-practices/
- GraphQL cursor connections (Relay) spec: https://relay.dev/graphql/connections.htm
- JSON-RPC 2.0 spec: https://www.jsonrpc.org/specification