GraphQL na prática com Claude Code: SDL, resolvers tipados, DataLoader e testes
Implemente GraphQL com Claude Code: SDL, resolvers tipados, N+1, autorização, complexidade, erros e testes.
Quando você pede para o Claude Code criar uma API GraphQL, a primeira decisão não é a lista de tipos. Antes disso, defina onde fica o contrato, onde a autorização é aplicada, como evitar N+1, como rejeitar operações profundas ou caras, e como erros e testes provam que a API pode ir para produção. Um pedido vago como “transforme em GraphQL” pode gerar uma demo funcional, mas com riscos escondidos.
Neste guia, Claude Code é usado como parceiro de implementação. SDL é a linguagem de definição de schema, o contrato textual de uma API GraphQL. Um resolver é a função que retorna o valor de um campo. DataLoader é uma ferramenta de batching por request que reduz leituras repetidas durante uma operação GraphQL.
Use documentação primária: GraphQL Schemas and Types, Pagination, Authorization, Security, Response Format, DataLoader README, GraphQL Code Generator TypeScript Resolvers e Claude Code docs. No ClaudeCodeLab, veja também desenvolvimento de APIs, testes de API e técnicas de prompt.
Escolher schema-first ou resolver-first
Schema-first significa escrever SDL primeiro, tratá-lo como contrato público e implementar resolvers e testes depois. Resolver-first significa partir de serviços, funções TypeScript ou acesso a dados que já existem e depois organizar o schema. Com Claude Code, schema-first é melhor para APIs novas, móveis ou usadas por vários times. Resolver-first é útil para migrar REST ou camadas de domínio já consolidadas.
Três casos são comuns. Um dashboard SaaS precisa mostrar fronteiras de time, cobrança e auditoria antes do código. Uma API mobile reduz chamadas de rede, mas exige paginação, limites e erros estáveis. Uma ferramenta interna de análise pode começar em serviços existentes, mas autorização e tenant continuam na lógica de negócio.
Passe essa decisão no prompt inicial.
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
Servidor e framework podem mudar, mas contrato, autorização, controle de demanda e testes devem estar explícitos desde o início.
Colocar SDL no centro
O SDL abaixo define uma API pequena de reviews de produto. Ele segue o modelo de connection da documentação oficial de paginação: edges, node, cursor, pageInfo. Campos aninhados como Product.reviews são úteis, mas também podem causar N+1 e queries profundas.
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!
}
Tipos GraphQL não substituem toda validação. rating: Int! não garante faixa de 1 a 5. Tamanho do texto, HTML inseguro e visibilidade de produto privado pertencem à camada de serviço ou resolver.
Gerar resolvers tipados
O atalho ruim em TypeScript é usar any para parent, args e context. O plugin typescript-resolvers do GraphQL Code Generator gera assinaturas a partir do SDL.
// 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 deixam claro que o tipo GraphQL e a linha do banco não são idênticos. Product expõe reviews e averageRating, mas ProductRow normalmente não.
Corrigir N+1 com DataLoader por request
N+1 acontece quando uma lista gera uma leitura extra por item. Dez produtos viram dez consultas de reviews. DataLoader agrupa .load() dentro do request e retorna valores na mesma ordem das chaves. O cache dele é por request; não substitui Redis e não deve ser global.
// 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;
},
},
};
A autorização fica no repo. A documentação oficial recomenda delegar autorização à lógica de negócio para não duplicar condições em cada resolver.
Limitar profundidade e complexidade
A liberdade do GraphQL pode virar custo: nesting profundo, first grande, aliases e batch. A página Security recomenda trusted documents, paginação, depth limiting, breadth/batch limiting, rate limiting e complexity analysis.
// 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 };
}
Essa regra considera first e last literais. Valores por variável ainda precisam ser limitados no resolver. Para clientes próprios, trusted documents podem ser mais fortes que aceitar texto GraphQL em produção.
Padronizar erros e testes
Respostas GraphQL usam data, errors e extensions. A especificação separa erros antes da execução e erros de campo durante a execução, permitindo dados parciais com erros. Em produção, mascare exceções internas e use extensions.code com 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");
});
});
Testes devem cobrir sucesso, mutation sem login, dados privados, entradas inválidas, queries profundas, limite de paginação e extensions.code.
Prompts seguros para Claude Code
Um prompt bom define escopo, regras e fim da tarefa.
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.
Em times, mova as regras para CLAUDE.md best practices.
Armadilhas antes do release
DataLoader global pode vazar cache entre usuários. Defaults de SDL não limitam first o suficiente. Autorização duplicada em resolvers diverge. Depth limit sem complexity não bloqueia queries largas. Erros internos revelam tabelas ou stack traces. Sem Codegen no CI, schema e tipos se separam.
Qualidade GraphQL também protege receita: conteúdo premium, templates, links Gumroad, formulários B2B e dashboards admin dependem de autorização confiável e custo previsível. Comece com a checklist gratuita, use produtos e templates para prompts reutilizáveis e treinamento ou consultoria Claude Code para schema, autorização, testes e CI em um repositório real.
Resultado prático
Quando Masa testou esse fluxo em uma API pequena de reviews, o maior ganho não foi o snippet de DataLoader. Foi colocar autorização, limite de first, depth/complexity, formato de erro e nomes de teste no prompt inicial. Antes, Product.reviews vinha primeiro e N+1 aparecia depois. Aqui, schema, resolvers, validation e testes mudaram juntos.
Resumo
Com Claude Code, escolha schema-first ou resolver-first de propósito, trate SDL como contrato, gere resolvers tipados, crie DataLoader por request, mantenha autorização na lógica de negócio e limite profundidade e complexidade. Depois padronize erros e teste falhas. GraphQL é flexível; produção exige limites explícitos no prompt.
Artigos relacionados
API type-safe com tRPC e Claude Code
Crie uma API type-safe com Claude Code e tRPC: Next.js, Zod, permissões, cache invalidation e prompts de revisão.
Claude Code e AWS API Gateway: HTTP API com SAM, CORS, logs, autenticação e limites
Implemente AWS API Gateway com Claude Code, SAM, CORS, logs, auth e throttling com exemplos práticos.
Claude Code × AWS Lambda Guia Completo | Da Geração de Funções à Automação do Deploy
Crie AWS Lambda com Claude Code: Node.js 24, SAM, IAM, API Gateway, S3, logs e falhas comuns.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.