Use Cases (Atualizado: 02/06/2026)

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.

GraphQL na prática com Claude Code: SDL, resolvers tipados, DataLoader 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.

#Claude Code #GraphQL #API development #TypeScript #DataLoader
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.