StackPractices
intermediate Por Mathias Paulenko

Implementar una API GraphQL

Construye una API GraphQL lista para producción con schemas tipados, resolvers y mutaciones en Python, JavaScript y Java.

Temas: api

Visión General

GraphQL es un lenguaje de consultas y un runtime para APIs donde el cliente declara exactamente qué campos necesita. En lugar de las respuestas fijas de REST, un único endpoint /graphql sirve combinaciones arbitrarias de campos — por eso encaja en apps cuyas pantallas consumen cortes distintos de los mismos datos.

Esta receta construye una API GraphQL funcional en tres stacks: Python con Strawberry y FastAPI, JavaScript con Apollo Server 4 y Java con Spring for GraphQL. Cada ejemplo incluye una Query y una Mutation para que veas las dos caras del contrato.

Cuándo Usar

Usa este recurso cuando:

  • Tus clientes necesitan flexibilidad al pedir datos (apps móviles con ancho de banda limitado, dashboards con layouts variables).
  • Quieres un contrato de API fuertemente tipado con documentación viva que sale sola del schema vía introspection.
  • Necesitas agregar datos de varios microservicios — para llamadas servicio-a-servicio dentro de tu propio cluster, gRPC suele ser mejor opción.
  • Los consumidores de tu API piden combinaciones de campos distintas con frecuencia.

Solución

Python

Strawberry + FastAPI es la pareja moderna: los tipos salen de decoradores y el router se monta bajo /graphql.

import strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

BOOKS = [
    {"title": "Clean Code", "author": "Robert C. Martin", "pages": 464},
    {"title": "The Pragmatic Programmer", "author": "Andy Hunt", "pages": 352},
]

@strawberry.type
class Book:
    title: str
    author: str
    pages: int

@strawberry.type
class Query:
    @strawberry.field
    def books(self) -> list[Book]:
        return [Book(**b) for b in BOOKS]

@strawberry.type
class Mutation:
    @strawberry.mutation
    def add_book(self, title: str, author: str, pages: int) -> Book:
        BOOKS.append({"title": title, "author": author, "pages": pages})
        return Book(title=title, author=author, pages=pages)

schema = strawberry.Schema(query=Query, mutation=Mutation)
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")

Ejecuta uvicorn main:app --reload y abre http://localhost:8000/graphql — Strawberry incluye GraphiQL en esa ruta.

JavaScript

Apollo Server 4 separa el transporte del núcleo: startStandaloneServer te da un servidor completo sin framework web.

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `#graphql
  type Book {
    title: String!
    author: String!
    pages: Int!
  }

  type Query {
    books: [Book!]!
  }

  type Mutation {
    addBook(title: String!, author: String!, pages: Int!): Book!
  }
`;

const books = [
  { title: 'Clean Code', author: 'Robert C. Martin', pages: 464 },
  { title: 'The Pragmatic Programmer', author: 'Andy Hunt', pages: 352 },
];

const resolvers = {
  Query: {
    books: () => books,
  },
  Mutation: {
    addBook: (_, { title, author, pages }) => {
      const book = { title, author, pages };
      books.push(book);
      return book;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`Servidor listo en ${url}`);

Guárdalo como index.mjs (el await de nivel superior requiere ESM) y ejecútalo con node index.mjs.

Java

Spring for GraphQL es el estándar actual — el schema vive en src/main/resources/graphql/schema.graphqls y los resolvers son métodos de un controlador:

// src/main/resources/graphql/schema.graphqls
// type Book { title: String!, author: String!, pages: Int! }
// type Query { books: [Book!]! }
// type Mutation { addBook(title: String!, author: String!, pages: Int!): Book! }

@Controller
public class BookController {

    private final List<Book> books = new ArrayList<>(List.of(
        new Book("Clean Code", "Robert C. Martin", 464),
        new Book("The Pragmatic Programmer", "Andy Hunt", 352)
    ));

    @QueryMapping
    public List<Book> books() {
        return books;
    }

    @MutationMapping
    public Book addBook(@Argument String title,
                        @Argument String author,
                        @Argument int pages) {
        Book book = new Book(title, author, pages);
        books.add(book);
        return book;
    }
}

record Book(String title, String author, int pages) {}

Añade spring-boot-starter-graphql al proyecto; el endpoint queda en /graphql por defecto. Evita las librerías antiguas graphql-java-servlet / com.coxautodev — están sin mantenimiento y todavía arrastran javax.servlet.

Suscripciones

Las suscripciones van por un transporte WebSocket, pero la forma es la misma en todas partes: un stream asíncrono que emite valores a medida que ocurren los eventos. En Strawberry una suscripción es solo un generador asíncrono:

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def book_added(self) -> AsyncGenerator[Book, None]:
        while True:
            yield await BOOK_EVENTS.get()  # asyncio.Queue alimentada por add_book

En JavaScript la convención es graphql-ws bajo el protocolo WS; Spring conecta métodos @SubscriptionMapping a su endpoint WebSocket de la misma manera. Si no necesitas push en vivo, sáltalo — sondear una query suele ser suficiente.

Explicación

Por dentro, una API GraphQL son tres piezas:

  • Schema: el contrato tipado — tipos, queries, mutaciones, suscripciones — escrito en SDL (el archivo typeDefs / schema.graphqls) o generado desde código.
  • Resolvers: una función por campo que devuelve los datos reales. Reciben (parent, args, context, info); mantenlos sin estado.
  • Servidor: acepta la petición HTTP, parsea la query, la valida contra el schema, ejecuta los resolvers y ensambla data + errors.

Vale la pena tener el ciclo de vida de la petición en la cabeza:

flowchart diagram: Cliente -->|POST /graphql| HTTP[Capa HTTP

Dos detalles que el diagrama explica:

  • Un 200 puede ser un fallo. Los errores de validación y ejecución vuelven dentro de errors con HTTP 200 — revisa siempre ese array del lado del cliente (más patrones en manejo de errores GraphQL).
  • Los resolvers corren por campo, no por petición. Una query que toca books { author { books { ... } } } llama a un resolver por libro — ahí vive el problema N+1 y por eso existe el batching con DataLoader.

Diferencias entre lenguajes que conviene conocer:

  • Python (Strawberry): code-first — los decoradores generan el SDL desde tus clases.
  • JavaScript (Apollo): schema-first — SDL en typeDefs, resolvers como objeto plano.
  • Java (Spring): schema-first — archivo .graphqls más métodos @QueryMapping/@MutationMapping.

Variantes

TecnologíaBibliotecaEnfoqueNotas
PythonStrawberryCode-firstDecoradores, integración con FastAPI
PythonGrapheneCode-firstIntegración Django, ecosistema maduro
JavaScriptApollo ServerSchema-firstFederación, suscripciones, el más usado
JavaScriptNexusCode-firstTypeScript-first, inferencia de tipos
JavaSpring for GraphQLSchema-firstStarter de Spring Boot, estándar actual
JavaDGS FrameworkCode-firstOpen-source de Netflix, integración Spring

Cuándo No Usar Este Enfoque

  • CRUD simple con schema estable: para 5-10 endpoints con formas predecibles, REST es más simple, más fácil de depurar y cachea gratis.
  • APIs públicas cacheadas en CDN: los POST de GraphQL esquivan la caché de borde por defecto. Para datos públicos de lectura intensiva, los GET de REST más un CDN rinden 10-100x más barato.
  • Clientes con ancho de banda limitado: la introspection del schema y los payloads más grandes añaden overhead real en redes 2G/3G o enlaces IoT.

Consideraciones de Seguridad

La flexibilidad de GraphQL también es su superficie de ataque. Estos son los controles que importan:

  • Límite de profundidad de queries: queries anidadas como user { friends { friends { ... } } } agotan la memoria. Limita la profundidad a 7-10 niveles con graphql-depth-limit o el equivalente de tu framework.
  • Análisis de complejidad de queries: asigna un coste por campo y rechaza queries que superen un presupuesto — una sola query de cliente puede desencadenar 5-15 llamadas internas en un esquema federado.
  • Ataques de batch queries: varias operaciones en un solo POST esquivan los rate limits por petición. Limita el conteo del batch (5 es razonable) y aplica rate limit por operación.
  • Introspection en producción: desactívala (introspection: false) o protégela con auth — le da al atacante el mapa completo de tu superficie de API.
  • Persisted queries: en producción, sirve solo queries aprobadas del lado del servidor por hash. Elimina por completo la ejecución de queries arbitrarias.
  • Los errores no deben filtrar internals: quita stack traces y detalle de extensions en los builds de producción.
  • Mutations y CSRF: si el endpoint acepta cookies, exige un header personalizado o pasa las mutations a auth por token.

Mejores Prácticas

  • Usa DataLoader para las queries N+1: agrupa y deduplica las llamadas a BD por petición — convierte 1 + N queries en 2. La receta de batching cubre la mecánica.
  • Pagina las colecciones: usa paginación por cursores (spec Relay Connection) para listas más allá de una página.
  • Valida la entrada temprano: escalares personalizados y directivas atrapan entrada mala antes de que corran los resolvers.
  • Depreca, no versiones: evoluciona el schema con campos @deprecated en lugar de endpoints /v2.
  • Mantén los resolvers delgados: la lógica de negocio va en una capa de servicio; los resolvers solo adaptan el plumbing de HTTP/GraphQL a ella.
  • No filtres modelos de BD: mapea objetos de dominio a tipos del schema en lugar de exponer entidades del ORM directamente.

Errores Comunes

  • Ignorar el array errors: los clientes que solo revisan data descartan fallos en silencio — GraphQL devuelve 200 con errors: [...] tanto para fallos de validación como de resolvers.
  • Sin límites de profundidad o complejidad: una query hostil puede agotar el servidor. Configura maxDepth y maxComplexity en las reglas de validación desde el día uno.
  • Resolvers con N+1: obtener objetos relacionados fila a fila en lugar de por lotes — una query de 100 usuarios con sus posts se convierte en 101 accesos a BD.
  • Guardar estado en resolvers: los resolvers se comparten entre peticiones; los datos del scope de la petición van en context.
  • Devolver errores con stack traces: los extensions de error en producción deben ocultar internals.
  • Saltarse la capa de dominio: conectar entidades del ORM directamente al schema acopla tu contrato de API a tus tablas.

Monitoring y Observabilidad

  • Rastrea la latencia por resolver: p50/p95/p99 por campo — los resolvers lentos (p95 >100ms) suelen ser el cuello de botella, no la capa HTTP.
  • Registra los scores de complejidad de query: alerta cuando el promedio semanal suba >20%; normalmente significa que un cliente empezó a pedir grafos más profundos.
  • Vigila el ratio de lotes: los batches de DataLoader vs. llamadas totales por debajo de ~80% señalizan que el N+1 vuelve a colarse.
  • Limita las conexiones de suscripción: las suscripciones WebSocket retienen memoria por cliente; alerta antes de tocar límites de FD o heap.

Estrategia de Testing

  • Resolvers en aislamiento: llámalos con un context y argumentos de prueba; verifica valores de retorno y caminos de error.
  • Tests de introspection del schema: verifica que el schema desplegado expone solo los campos previstos — atrapa exposición accidental antes de que la encuentren los clientes.
  • Tests de límites de complejidad: dispara queries profundamente anidadas y con muchos campos; verifica que el analizador las rechaza con un error claro.
  • Ciclo de vida de suscripciones: conecta, recibe actualizaciones, desconecta, confirma que el servidor libera la suscripción.

Notas de Rendimiento y Coste

GraphQL añade overhead de parsing + validación + resolución por campo sobre REST — típicamente 2-5x de latencia para lecturas equivalentes sin caché. Las palancas que cierran la brecha, en orden de esfuerzo:

  1. Persisted queries — saltan el parsing por completo en tráfico repetido.
  2. Caché de respuestas — cachea por hash de query en Redis para endpoints de lectura intensiva (un TTL de 60s es un arranque razonable).
  3. DataLoader — colapsa llamadas a BD por campo en lotes.
  4. Caché en el gateway federado — cachea resultados de sub-resolvers en el gateway para evitar llamadas duplicadas a subgrafos.

En costes, la vía self-hosted es barata: Apollo Server o Strawberry corren en instancias comunes, y los tiers gestionados (Apollo Cloud, Hasura Cloud) sobre todo te compran el registry de schema y la caché gestionada, más que throughput bruto.

Checklist de Producción

  • Límite de profundidad (7-10) y presupuesto de complejidad aplicados en las reglas de validación
  • Introspection deshabilitada o protegida en producción
  • Persisted queries aplicadas; queries arbitrarias rechazadas
  • Batching con DataLoader en todo resolver que toca BD
  • Rate limiting por operación y por conteo de batch, no solo por petición
  • Las respuestas de error eliminan stack traces e extensions internas
  • Paginación (por cursores) en todos los campos de colección
  • Límites de conexión de suscripción y timeouts de heartbeat configurados
  • Los cambios de schema se revisan por campos que rompen antes del despliegue
  • Schema registrado en Apollo Studio o registry equivalente

Troubleshooting

  • El cliente recibe data pero errors no está vacío: fallo parcial — algunos resolvers fallaron mientras otros funcionaron. Lee errors[].path para encontrar el campo roto; revisa los logs de ese resolver, no el status HTTP.
  • “Cannot query field” en producción pero funciona en local: la introspection está desactivada pero el cliente depende de la descarga del schema, o la query usa un campo que no existe en el schema desplegado — regenera los tipos del cliente desde el registry.
  • La memoria sube con queries anidadas: límites de profundidad/complejidad ausentes o demasiado generosos. Perfil el tamaño del plan de ejecución de la query problemática.
  • Respuestas lentas pese a BD rápida: resolvers esperando en serie por fila — eso es N+1; añade DataLoader o un lote findByIds.
  • Errores CORS desde el navegador: el endpoint GraphQL necesita Access-Control-Allow-Origin en el preflight como cualquier ruta de API — ver manejo de CORS.

Véase También

Preguntas frecuentes

¿Debería migrar mi API REST a GraphQL?

No necesariamente. GraphQL compensa cuando los clientes necesitan flexibilidad — conjuntos de campos variables, anidación profunda, un solo round-trip para una pantalla compleja. Si tus consumidores son pocos y estables, REST sigue siendo más simple y barato de cachear.

¿Cómo manejo la subida de archivos en GraphQL?

Usa el spec de GraphQL multipart request (Apollo Server lo soporta; Strawberry vía el escalar Upload) o mantén un endpoint REST pequeño para las subidas y devuelve la URL por GraphQL. La mayoría de equipos toma la segunda vía y saca las subidas del grafo.

¿Qué es la federación de GraphQL?

La federación permite que varios servicios GraphQL posean cada uno parte de un schema unificado, unido por un gateway. Es cómo los equipos de microservicios exponen un solo grafo — ver la receta de gateway de federación.

¿Por qué GraphQL devuelve 200 aunque falle?

En GraphQL el error es contenido, no transporte. Los fallos de validación, de resolvers y parciales vuelven todos como HTTP 200 con un array errors — es por diseño del spec para que el data parcial pueda entregarse igualmente. Trata errors como tu status real.

¿Cómo detengo las queries costosas?

Tres capas: configura maxDepth (7-10) para que las queries no puedan anidarse infinitamente, añade análisis de complejidad para que cada campo lleve un coste con un presupuesto total, y aplica persisted queries en producción para que solo corran queries preaprobadas.

¿Cuándo GraphQL es la elección equivocada?

Cuando la flexibilidad no se necesita: un puñado de endpoints CRUD estables, APIs públicas que viven del cacheo de borde del CDN, o clientes en redes limitadas. En esos casos la simplicidad y cacheabilidad de REST ganan directamente.