GraphQL hat REST in vielen modernen Projekten längst abgelöst — aber das volle Potenzial entfaltet es erst, wenn Schema-Design, Resolver-Logik und Client-seitiges Caching sauber zusammenspielen. Genau hier ist Claude Code besonders stark: Es versteht die Semantik von GraphQL-SDL, generiert typsichere Resolver, erklärt das N+1-Problem und schreibt DataLoader-Code, der in Produktion sofort funktioniert.
Dieser Artikel zeigt, wie du mit Claude Code eine vollständige GraphQL-API mit Apollo Server und Apollo Client aufbaust — von der ersten Type-Definition bis zu Subscriptions und Performance-Optimierungen.
1. GraphQL Schema-Design mit SDL-First
Der erste Schritt jeder GraphQL-API ist das Schema. Claude Code hilft dir, ein konsistentes SDL (Schema Definition Language) zu entwerfen, das alle Anforderungen abbildet — inklusive Mutations, Subscriptions und wiederverwendbaren Input Types.
SDL Basis-Schema: Typen, Queries und Mutations
Claude Code-Prompt: "Erstelle ein GraphQL-Schema für eine Blog-Plattform mit Users, Posts und Comments. Inkl. Pagination und Input-Types."
# schema.graphql — generiert von Claude Code
scalar DateTime
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
tags: [String!]!
published: Boolean!
createdAt: DateTime!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
published: Boolean = false
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
input PostsFilterInput {
authorId: ID
tags: [String!]
published: Boolean
search: String
}
type Query {
post(id: ID!): Post
posts(
first: Int = 10
after: String
filter: PostsFilterInput
): PostConnection!
user(id: ID!): User
me: User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
addComment(postId: ID!, body: String!): Comment!
}
type Subscription {
commentAdded(postId: ID!): Comment!
postPublished: Post!
}
Claude Code-Tipp: Beschreibe dein Datenmodell in natürlicher Sprache — Claude Code schlägt automatisch sinnvolle Relay-kompatible Pagination (Cursor-based), wiederverwendbare Input-Types und Non-Null-Markierungen vor. Du kannst direkt im Chat iterieren: "Füge ein Rating-Feld zu Post hinzu und ein AggregateType für Statistiken."
2. Apollo Server Setup mit Resolvern und Context
Mit dem Schema als Grundlage baut Claude Code den Apollo Server inklusive typsicherer Resolver, Auth-Context und strukturierter Fehlerbehandlung. Das spart Stunden an Boilerplate.
Server Apollo Server 4 mit TypeScript
// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { readFileSync } from 'fs'
import { resolvers } from './resolvers'
import { createContext } from './context'
import { createLoaders } from './loaders'
const typeDefs = readFileSync('./schema.graphql', 'utf-8')
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Sensible Fehler in Produktion maskieren
if (process.env.NODE_ENV === 'production') {
return { message: 'Internal server error', code: formattedError.extensions?.code }
}
return formattedError
},
})
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const ctx = await createContext(req)
return {
...ctx,
loaders: createLoaders(ctx.db), // DataLoader per Request
}
},
listen: { port: 4000 },
})
console.log(`Server ready at: ${url}`)
Server Resolver-Struktur mit Auth-Guard
// src/resolvers/post.ts
import { GraphQLError } from 'graphql'
import { Resolvers } from '../generated/graphql'
export const postResolvers: Resolvers = {
Query: {
post: async (_, { id }, { db, loaders }) => {
return loaders.post.load(id)
},
posts: async (_, { first, after, filter }, { db }) => {
const posts = await db.post.findMany({
take: first + 1,
cursor: after ? { id: after } : undefined,
where: {
published: filter?.published ?? true,
authorId: filter?.authorId,
tags: filter?.tags ? { hasSome: filter.tags } : undefined,
OR: filter?.search ? [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ content: { contains: filter.search, mode: 'insensitive' } },
] : undefined,
},
orderBy: { createdAt: 'desc' },
})
const hasNextPage = posts.length > first
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post.id,
}))
return {
edges,
totalCount: await db.post.count(),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
}
},
},
Mutation: {
createPost: async (_, { input }, { db, user }) => {
if (!user) throw new GraphQLError('Nicht angemeldet', {
extensions: { code: 'UNAUTHENTICATED' },
})
return db.post.create({
data: { ...input, authorId: user.id, tags: input.tags ?? [] },
})
},
},
Post: {
// Field Resolver nutzt DataLoader statt N+1-Query
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
comments: (post, _, { loaders }) => loaders.commentsByPost.load(post.id),
},
}
3. Das N+1-Problem mit DataLoader lösen
Das N+1-Problem ist das häufigste Performance-Anti-Pattern in GraphQL: Für eine Liste von 100 Posts werden 100 separate DB-Queries für die Autoren ausgelöst. Claude Code erklärt nicht nur das Problem — es generiert sofort den DataLoader-Code.
Perf DataLoader — Batching & Caching
// src/loaders/index.ts
import DataLoader from 'dataloader'
import { PrismaClient } from '@prisma/client'
export function createLoaders(db: PrismaClient) {
return {
// Statt N einzelner User-Queries → 1 Batch-Query für alle IDs
user: new DataLoader<string, User>(
async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
})
const userMap = Object.fromEntries(users.map(u => [u.id, u]))
return ids.map(id => userMap[id] ?? new Error(`User ${id} not found`))
},
{ cache: true } // Request-scoped Cache
),
post: new DataLoader<string, Post>(
async (ids) => {
const posts = await db.post.findMany({
where: { id: { in: [...ids] } },
})
const postMap = Object.fromEntries(posts.map(p => [p.id, p]))
return ids.map(id => postMap[id] ?? new Error(`Post ${id} not found`))
}
),
// 1:N Loader — alle Comments für mehrere Posts in einem Query
commentsByPost: new DataLoader<string, Comment[]>(
async (postIds) => {
const comments = await db.comment.findMany({
where: { postId: { in: [...postIds] } },
orderBy: { createdAt: 'asc' },
})
return postIds.map(id =>
comments.filter(c => c.postId === id)
)
}
),
}
}
// Ergebnis: 100 Posts → 1 User-Query + 1 Comment-Query statt 200 Queries!
Wichtig: DataLoader muss per Request neu erstellt werden (nicht als Singleton). Sonst teilt sich der Cache über Request-Grenzen hinweg — und User A sieht User Bs Daten. Claude Code erstellt den Loader immer im Context-Factory-Pattern, wie im Server-Setup zu sehen.
4. Apollo Client: useQuery, useMutation und Cache-Management
Auf der Client-Seite generiert Claude Code vollständige React-Komponenten mit Apollo Hooks, typsicheren Queries aus generierten Types und sauberem Cache-Update-Code nach Mutations.
Client Apollo Client Setup und InMemoryCache
// src/apollo/client.ts
import { ApolloClient, InMemoryCache, createHttpLink, split } from '@apollo/client'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { getMainDefinition } from '@apollo/client/utilities'
import { createClient } from 'graphql-ws'
import { setContext } from '@apollo/client/link/context'
const httpLink = createHttpLink({ uri: '/graphql' })
const wsLink = new GraphQLWsLink(createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: () => ({
authorization: localStorage.getItem('token'),
}),
}))
const authLink = setContext((_, { headers }) => ({
headers: {
...headers,
authorization: localStorage.getItem('token') ?? '',
},
}))
// HTTP für Queries/Mutations, WebSocket für Subscriptions
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query)
return def.kind === 'OperationDefinition' && def.operation === 'subscription'
},
wsLink,
authLink.concat(httpLink),
)
export const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// Cursor-based Pagination merge policy
keyArgs: ['filter'],
merge(existing, incoming) {
return {
...incoming,
edges: [...(existing?.edges ?? []), ...incoming.edges],
}
},
},
},
},
},
}),
})
Client useMutation mit Optimistic Updates & Cache-Update
// src/components/PostList.tsx
import { useQuery, useMutation, gql } from '@apollo/client'
const GET_POSTS = gql`
query GetPosts($first: Int, $filter: PostsFilterInput) {
posts(first: $first, filter: $filter) {
edges {
node { id title author { name } tags createdAt }
}
pageInfo { hasNextPage endCursor }
totalCount
}
}
`
const ADD_COMMENT = gql`
mutation AddComment($postId: ID!, $body: String!) {
addComment(postId: $postId, body: $body) {
id body author { name } createdAt
}
}
`
export function PostList() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10, filter: { published: true } },
})
const [addComment] = useMutation(ADD_COMMENT, {
// Optimistic UI: Comment erscheint sofort vor Server-Antwort
optimisticResponse: ({ postId, body }) => ({
addComment: {
__typename: 'Comment',
id: `temp-${Date.now()}`,
body,
author: { __typename: 'User', name: 'Du' },
createdAt: new Date().toISOString(),
},
}),
update(cache, { data: { addComment } }, { variables: { postId } }) {
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
comments: (existing = []) => [...existing, cache.writeFragment({
data: addComment,
fragment: gql`fragment NewComment on Comment {
id body author { name } createdAt
}`,
})],
},
})
},
})
if (loading) return <span>Laden...</span>
return (
<div>
{data?.posts.edges.map(({ node }) => (
<article key={node.id}>
<h2>{node.title}</h2>
<p>Von {node.author.name}</p>
</article>
))}
{data?.posts.pageInfo.hasNextPage && (
<button onClick={() => fetchMore({
variables: { after: data.posts.pageInfo.endCursor }
})}>Mehr laden</button>
)}
</div>
)
}
5. Code-First mit TypeGraphQL
Beim Code-First-Ansatz entsteht das Schema automatisch aus TypeScript-Klassen. Claude Code ist ideal dafür: Es versteht Decorator-Patterns und generiert die vollständige Klassen-Hierarchie inklusive Validation durch class-validator.
SDL TypeGraphQL — Decorator-based Schema-Generierung
// src/schema/Post.ts — Schema direkt aus TypeScript
import {
ObjectType, Field, ID, InputType,
Resolver, Query, Mutation, Arg, Ctx
} from 'type-graphql'
import { IsNotEmpty, MinLength, ArrayMaxSize } from 'class-validator'
@ObjectType()
export class Post {
@Field(() => ID)
id: string
@Field()
title: string
@Field()
content: string
@Field(() => [String])
tags: string[]
@Field()
published: boolean
@Field()
createdAt: Date
}
@InputType()
export class CreatePostInput {
@Field()
@IsNotEmpty()
@MinLength(5, { message: 'Titel zu kurz (min. 5 Zeichen)' })
title: string
@Field()
@MinLength(20)
content: string
@Field(() => [String], { nullable: true })
@ArrayMaxSize(10)
tags?: string[]
}
@Resolver(Post)
export class PostResolver {
@Query(() => Post, { nullable: true })
async post(@Arg('id', () => ID) id: string, @Ctx() { db }: Context) {
return db.post.findUnique({ where: { id } })
}
@Mutation(() => Post)
async createPost(@Arg('input') input: CreatePostInput, @Ctx() { db, user }: Context) {
if (!user) throw new Error('Nicht angemeldet')
return db.post.create({ data: { ...input, authorId: user.id } })
}
}
Code-First vs. Schema-First: Schema-First (SDL) eignet sich gut wenn mehrere Teams (Frontend/Backend) das Schema gemeinsam besitzen. Code-First mit TypeGraphQL ist ideal wenn TypeScript die einzige Sprache ist — du vermeidest Typ-Duplizierung komplett. Claude Code beherrscht beide Ansätze und kann auch zwischen ihnen konvertieren.
6. Subscriptions: Real-Time mit WebSocket und PubSub
GraphQL Subscriptions ermöglichen echte Real-Time-Updates — für Chats, Live-Dashboards oder kollaborative Features. Claude Code generiert den vollständigen PubSub-Setup inklusive Subscription-Resolver und Client-seitigem useSubscription-Hook.
Server PubSub und Subscription Resolver
// src/pubsub.ts
import { PubSub } from 'graphql-subscriptions'
export const pubsub = new PubSub()
export const EVENTS = {
COMMENT_ADDED: 'COMMENT_ADDED',
POST_PUBLISHED: 'POST_PUBLISHED',
} as const
// src/resolvers/subscription.ts
import { withFilter } from 'graphql-subscriptions'
export const subscriptionResolvers = {
Subscription: {
commentAdded: {
// withFilter: Nur Events für den angefragten Post durchlassen
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.COMMENT_ADDED]),
(payload, variables) =>
payload.commentAdded.postId === variables.postId
),
},
postPublished: {
subscribe: () => pubsub.asyncIterator([EVENTS.POST_PUBLISHED]),
},
},
}
// In der addComment-Mutation: PubSub-Event auslösen
addComment: async (_, { postId, body }, { db, user }) => {
const comment = await db.comment.create({
data: { postId, body, authorId: user.id },
include: { author: true },
})
// Alle Subscriber für diesen Post erhalten sofort das Update
await pubsub.publish(EVENTS.COMMENT_ADDED, { commentAdded: comment })
return comment
},
Client useSubscription im React-Frontend
// src/components/CommentSection.tsx
import { useSubscription, gql } from '@apollo/client'
const COMMENT_ADDED = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id body author { name } createdAt
}
}
`
export function CommentSection({ postId }: { postId: string }) {
useSubscription(COMMENT_ADDED, {
variables: { postId },
onData: ({ client, data }) => {
// Neuen Comment direkt in Apollo Cache schreiben
client.cache.modify({
id: client.cache.identify({ __typename: 'Post', id: postId }),
fields: {
comments: (existing = []) => [
...existing,
{ __ref: client.cache.identify(data.data!.commentAdded) }
],
},
})
},
})
return <section><h3>Kommentare (Live)</h3></section>
}
7. Performance: Persisted Queries, Query-Komplexität und Response-Caching
In Produktion braucht eine GraphQL-API drei Sicherheits- und Performance-Schichten. Claude Code hilft dir, alle drei in wenigen Minuten zu konfigurieren.
Perf Query Complexity Limits — DoS-Schutz
// src/server.ts — Query Complexity Plugin
import {
createComplexityPlugin,
fieldExtensionsEstimator,
simpleEstimator
} from 'graphql-query-complexity'
const complexityPlugin = createComplexityPlugin({
maximumComplexity: 1000,
onComplete: (complexity) => {
console.log(`Query complexity: ${complexity}`)
},
createError: (max, actual) =>
new GraphQLError(`Query zu komplex: ${actual} (max ${max})`, {
extensions: { code: 'QUERY_TOO_COMPLEX' },
}),
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
})
// Persisted Queries — Clients senden nur Hash, kein Query-String
// Spart Bandbreite + verhindert Ad-hoc-Queries in Produktion
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'
import { generatePersistedQueryIdsFromManifest } from '@apollo/persisted-query-lists'
const persistedQueryLink = createPersistedQueryLink({
generateHash: generatePersistedQueryIdsFromManifest({
loadManifest: () => import('./persisted-query-manifest.json'),
}),
})
Perf Response Caching mit Redis-Backend
// Schema-Direktive für Field-Level Caching
type Query {
# 60s gecacht für alle anonymen Requests
posts(first: Int, filter: PostsFilterInput): PostConnection! @cacheControl(maxAge: 60)
post(id: ID!): Post @cacheControl(maxAge: 30)
# me = private, nie cachen!
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
// Apollo Server Response Cache Plugin mit Redis
import responseCachePlugin from '@apollo/server-plugin-response-cache'
import { KeyvAdapter } from '@apollo/utils.keyvadapter'
import Keyv from 'keyv'
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
complexityPlugin,
responseCachePlugin({
// Authenticated Requests erhalten eigenen Cache-Key
sessionId: ({ contextValue }) => contextValue.user?.id ?? null,
}),
],
cache: new KeyvAdapter(new Keyv('redis://localhost:6379')),
})
GraphQL-Performance-Checkliste:
✅ DataLoader für alle Relationen (N+1 eliminiert)
✅ Query Complexity Limit (DoS-Schutz, max. 1000 Punkte)
✅ Persisted Queries in Produktion (keine Ad-hoc-Queries)
✅ Response Cache mit Redis (field-level, scope-aware)
✅ Apollo Client InMemoryCache mit TypePolicies (kein Over-Fetching)
Fazit: Claude Code als GraphQL-Copilot
GraphQL-Expertise aufzubauen kostet normalerweise Monate. Mit Claude Code verkürzt sich der Weg dramatisch: Du beschreibst dein Datenmodell, Claude Code generiert SDL, typsichere Resolver, DataLoader-Konfigurationen und Apollo-Client-Code — in einem konsistenten, produktionsreifen Stil.
Besonders wertvoll ist Claude Codes Fähigkeit, das N+1-Problem automatisch zu erkennen und DataLoader-Code vorzuschlagen, bevor du das erste Performanceproblem erlebst. Und wenn du zwischen Code-First und Schema-First wechseln möchtest, kann Claude Code auch das — inklusive Migrationsstrategie.
Der nächste Schritt: Schema-Stitching und Federation für Microservices-Architekturen. Auch das beherrscht Claude Code — aber das ist ein eigener Artikel wert.
GraphQL-Modul im Kurs
Unser Kurs enthält ein vollständiges GraphQL-Modul: Schema-Design, Apollo Server & Client, DataLoader, Subscriptions und Performance-Optimierung — alles mit Claude Code als Copilot. Inklusive hands-on Projekte, die du direkt in dein Portfolio übernehmen kannst.
14 Tage kostenlos testen →