GraphQL práctico con Claude Code: SDL, resolvers tipados, DataLoader y tests
Crea APIs GraphQL con Claude Code: SDL, resolvers tipados, N+1, autorización, complejidad, errores y pruebas.
Cuando le pides a Claude Code que cree una API GraphQL, la primera decisión no es qué tipos añadir. Primero necesitas decidir dónde vive el contrato, dónde se aplica la autorización, cómo se evita N+1, cómo se rechazan operaciones demasiado profundas o caras, y cómo los errores y tests demuestran que la API se puede publicar. Un prompt vago como “hazlo en GraphQL” puede generar una demo funcional con riesgos de producción escondidos.
En esta guía, Claude Code se usa como compañero de implementación, no como generador de código de una sola pasada. SDL es el lenguaje de definición de esquema: el contrato textual de GraphQL. Un resolver es la función que obtiene el valor de un campo. DataLoader es una utilidad de batching por request que ayuda a reducir lecturas repetidas durante una operación GraphQL.
Usa fuentes primarias: GraphQL Schemas and Types, Pagination, Authorization, Security, Response Format de la especificación, DataLoader README, GraphQL Code Generator TypeScript Resolvers y Claude Code docs. Para contexto de ClaudeCodeLab, revisa desarrollo de APIs, testing de APIs y técnicas de prompts.
Elegir schema-first o resolver-first
Hay dos formas comunes de empezar. Schema-first significa escribir SDL primero, tratarlo como contrato público y después implementar resolvers y tests. Resolver-first significa partir de servicios existentes, funciones TypeScript o acceso a datos, y luego dar forma al esquema. Con Claude Code, schema-first suele ser más fácil de revisar para APIs nuevas, móviles o usadas por varios equipos. Resolver-first funciona bien cuando migras una capa REST o de dominio que ya existe.
Tres casos lo muestran. En un dashboard SaaS, los límites de equipo, facturación y auditoría deben verse antes de programar. En una app móvil, GraphQL reduce viajes de red, pero exige paginación, límites de operación y errores estables. En una herramienta interna de análisis, quizá convenga reutilizar servicios existentes, pero la autorización debe seguir en lógica de negocio compartida.
Entrega esa decisión en el primer prompt.
Design this GraphQL API schema-first.
Requirements:
- Put SDL in src/graphql/schema.graphql
- Type TypeScript resolvers with GraphQL Code Generator
- Use cursor-based connections for list fields
- Keep authorization in repo/service functions, not scattered through resolvers
- Create DataLoader instances per request inside createContext
- Include depth and complexity validation, safe error formatting, and Vitest tests
- Report verification commands and remaining pitfalls after implementation
Esto fija las fronteras de ingeniería antes de hablar de librerías concretas. Apollo Server, GraphQL Yoga, Mercurius o GraphQL.js pueden ser válidos, pero contrato, autorización, control de demanda y pruebas no deben quedar implícitos.
Poner SDL en el centro
El siguiente SDL define una API mínima de reseñas de productos. Sigue el modelo de conexión de la guía oficial de paginación: edges, node, cursor y pageInfo. Un campo anidado como Product.reviews es útil, pero también puede abrir N+1 y operaciones profundas, así que después añadimos DataLoader y demand control.
npm install graphql @graphql-tools/schema dataloader
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers vitest tsx typescript
# src/graphql/schema.graphql
interface Node {
id: ID!
}
type Product implements Node {
id: ID!
title: String!
isPublic: Boolean!
averageRating: Float
reviews(first: Int = 10, after: String): ReviewConnection!
createdAt: String!
}
type Review implements Node {
id: ID!
product: Product!
rating: Int!
body: String!
authorId: ID!
createdAt: String!
}
type ProductEdge {
node: Product!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ReviewEdge {
node: Review!
cursor: String!
}
type ReviewConnection {
edges: [ReviewEdge!]!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
input CreateReviewInput {
productId: ID!
rating: Int!
body: String!
}
type Query {
product(id: ID!): Product
products(first: Int = 10, after: String): ProductConnection!
}
type Mutation {
createReview(input: CreateReviewInput!): Review!
}
Los tipos de GraphQL no sustituyen toda la validación. rating: Int! dice que el valor es entero, no que esté entre 1 y 5. Longitud del texto, HTML inseguro y visibilidad de productos privados se validan en resolvers o servicios. Pídele a Claude Code que separe “restricciones del SDL” y “restricciones del servicio”.
Generar resolvers tipados
El atajo peligroso en TypeScript es aceptar parent, args y context como any. Parece rápido, pero cada cambio de esquema se vuelve un riesgo. GraphQL Code Generator con typescript-resolvers genera firmas de resolver desde SDL para mantener argumentos, parents, contexto y retornos alineados.
// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "src/graphql/schema.graphql",
generates: {
"src/graphql/generated/resolvers-types.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
contextType: "../context#GraphQLContext",
mappers: {
Product: "../models#ProductRow",
Review: "../models#ReviewRow",
},
useIndexSignature: true,
},
},
},
};
export default config;
{
"scripts": {
"codegen": "graphql-codegen --config codegen.ts",
"test:graphql": "npm run codegen && vitest run test/graphql.test.ts"
}
}
mappers importa porque los tipos GraphQL y las filas de base de datos no suelen tener la misma forma. Product expone reviews y averageRating; ProductRow no tiene por qué. Esta diferencia explícita evita que Claude Code invente campos de almacenamiento para satisfacer el tipo de GraphQL.
Evitar N+1 con DataLoader por request
N+1 ocurre cuando una lista genera una lectura adicional por cada fila. Traes 10 productos y luego haces 10 consultas de reseñas. DataLoader agrupa llamadas .load() del mismo request y devuelve resultados en el mismo orden que las claves. Su cache es por request: no reemplaza Redis y no debe compartirse entre usuarios.
// src/graphql/models.ts
export type Viewer = { id: string; role: "user" | "admin" } | null;
export type ProductRow = {
id: string;
title: string;
ownerId: string;
isPublic: boolean;
createdAt: string;
};
export type ReviewRow = {
id: string;
productId: string;
rating: number;
body: string;
authorId: string;
createdAt: string;
};
const products: ProductRow[] = [
{ id: "prod_1", title: "Claude Code Playbook", ownerId: "user_1", isPublic: true, createdAt: "2026-06-02T00:00:00.000Z" },
{ id: "prod_private", title: "Internal Prompt Pack", ownerId: "user_2", isPublic: false, createdAt: "2026-06-02T00:00:00.000Z" },
];
const reviews: ReviewRow[] = [
{ id: "rev_1", productId: "prod_1", rating: 5, body: "Practical.", authorId: "user_1", createdAt: "2026-06-02T00:10:00.000Z" },
{ id: "rev_2", productId: "prod_1", rating: 4, body: "Useful for reviews.", authorId: "user_2", createdAt: "2026-06-02T00:20:00.000Z" },
];
function canSeeProduct(viewer: Viewer, product: ProductRow) {
return product.isPublic || viewer?.role === "admin" || viewer?.id === product.ownerId;
}
export const repo = {
productById(id: string, viewer: Viewer) {
const product = products.find((item) => item.id === id);
return product && canSeeProduct(viewer, product) ? product : null;
},
products(viewer: Viewer) {
return products.filter((product) => canSeeProduct(viewer, product));
},
reviewsByProductIds(productIds: readonly string[], viewer: Viewer) {
const visibleIds = new Set(
products.filter((product) => productIds.includes(product.id) && canSeeProduct(viewer, product)).map((product) => product.id),
);
return reviews.filter((review) => visibleIds.has(review.productId));
},
createReview(input: { productId: string; rating: number; body: string }, viewer: NonNullable<Viewer>) {
const product = products.find((item) => item.id === input.productId);
if (!product || !canSeeProduct(viewer, product)) return null;
if (input.rating < 1 || input.rating > 5) return null;
const review: ReviewRow = {
id: `rev_${reviews.length + 1}`,
productId: input.productId,
rating: input.rating,
body: input.body.slice(0, 1000),
authorId: viewer.id,
createdAt: new Date().toISOString(),
};
reviews.push(review);
return review;
},
};
// src/graphql/context.ts
import { randomUUID } from "node:crypto";
import DataLoader from "dataloader";
import { repo, type ReviewRow, type Viewer } from "./models";
function createLoaders(viewer: Viewer) {
return {
reviewsByProductId: new DataLoader<string, ReviewRow[]>(async (productIds) => {
const rows = repo.reviewsByProductIds(productIds, viewer);
const grouped = new Map<string, ReviewRow[]>();
for (const productId of productIds) grouped.set(productId, []);
for (const review of rows) grouped.get(review.productId)?.push(review);
return productIds.map((productId) => grouped.get(productId) ?? []);
}),
};
}
export type GraphQLContext = {
viewer: Viewer;
requestId: string;
loaders: ReturnType<typeof createLoaders>;
};
export function createContext(options: { viewer?: Viewer; requestId?: string } = {}): GraphQLContext {
const viewer = options.viewer ?? null;
return {
viewer,
requestId: options.requestId ?? randomUUID(),
loaders: createLoaders(viewer),
};
}
// src/graphql/resolvers.ts
import { GraphQLError } from "graphql";
import type { Resolvers } from "./generated/resolvers-types";
import { repo, type ReviewRow } from "./models";
function encodeCursor(id: string) {
return Buffer.from(id).toString("base64url");
}
function decodeCursor(cursor?: string | null) {
if (!cursor) return null;
try {
return Buffer.from(cursor, "base64url").toString("utf8");
} catch {
return null;
}
}
function connectionFromRows<T extends { id: string }>(rows: T[], args: { first?: number | null; after?: string | null }) {
const first = Math.min(Math.max(args.first ?? 10, 1), 50);
const afterId = decodeCursor(args.after);
const startIndex = afterId ? rows.findIndex((row) => row.id === afterId) + 1 : 0;
const slice = rows.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + first + 1);
const page = slice.slice(0, first);
return {
edges: page.map((node) => ({ node, cursor: encodeCursor(node.id) })),
pageInfo: {
hasNextPage: slice.length > first,
endCursor: page.length > 0 ? encodeCursor(page[page.length - 1].id) : null,
},
};
}
export const resolvers: Resolvers = {
Node: {
__resolveType(parent) {
return "rating" in parent ? "Review" : "Product";
},
},
Query: {
product: (_parent, args, context) => repo.productById(args.id, context.viewer),
products: (_parent, args, context) => connectionFromRows(repo.products(context.viewer), args),
},
Product: {
reviews: async (product, args, context) => {
const rows = await context.loaders.reviewsByProductId.load(product.id);
return connectionFromRows(rows, args);
},
averageRating: async (product, _args, context) => {
const rows = await context.loaders.reviewsByProductId.load(product.id);
if (rows.length === 0) return null;
return rows.reduce((sum: number, review: ReviewRow) => sum + review.rating, 0) / rows.length;
},
},
Review: {
product: (review, _args, context) => {
const product = repo.productById(review.productId, context.viewer);
if (!product) {
throw new GraphQLError("Product is not available.", { extensions: { code: "FORBIDDEN" } });
}
return product;
},
},
Mutation: {
createReview: (_parent, args, context) => {
if (!context.viewer) {
throw new GraphQLError("Login required.", { extensions: { code: "UNAUTHENTICATED" } });
}
const review = repo.createReview(args.input, context.viewer);
if (!review) {
throw new GraphQLError("Review could not be created.", { extensions: { code: "BAD_USER_INPUT" } });
}
return review;
},
},
};
La autorización se concentra en repo. La guía oficial de GraphQL recomienda delegarla a la capa de negocio para evitar reglas duplicadas. Así “este usuario puede ver este producto” se decide una vez, aunque haya varias rutas de consulta.
Limitar profundidad y complejidad
GraphQL permite al cliente elegir campos, pero esa libertad puede crear operaciones caras: nesting profundo, first enorme, muchos alias o batch. La guía de seguridad de GraphQL recomienda combinar trusted documents, paginación, depth limiting, breadth/batch limiting, rate limiting y query complexity analysis.
Este validation rule pequeño funciona con GraphQL.js. Cuenta first y last literales. Los valores por variable no siempre están disponibles durante validation, así que el resolver también limita first.
// src/graphql/demandControl.ts
import {
GraphQLError,
Kind,
type ASTVisitor,
type FieldNode,
type FragmentDefinitionNode,
type SelectionSetNode,
type ValidationContext,
} from "graphql";
type Limits = {
maxDepth: number;
maxComplexity: number;
defaultListSize: number;
maxListSize: number;
};
const defaultLimits: Limits = {
maxDepth: 6,
maxComplexity: 300,
defaultListSize: 10,
maxListSize: 50,
};
export function depthAndComplexityRule(limits: Partial<Limits> = {}) {
const merged = { ...defaultLimits, ...limits };
return (context: ValidationContext): ASTVisitor => {
const fragments = new Map<string, FragmentDefinitionNode>();
return {
FragmentDefinition(node) {
fragments.set(node.name.value, node);
},
OperationDefinition(node) {
const score = scoreSelectionSet(node.selectionSet, fragments, new Set(), 0, 1, merged);
if (score.depth > merged.maxDepth) {
context.reportError(new GraphQLError(`GraphQL operation is too deep: ${score.depth}`, { nodes: node }));
}
if (score.complexity > merged.maxComplexity) {
context.reportError(
new GraphQLError(`GraphQL operation is too complex: ${score.complexity}`, { nodes: node }),
);
}
},
};
};
}
function listMultiplier(field: FieldNode, limits: Limits) {
const arg = field.arguments?.find((item) => item.name.value === "first" || item.name.value === "last");
if (!arg) return 1;
if (arg.value.kind !== Kind.INT) return limits.defaultListSize;
return Math.min(Number.parseInt(arg.value.value, 10), limits.maxListSize);
}
function scoreSelectionSet(
selectionSet: SelectionSetNode,
fragments: Map<string, FragmentDefinitionNode>,
seenFragments: Set<string>,
depth: number,
multiplier: number,
limits: Limits,
) {
let maxDepth = depth;
let complexity = 0;
for (const selection of selectionSet.selections) {
if (selection.kind === Kind.FIELD) {
const nextMultiplier = multiplier * listMultiplier(selection, limits);
complexity += nextMultiplier;
if (selection.selectionSet) {
const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth + 1, nextMultiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth, multiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragmentName = selection.name.value;
if (seenFragments.has(fragmentName)) continue;
const fragment = fragments.get(fragmentName);
if (!fragment) continue;
seenFragments.add(fragmentName);
const child = scoreSelectionSet(fragment.selectionSet, fragments, seenFragments, depth, multiplier, limits);
maxDepth = Math.max(maxDepth, child.depth);
complexity += child.complexity;
}
}
return { depth: maxDepth, complexity };
}
Para clientes propios, trusted documents puede ser más fuerte que aceptar texto GraphQL arbitrario. En APIs públicas, añade rate limit HTTP, timeouts, límites de lista y logs.
Estandarizar errores y pruebas
Las respuestas GraphQL usan data, errors y extensions. La especificación diferencia errores antes de ejecutar y errores de campo durante la ejecución; por eso puede existir data parcial junto a errors. En producción, no devuelvas excepciones de base de datos ni stack traces. Usa extensions.code y requestId.
// src/graphql/schema.ts
import { readFileSync } from "node:fs";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { resolvers } from "./resolvers";
export const schema = makeExecutableSchema({
typeDefs: readFileSync(new URL("./schema.graphql", import.meta.url), "utf8"),
resolvers,
});
// src/graphql/execute.ts
import {
GraphQLError,
execute,
parse,
specifiedRules,
validate,
type ExecutionResult,
} from "graphql";
import { createContext, type GraphQLContext } from "./context";
import { depthAndComplexityRule } from "./demandControl";
import { schema } from "./schema";
import type { Viewer } from "./models";
type ExecuteOptions = {
source: string;
variables?: Record<string, unknown>;
viewer?: Viewer;
requestId?: string;
};
export async function executeGraphQL(options: ExecuteOptions): Promise<ExecutionResult> {
const context = createContext({ viewer: options.viewer, requestId: options.requestId });
const document = parse(options.source);
const validationErrors = validate(schema, document, [
...specifiedRules,
depthAndComplexityRule({ maxDepth: 6, maxComplexity: 300 }),
]);
if (validationErrors.length > 0) {
return { errors: validationErrors.map((error) => maskError(error, context)) };
}
const result = await execute({
schema,
document,
variableValues: options.variables,
contextValue: context,
});
return {
data: result.data,
errors: result.errors?.map((error) => maskError(error, context)),
};
}
function maskError(error: GraphQLError, context: GraphQLContext) {
const code = typeof error.extensions.code === "string" ? error.extensions.code : "INTERNAL_SERVER_ERROR";
const message = process.env.NODE_ENV === "production" ? "GraphQL request failed." : error.message;
return new GraphQLError(message, {
nodes: error.nodes,
path: error.path,
originalError: error.originalError,
extensions: {
code,
requestId: context.requestId,
},
});
}
// test/graphql.test.ts
import { describe, expect, it } from "vitest";
import { executeGraphQL } from "../src/graphql/execute";
const viewer = { id: "user_1", role: "user" as const };
describe("GraphQL API", () => {
it("returns products with reviews through typed resolvers", async () => {
const result = await executeGraphQL({
viewer,
source: `
query Products {
products(first: 10) {
edges {
node {
id
title
averageRating
reviews(first: 5) {
edges { node { id rating body } }
}
}
}
}
}
`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.products.edges[0].node.title).toBe("Claude Code Playbook");
});
it("does not expose a private product to another user", async () => {
const result = await executeGraphQL({
viewer,
source: `query PrivateProduct { product(id: "prod_private") { id title } }`,
});
expect(result.errors).toBeUndefined();
expect(result.data?.product).toBeNull();
});
it("requires login for mutations", async () => {
const result = await executeGraphQL({
source: `
mutation CreateReview {
createReview(input: { productId: "prod_1", rating: 5, body: "Great" }) { id }
}
`,
});
expect(result.errors?.[0].extensions?.code).toBe("UNAUTHENTICATED");
});
it("rejects overly deep operations before execution", async () => {
const result = await executeGraphQL({
viewer,
source: `
query TooDeep {
products(first: 50) {
edges { node { reviews(first: 50) { edges { node { product { reviews(first: 50) { edges { node { id } } } } } } } } }
}
}
`,
});
expect(result.errors?.[0].message).toContain("too");
});
});
No pruebes solo el camino feliz. Incluye mutation sin login, datos privados, input inválido, operaciones profundas, límites de paginación y extensions.code.
Prompts seguros para Claude Code
Los prompts peligrosos son vagos: “hazlo seguro”, “evita N+1”, “añade tests”. Claude Code necesita alcance, reglas y criterios de aceptación.
Improve the GraphQL implementation in this repository.
Allowed files:
- src/graphql/**
- test/graphql.test.ts
Design rules:
- Schema-first; schema.graphql is the public contract
- Use GraphQL Code Generator's Resolvers type; do not add any `any` types
- Keep authorization in repo/service functions, not scattered across resolvers
- Create DataLoader instances inside createContext for each request
- Clamp list fields to first between 1 and 50 and return connection objects
- Add depth and complexity validation
- Shape errors as message, path, extensions.code, extensions.requestId
- Add Vitest coverage for happy path, unauthenticated mutation, private data, and overly deep queries
At the end, report changed files, commands run, remaining risks, and official docs to re-check.
Para equipos, mueve estas reglas a CLAUDE.md best practices y mantén la misma frontera en futuros cambios GraphQL.
Pitfalls antes de publicar
Primero, un DataLoader global puede filtrar cache entre usuarios. Segundo, confiar solo en valores por defecto de SDL para first permite listas enormes. Tercero, repartir autorización entre resolvers hace que una ruta alternativa se salte una regla. Cuarto, limitar profundidad sin complejidad no bloquea queries poco profundas pero muy anchas.
Quinto, exponer errores internos revela tablas, stack traces o pistas del esquema. Sexto, dejar Codegen fuera de CI hace que schema y tipos diverjan. Cada cambio de schema debe regenerar tipos y ejecutar tests.
La calidad GraphQL también protege ingresos: contenido premium, plantillas, enlaces Gumroad, formularios B2B y dashboards de admin dependen de autorización correcta y coste predecible. Empieza con la checklist gratuita, usa productos y plantillas si necesitas prompts reutilizables, y considera formación o consultoría Claude Code para revisar schema, autorización, tests y CI en un repo real.
Resultado práctico
Cuando Masa probó este flujo en una API pequeña de reseñas, lo más útil no fue el snippet de DataLoader. El avance vino de incluir autorización, límite de first, depth/complexity, forma de error y nombres de tests en el primer prompt. Antes se añadía Product.reviews y N+1 aparecía después. Con este enfoque, schema, resolvers, validation y tests cambiaron juntos, y la revisión fue más corta.
Resumen
Para desarrollar GraphQL con Claude Code, elige schema-first o resolver-first de forma consciente, usa SDL como contrato, genera resolvers tipados, crea DataLoader por request, deja la autorización en la lógica de negocio y limita profundidad y complejidad. Después fija errores y tests de fallo. GraphQL es flexible; en producción esa flexibilidad necesita límites explícitos desde el prompt.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.