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.
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.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.