Pengembangan GraphQL dengan Claude Code: SDL, resolver bertipe, DataLoader, dan tes
Bangun API GraphQL dengan Claude Code: SDL, resolver bertipe, N+1, otorisasi, kompleksitas, error, dan tes.
Saat meminta Claude Code membangun API GraphQL, keputusan pertama bukan daftar tipe. Tentukan dulu di mana kontrak publik berada, di mana otorisasi dijalankan, bagaimana N+1 dicegah, bagaimana query yang terlalu dalam atau mahal ditolak, serta bagaimana error dan tes membuktikan API siap rilis. Prompt seperti “buatkan GraphQL API” bisa menghasilkan demo yang berjalan, tetapi menyisakan query lambat, kebocoran otorisasi, tipe yang bergeser, dan error yang sulit dilacak.
Panduan ini memakai Claude Code sebagai partner implementasi. SDL adalah schema definition language, kontrak teks untuk API GraphQL. resolver adalah fungsi yang mengembalikan nilai sebuah field. DataLoader adalah utilitas batching per request untuk mengurangi pembacaan berulang selama operasi GraphQL.
Gunakan sumber resmi: GraphQL Schemas and Types, Pagination, Authorization, Security, spesifikasi Response Format, DataLoader README, GraphQL Code Generator TypeScript Resolvers, dan Claude Code docs. Bacaan terkait: pengembangan API, API testing, dan teknik prompt.
Pilih schema-first atau resolver-first
Schema-first berarti menulis SDL terlebih dahulu, menjadikannya kontrak publik, lalu membuat resolver dan tes. Resolver-first berarti mulai dari service, fungsi TypeScript, atau akses data yang sudah ada, lalu membentuk schema. Dengan Claude Code, schema-first lebih mudah direview untuk API baru, API mobile, atau API lintas tim. Resolver-first cocok saat memigrasikan REST atau domain layer yang sudah berjalan.
Tiga contoh praktis: dashboard SaaS perlu batas tim, billing, dan audit log yang jelas sebelum kode. API mobile mengurangi round trip, tetapi perlu pagination, operation limit, dan error yang stabil. Tool analitik internal boleh fleksibel, tetapi tenant dan data sensitif tetap harus dijaga di business logic.
Berikan keputusan ini sejak prompt awal.
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
Library server bisa berbeda, tetapi kontrak, otorisasi, demand control, dan tes harus eksplisit.
Jadikan SDL sebagai kontrak
SDL berikut mendefinisikan API review produk kecil. Bentuknya mengikuti connection model dari dokumentasi pagination: edges, node, cursor, dan pageInfo. Field bersarang seperti Product.reviews berguna, tetapi juga titik awal N+1 dan query dalam.
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!
}
Tipe GraphQL tidak menggantikan semua validasi. rating: Int! hanya berarti integer, bukan rentang 1 sampai 5. Panjang teks, HTML berbahaya, dan visibilitas produk privat tetap diperiksa di service atau resolver.
Hasilkan resolver bertipe
Shortcut berbahaya di TypeScript adalah menerima parent, args, dan context sebagai any. Plugin typescript-resolvers dari GraphQL Code Generator menghasilkan signature resolver dari 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 menjelaskan bahwa tipe GraphQL dan row database tidak selalu sama. Product memiliki reviews dan averageRating, sedangkan ProductRow biasanya tidak.
Cegah N+1 dengan DataLoader per request
N+1 terjadi saat satu list memicu satu pembacaan tambahan untuk setiap item. Sepuluh produk bisa menjadi sepuluh query review. DataLoader menggabungkan .load() dalam request yang sama dan mengembalikan hasil sesuai urutan key. Cache DataLoader adalah per request, bukan pengganti Redis dan bukan cache global lintas user.
// 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;
},
},
};
Otorisasi sengaja berada di repo. Dokumentasi GraphQL menyarankan logika otorisasi didelegasikan ke business logic agar aturan yang sama dipakai di semua jalur query.
Batasi depth dan complexity
GraphQL fleksibel, tetapi nesting dalam, first besar, banyak alias, dan batch dapat menjadi mahal. Panduan Security menyarankan kombinasi trusted documents, pagination, depth limiting, breadth/batch limiting, rate limiting, dan 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 };
}
Rule ini membaca first dan last literal. Nilai variabel tetap harus dibatasi di resolver. Untuk klien internal, trusted documents sering lebih kuat daripada menerima teks GraphQL bebas di produksi.
Standarkan error dan tes
Response GraphQL memakai data, errors, dan extensions. Spesifikasi membedakan error sebelum eksekusi dan field error saat eksekusi. Di produksi, jangan kembalikan exception database atau stack trace mentah. Gunakan extensions.code dan 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");
});
});
Tes perlu mencakup happy path, mutation tanpa login, data privat, input tidak valid, query terlalu dalam, batas pagination, dan extensions.code.
Prompt aman untuk Claude Code
Prompt yang baik memberi scope, aturan, dan definisi selesai.
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.
Untuk tim, pindahkan aturan ini ke CLAUDE.md best practices.
Pitfall sebelum rilis
DataLoader global dapat membocorkan cache antar user. Default SDL tidak cukup untuk membatasi first. Otorisasi yang tersebar di resolver mudah berbeda antar jalur. Depth limit tanpa complexity tidak menahan query dangkal tetapi lebar. Error internal mentah bisa membocorkan tabel atau stack trace. Tanpa Codegen di CI, schema dan tipe resolver akan terpisah.
Kualitas GraphQL juga melindungi revenue: konten premium, template, link Gumroad, form B2B, dan dashboard admin bergantung pada otorisasi andal dan biaya API yang terkontrol. Mulai dari checklist gratis, gunakan produk dan template untuk prompt reusable, dan gunakan training atau konsultasi Claude Code untuk schema, auth, tes, dan CI pada repo nyata.
Hasil praktik
Saat Masa mencoba alur ini pada API review kecil, peningkatan terbesar bukan snippet DataLoader. Manfaatnya datang dari prompt awal yang sudah memuat batas otorisasi, batas first, depth/complexity, bentuk error, dan nama tes. Sebelumnya Product.reviews dibuat dulu dan N+1 ditemukan belakangan. Dengan alur ini, schema, resolver, validation, dan tes berubah bersama.
Ringkasan
Dengan Claude Code, pilih schema-first atau resolver-first secara sadar, jadikan SDL kontrak, hasilkan resolver bertipe, buat DataLoader per request, taruh otorisasi di business logic, lalu batasi depth dan complexity. Setelah itu, standarkan error dan uji jalur gagal. GraphQL fleksibel; produksi membutuhkan guardrail eksplisit di prompt.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.