GraphQL hat sich als Standard für moderne APIs durchgesetzt — und Claude Code beschleunigt die Entwicklung dramatisch. Dieser Artikel zeigt, wie du 2026 mit Claude Code produktionsreife TypeScript GraphQL-APIs baust: von Schema-First Design über Resolver und DataLoader bis hin zu Subscriptions und Code Generator. Alle Codebeispiele sind direkt produktionsbereit.
đź”·
Schema-First Design
SDL, Typen, Interfaces
⚙️
Resolver & DataLoader
N+1 Problem lösen
đź§±
Pothos Builder
Type-safe Code-First
🔄
Real-Time Subscriptions
WebSocket, PubSub
1. GraphQL Schema Design mit SDL
Der erste Schritt bei jeder GraphQL-API ist das Schema. Claude Code hilft dir, ein sauberes, konsistentes Schema zu entwerfen — mit Scalars, Enums, Input Types und klaren Interfaces. Hier zeigen wir ein vollständiges Beispiel für eine Produktmanagement-API.
SDL SCHEMA
schema.graphql
# Scalars & Direktiven
scalar DateTime
scalar JSON
scalar Upload
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE
# Enums
enum Role {
ADMIN
MANAGER
USER
}
enum ProductStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum SortOrder {
ASC
DESC
}
# Interface
interface Node {
id: ID!
}
interface Timestamps {
createdAt: DateTime!
updatedAt: DateTime!
}
# Types
type User implements Node & Timestamps {
id: ID!
email: String!
name: String!
role: Role!
products: [Product!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Product implements Node & Timestamps {
id: ID!
title: String!
description: String
price: Float!
status: ProductStatus!
tags: [String!]!
metadata: JSON
owner: User!
variants: [ProductVariant!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type ProductVariant implements Node {
id: ID!
sku: String!
price: Float!
stock: Int!
attributes: JSON!
}
# Pagination
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
# Input Types
input CreateProductInput {
title: String!
description: String
price: Float!
status: ProductStatus = DRAFT
tags: [String!] = []
metadata: JSON
}
input UpdateProductInput {
title: String
description: String
price: Float
status: ProductStatus
tags: [String!]
metadata: JSON
}
input ProductFilterInput {
status: ProductStatus
minPrice: Float
maxPrice: Float
tags: [String!]
ownerId: ID
search: String
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
input ProductSortInput {
field: ProductSortField!
order: SortOrder = DESC
}
enum ProductSortField {
CREATED_AT
UPDATED_AT
PRICE
TITLE
}
# Root Types
type Query {
me: User @auth
user(id: ID!): User @auth(requires: ADMIN)
users: [User!]! @auth(requires: ADMIN)
product(id: ID!): Product
products(
filter: ProductFilterInput
sort: ProductSortInput
pagination: PaginationInput
): ProductConnection!
searchProducts(query: String!, limit: Int = 10): [Product!]!
}
type Mutation {
createProduct(input: CreateProductInput!): Product! @auth
updateProduct(id: ID!, input: UpdateProductInput!): Product! @auth
deleteProduct(id: ID!): Boolean! @auth
publishProduct(id: ID!): Product! @auth(requires: MANAGER)
uploadProductImage(productId: ID!, file: Upload!): String! @auth
}
type Subscription {
productUpdated(id: ID!): Product!
productStatusChanged(status: ProductStatus): Product!
newProductCreated: Product!
}
đź’ˇ Claude Code Tipp: Lass Claude Code dein Schema reviewen und auf N+1-Probleme, fehlende Null-Safety und Pagination-Antipatterns prĂĽfen. Einfach das Schema einfĂĽgen und fragen: "Welche Performance-Probleme siehst du?"
Schema-Validierung mit TypeScript
Nach dem SDL-Design empfiehlt sich eine automatische Validierung beim Start des Servers:
// src/schema/validate.ts
import { buildSchema, validateSchema } from 'graphql';
import { readFileSync } from 'fs';
import { join } from 'path';
export function loadAndValidateSchema() {
const sdl = readFileSync(join(__dirname, 'schema.graphql'), 'utf8');
const schema = buildSchema(sdl);
const errors = validateSchema(schema);
if (errors.length > 0) {
console.error('❌ Schema-Validierungsfehler:');
errors.forEach(e => console.error(e.message));
process.exit(1);
}
console.log('✅ Schema valide — starte Server');
return schema;
}
2. Resolver-Implementierung mit TypeScript
Resolver sind das Herzstück jeder GraphQL-API. Mit TypeScript und Claude Code bekommst du vollständig typisierte Resolver mit DataLoader für N+1-Prevention, Context-Typing und sauberer Fehlerbehandlung — alles in einem konsistenten Pattern.
RESOLVER
src/resolvers/types.ts
// Context Typing — SSOT für alle Resolver
import { PrismaClient } from '@prisma/client';
import DataLoader from 'dataloader';
import { User } from './generated/graphql';
export interface DataLoaders {
userLoader: DataLoader<string, User>;
productsByOwnerLoader: DataLoader<string, Product[]>;
variantsByProductLoader: DataLoader<string, ProductVariant[]>;
}
export interface GraphQLContext {
prisma: PrismaClient;
currentUser: User | null;
loaders: DataLoaders;
requestId: string;
}
// ResolverMap — vollständig typisiert
import { Resolvers } from './generated/graphql';
export type TypedResolvers = Resolvers<GraphQLContext>;
RESOLVER
src/resolvers/product.resolver.ts
// Vollständige Product-Resolver mit DataLoader
import { TypedResolvers, GraphQLContext } from './types';
import { AuthenticationError, UserInputError, ForbiddenError } from 'apollo-server-core';
import { encodeCursor, decodeCursor } from '../utils/cursor';
import { pubsub, EVENTS } from '../pubsub';
export const productResolvers: TypedResolvers = {
Query: {
product: async (_parent, { id }, ctx: GraphQLContext) => {
return ctx.prisma.product.findUnique({ where: { id } });
},
products: async (_parent, { filter, sort, pagination }, ctx: GraphQLContext) => {
const { first = 20, after } = pagination ?? {};
const cursor = after ? decodeCursor(after) : undefined;
const where = buildProductWhere(filter);
const orderBy = sort
? { [sort.field.toLowerCase()]: sort.order.toLowerCase() }
: { createdAt: 'desc' as const };
const [items, totalCount] = await ctx.prisma.$transaction([
ctx.prisma.product.findMany({
where,
orderBy,
take: first + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
}),
ctx.prisma.product.count({ where }),
]);
const hasNextPage = items.length > first;
const edges = items.slice(0, first).map(node => ({
node,
cursor: encodeCursor(node.id),
}));
return {
edges,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage: !!cursor,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
searchProducts: async (_parent, { query, limit }, ctx: GraphQLContext) => {
return ctx.prisma.product.findMany({
where: {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ tags: { hasSome: [query] } },
],
status: 'PUBLISHED',
},
take: limit,
orderBy: { createdAt: 'desc' },
});
},
},
Mutation: {
createProduct: async (_parent, { input }, ctx: GraphQLContext) => {
if (!ctx.currentUser) throw new AuthenticationError('Nicht authentifiziert');
const product = await ctx.prisma.product.create({
data: {
...input,
ownerId: ctx.currentUser.id,
},
});
await pubsub.publish(EVENTS.PRODUCT_CREATED, { newProductCreated: product });
return product;
},
updateProduct: async (_parent, { id, input }, ctx: GraphQLContext) => {
if (!ctx.currentUser) throw new AuthenticationError('Nicht authentifiziert');
const existing = await ctx.prisma.product.findUnique({ where: { id } });
if (!existing) throw new UserInputError(`Produkt ${id} nicht gefunden`);
if (existing.ownerId !== ctx.currentUser.id && ctx.currentUser.role !== 'ADMIN') {
throw new ForbiddenError('Keine Berechtigung');
}
const updated = await ctx.prisma.product.update({
where: { id },
data: { ...input, updatedAt: new Date() },
});
await pubsub.publish(EVENTS.PRODUCT_UPDATED, { productUpdated: updated });
return updated;
},
deleteProduct: async (_parent, { id }, ctx: GraphQLContext) => {
if (!ctx.currentUser) throw new AuthenticationError('Nicht authentifiziert');
await ctx.prisma.product.delete({ where: { id } });
return true;
},
},
// Field Resolver — DataLoader für N+1 Prevention
Product: {
owner: (parent, _args, ctx: GraphQLContext) => {
return ctx.loaders.userLoader.load(parent.ownerId);
},
variants: (parent, _args, ctx: GraphQLContext) => {
return ctx.loaders.variantsByProductLoader.load(parent.id);
},
},
};
// DataLoader Factory — verhindert N+1 Queries
export function createDataLoaders(prisma: PrismaClient): DataLoaders {
return {
userLoader: new DataLoader<string, User>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
const byId = Object.fromEntries(users.map(u => [u.id, u]));
return ids.map(id => byId[id] ?? new Error(`User ${id} not found`));
}),
productsByOwnerLoader: new DataLoader(async (ownerIds) => {
const products = await prisma.product.findMany({
where: { ownerId: { in: [...ownerIds] } },
});
const byOwner = ownerIds.map(id => products.filter(p => p.ownerId === id));
return byOwner;
}),
variantsByProductLoader: new DataLoader(async (productIds) => {
const variants = await prisma.productVariant.findMany({
where: { productId: { in: [...productIds] } },
});
const byProduct = productIds.map(id => variants.filter(v => v.productId === id));
return byProduct;
}),
};
}
⚠️ N+1-Problem: Ohne DataLoader führt jede product.owner-Anfrage eine separate DB-Query durch. Bei 100 Produkten = 101 Queries statt 2. Claude Code erkennt dieses Muster und schlägt automatisch DataLoader vor.
3. Pothos Schema Builder — Code-First mit TypeScript
Pothos ist 2026 der beste Code-First-Ansatz für GraphQL mit TypeScript. Vollständige Typsicherheit ohne Code-Generator, native Prisma-Integration und Plugin-Architektur für Auth, Complexity-Limits und mehr. Claude Code versteht das Pothos-API perfekt.
POTHOS
src/schema/builder.ts
// Pothos Builder Setup mit allen Plugins
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
import ScopeAuthPlugin from '@pothos/plugin-scope-auth';
import RelayPlugin from '@pothos/plugin-relay';
import ComplexityPlugin from '@pothos/plugin-complexity';
import ValidationPlugin from '@pothos/plugin-validation';
import { DateTimeResolver, JSONResolver } from 'graphql-scalars';
import { PrismaClient } from '@prisma/client';
import type { GraphQLContext } from './context';
import prismaTypes from './prisma-types'; // generated
export const builder = new SchemaBuilder<{
Context: GraphQLContext;
PrismaTypes: typeof prismaTypes;
AuthScopes: {
isAuthenticated: boolean;
hasRole: 'ADMIN' | 'MANAGER' | 'USER';
};
Scalars: {
DateTime: { Input: Date; Output: Date };
JSON: { Input: unknown; Output: unknown };
};
}>({
plugins: [ScopeAuthPlugin, PrismaPlugin, RelayPlugin, ComplexityPlugin, ValidationPlugin],
authScopes: (ctx: GraphQLContext) => ({
isAuthenticated: !!ctx.currentUser,
hasRole: (role) => ctx.currentUser?.role === role,
}),
prisma: { client: (ctx) => ctx.prisma },
complexity: {
defaultComplexity: 1,
defaultListMultiplier: 10,
limit: (ctx) => ({
complexity: ctx.currentUser?.role === 'ADMIN' ? 10000 : 2000,
depth: 8,
breadth: 40,
}),
},
relayOptions: {
clientMutationId: 'omit',
cursorType: 'String',
},
validationOptions: { useZod: true },
});
// Scalars registrieren
builder.addScalarType('DateTime', DateTimeResolver, {});
builder.addScalarType('JSON', JSONResolver, {});
POTHOS
src/schema/product.type.ts
// Pothos Prisma-Object mit Relay Connection
import { builder } from './builder';
const ProductStatusEnum = builder.enumType('ProductStatus', {
values: ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const,
});
const ProductRef = builder.prismaNode('Product', {
id: { field: 'id' },
authScopes: { isAuthenticated: false }, // public read
fields: (t) => ({
title: t.exposeString('title'),
description: t.exposeString('description', { nullable: true }),
price: t.exposeFloat('price'),
status: t.expose('status', { type: ProductStatusEnum }),
tags: t.exposeStringList('tags'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
updatedAt: t.expose('updatedAt', { type: 'DateTime' }),
// Relation — Prisma Plugin löst N+1 automatisch
owner: t.relation('owner'),
variants: t.relation('variants'),
// Berechnetes Feld
priceFormatted: t.string({
resolve: (product) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
.format(product.price),
}),
}),
});
// Query Fields
builder.queryField('product', (t) =>
t.prismaField({
type: 'Product',
nullable: true,
args: {
id: t.arg.id({ required: true }),
},
resolve: (query, _root, { id }, ctx) =>
ctx.prisma.product.findUnique({ ...query, where: { id: String(id) } }),
})
);
builder.queryField('products', (t) =>
t.prismaConnection({
type: 'Product',
cursor: 'id',
args: {
status: t.arg({ type: ProductStatusEnum, required: false }),
minPrice: t.arg.float({ required: false }),
search: t.arg.string({ required: false }),
},
resolve: (query, _root, { status, minPrice, search }, ctx) =>
ctx.prisma.product.findMany({
...query,
where: {
...(status ? { status } : {}),
...(minPrice ? { price: { gte: minPrice } } : {}),
...(search ? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
} : {}),
},
orderBy: { createdAt: 'desc' },
}),
})
);
// Mutation Fields
const CreateProductInput = builder.inputType('CreateProductInput', {
fields: (t) => ({
title: t.string({ required: true, validate: { minLength: 3, maxLength: 200 } }),
description: t.string({ required: false }),
price: t.float({ required: true, validate: { min: 0 } }),
status: t.field({ type: ProductStatusEnum, defaultValue: 'DRAFT' }),
tags: t.stringList({ required: false, defaultValue: [] }),
}),
});
builder.mutationField('createProduct', (t) =>
t.prismaField({
type: 'Product',
authScopes: { isAuthenticated: true },
args: {
input: t.arg({ type: CreateProductInput, required: true }),
},
resolve: async (query, _root, { input }, ctx) => {
return ctx.prisma.product.create({
...query,
data: {
...input,
ownerId: ctx.currentUser!.id,
},
});
},
})
);
💡 Pothos vs. SDL-First: Pothos eignet sich besonders für komplexe APIs mit vielen Relationen. SDL-First ist besser wenn dein Team schema.graphql als Kommunikationsmittel nutzt (z.B. Mobile + Backend + Frontend). Claude Code unterstützt beide Ansätze vollständig.
4. GraphQL Code Generator
GraphQL Code Generator erzeugt aus deinem Schema und deinen Operations vollständig typisierte TypeScript-Typen, Hooks und Document Nodes. Claude Code hilft dir, die optimale codegen.yml-Konfiguration zu erstellen und zu warten.
CODEGEN
codegen.yml
overwrite: true
schema: "./src/schema/schema.graphql"
documents: "./src/**/*.{graphql,tsx,ts}"
generates:
# Backend: vollständige Resolver-Typen
./src/generated/graphql.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: "../context#GraphQLContext"
mappers:
User: "@prisma/client#User"
Product: "@prisma/client#Product"
ProductVariant: "@prisma/client#ProductVariant"
enumsAsConst: true
useIndexSignature: true
scalars:
DateTime: Date
JSON: unknown
Upload: Promise<FileUpload>
# Frontend: typed-document-node fĂĽr Apollo/urql
./src/generated/operations.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
config:
documentMode: documentNodeImportFragments
optimizeDocumentNode: true
pureMagicComment: true
dedupeFragments: true
# React Hooks mit Apollo
./src/generated/apollo-hooks.tsx:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withHooks: true
withRefetchFn: true
hooksSuffix: Hook
apolloReactHooksImportFrom: "@apollo/client"
# Client-side schema (fĂĽr Introspection / Apollo Studio)
./src/generated/schema.json:
plugins:
- introspection
CODEGEN
src/operations/products.graphql
# Fragments — Wiederverwendbare Feldmengen
fragment ProductCore on Product {
id
title
description
price
priceFormatted
status
tags
createdAt
}
fragment ProductFull on Product {
...ProductCore
owner {
id
name
email
}
variants {
id
sku
price
stock
}
}
# Query mit Variablen
query GetProducts(
$status: ProductStatus
$first: Int = 20
$after: String
$search: String
) {
products(
filter: { status: $status, search: $search }
pagination: { first: $first, after: $after }
) {
edges {
node { ...ProductFull }
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
query GetProduct($id: ID!) {
product(id: $id) {
...ProductFull
}
}
# Mutations
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
...ProductFull
}
}
mutation UpdateProduct($id: ID!, $input: UpdateProductInput!) {
updateProduct(id: $id, input: $input) {
...ProductFull
}
}
mutation DeleteProduct($id: ID!) {
deleteProduct(id: $id)
}
# Subscription
subscription OnProductUpdated($id: ID!) {
productUpdated(id: $id) {
...ProductCore
updatedAt
}
}
đź’ˇ Claude Code + Codegen: Claude Code kann automatisch Operations aus deinen React-Komponenten extrahieren und typisierte Hooks generieren. Prompt: "Analysiere diese Komponente und erstelle die passenden GraphQL Operations + codegen.yml-Eintrag."
5. Apollo Client Integration
Apollo Client ist 2026 weiterhin der Standard für GraphQL im Frontend. Mit TypeScript, InMemoryCache-Konfiguration, optimistic responses und reactive variables erhältst du eine vollständig reaktive UI mit minimalem Boilerplate.
APOLLO CLIENT
src/apollo/client.ts
// Apollo Client Setup — vollständig typisiert
import {
ApolloClient,
InMemoryCache,
HttpLink,
split,
makeVar,
Reference,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import type { ProductStatus } from '../generated/graphql';
// Reactive Variables — globaler State ohne Redux
export const filterStatusVar = makeVar<ProductStatus | null>(null);
export const searchQueryVar = makeVar<string>('');
export const isAuthenticatedVar = makeVar<boolean>(false);
// HTTP Link mit Auth
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/api/graphql',
credentials: 'include',
headers: {
'x-client-version': '1.0.0',
},
});
// WebSocket Link fĂĽr Subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL ?? 'ws://localhost:4000/graphql',
connectionParams: () => ({
authorization: localStorage.getItem('auth-token'),
}),
retryAttempts: 5,
shouldRetry: () => true,
})
);
// Error Link
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
isAuthenticatedVar(false);
window.location.href = '/login';
}
console.error(`[GraphQL Error] ${operation.operationName}: ${message}`);
});
}
if (networkError) console.error(`[Network Error]: ${networkError}`);
});
// Retry Link fĂĽr transiente Fehler
const retryLink = new RetryLink({
delay: { initial: 300, max: 3000, jitter: true },
attempts: { max: 3, retryIf: (error) => !!error },
});
// Split: HTTP fĂĽr Queries/Mutations, WS fĂĽr Subscriptions
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
errorLink.concat(retryLink.concat(httpLink))
);
// Cache-Konfiguration mit Type Policies
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
products: {
// Relay Cursor Pagination Merge
keyArgs: ['filter', 'sort'],
merge(existing, incoming, { args }) {
if (!args?.pagination?.after) return incoming;
return {
...incoming,
edges: [...(existing?.edges ?? []), ...incoming.edges],
};
},
read(existing) {
return existing;
},
},
filterStatus: { read: () => filterStatusVar() },
searchQuery: { read: () => searchQueryVar() },
},
},
Product: {
keyFields: ['id'],
},
User: {
keyFields: ['id'],
},
},
});
export const apolloClient = new ApolloClient({
link: splitLink,
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
errorPolicy: 'all',
notifyOnNetworkStatusChange: true,
},
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all',
},
mutate: {
errorPolicy: 'all',
},
},
connectToDevTools: process.env.NODE_ENV === 'development',
});
APOLLO CLIENT
src/components/ProductList.tsx
// React-Komponente mit generierten Hooks + Optimistic UI
import { useReactiveVar } from '@apollo/client';
import {
useGetProductsQuery,
useCreateProductMutation,
useDeleteProductMutation,
GetProductsDocument,
} from '../generated/apollo-hooks';
import { filterStatusVar, searchQueryVar } from '../apollo/client';
export function ProductList() {
const status = useReactiveVar(filterStatusVar);
const search = useReactiveVar(searchQueryVar);
const { data, loading, error, fetchMore } = useGetProductsQuery({
variables: { status, search, first: 20 },
notifyOnNetworkStatusChange: true,
});
const [createProduct] = useCreateProductMutation({
// Optimistic Response — UI sofort aktualisieren
optimisticResponse: (vars) => ({
createProduct: {
__typename: 'Product' as const,
id: `temp-${Date.now()}`,
title: vars.input.title,
description: vars.input.description ?? null,
price: vars.input.price,
priceFormatted: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(vars.input.price),
status: 'DRAFT' as const,
tags: vars.input.tags ?? [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
owner: { __typename: 'User', id: 'current', name: 'Du', email: '' },
variants: [],
},
}),
update: (cache, { data }) => {
if (!data?.createProduct) return;
cache.modify({
fields: {
products(existing) {
const newEdge = {
__typename: 'ProductEdge',
node: data.createProduct,
cursor: data.createProduct.id,
};
return {
...existing,
edges: [newEdge, ...existing.edges],
totalCount: existing.totalCount + 1,
};
},
},
});
},
});
const [deleteProduct] = useDeleteProductMutation({
update: (cache, _result, { variables }) => {
cache.evict({ id: cache.identify({ __typename: 'Product', id: variables?.id }) });
cache.gc();
},
});
const loadMore = () => {
if (!data?.products.pageInfo.hasNextPage) return;
fetchMore({
variables: {
after: data.products.pageInfo.endCursor,
},
});
};
if (loading && !data) return <div>Lade Produkte...</div>;
if (error) return <div>Fehler: {error.message}</div>;
return (
<div>
<div className="filters">
<input
value={search}
onChange={(e) => searchQueryVar(e.target.value)}
placeholder="Suche..."
/>
</div>
<div className="product-grid">
{data?.products.edges.map(({ node }) => (
<div key={node.id}>
<h3>{node.title}</h3>
<p>{node.priceFormatted}</p>
<button onClick={() => deleteProduct({ variables: { id: node.id } })}>
Löschen
</button>
</div>
))}
</div>
{data?.products.pageInfo.hasNextPage && (
<button onClick={loadMore}>Mehr laden</button>
)}
</div>
);
}
6. Subscriptions & Real-Time
GraphQL Subscriptions ermöglichen Echtzeit-Updates via WebSocket. Mit graphql-ws, PubSub und asyncIterator baust du skalierbare Real-Time-Features — von Live-Updates bis hin zu Kollaborations-Features. Claude Code generiert die gesamte Subscription-Infrastruktur in Minuten.
SUBSCRIPTION SERVER
src/pubsub.ts
// PubSub Setup — skalierbar mit Redis für Prod
import { PubSub } from 'graphql-subscriptions';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
// Event Registry — alle Subscription-Events typisiert
export const EVENTS = {
PRODUCT_UPDATED: 'PRODUCT_UPDATED',
PRODUCT_STATUS_CHANGED: 'PRODUCT_STATUS_CHANGED',
PRODUCT_CREATED: 'PRODUCT_CREATED',
} as const;
export type EventName = typeof EVENTS[keyof typeof EVENTS];
// Production: Redis PubSub (horizontal skalierbar)
function createPubSub() {
if (process.env.REDIS_URL) {
const publisher = new Redis(process.env.REDIS_URL);
const subscriber = new Redis(process.env.REDIS_URL);
return new RedisPubSub({ publisher, subscriber });
}
// Development: In-Memory PubSub
return new PubSub();
}
export const pubsub = createPubSub();
SUBSCRIPTION RESOLVERS
src/resolvers/subscription.resolver.ts
// Subscription Resolver mit withFilter + asyncIterator
import { withFilter } from 'graphql-subscriptions';
import { pubsub, EVENTS } from '../pubsub';
import type { TypedResolvers, GraphQLContext } from './types';
export const subscriptionResolvers: TypedResolvers['Subscription'] = {
productUpdated: {
// Subscribe mit Filter — nur relevante Updates
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.PRODUCT_UPDATED]),
(payload, variables) => {
return payload.productUpdated.id === variables.id;
}
),
resolve: (payload) => payload.productUpdated,
},
productStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.PRODUCT_STATUS_CHANGED]),
(payload, variables) => {
if (!variables.status) return true; // alle Status wenn kein Filter
return payload.productStatusChanged.status === variables.status;
}
),
resolve: (payload) => payload.productStatusChanged,
},
newProductCreated: {
subscribe: (_parent, _args, ctx: GraphQLContext) => {
// Authentifizierung fĂĽr Subscriptions
if (!ctx.currentUser) {
throw new Error('Nicht authentifiziert');
}
return pubsub.asyncIterator([EVENTS.PRODUCT_CREATED]);
},
resolve: (payload) => payload.newProductCreated,
},
};
WEBSOCKET SERVER
src/server.ts
// Vollständiger Server mit HTTP + WebSocket
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs, resolvers } from './schema';
import { createContext } from './context';
import { createDataLoaders } from './resolvers/product.resolver';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const app = express();
const httpServer = http.createServer(app);
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket Server fĂĽr Subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// Auth aus WebSocket Connection Params
const token = ctx.connectionParams?.authorization as string;
const currentUser = token ? await verifyToken(token) : null;
return {
prisma,
currentUser,
loaders: createDataLoaders(prisma),
requestId: crypto.randomUUID(),
};
},
onConnect: (ctx) => {
console.log(`WS connected: ${ctx.connectionParams?.clientId ?? 'anon'}`);
return true;
},
onDisconnect: (_ctx, code, reason) => {
console.log(`WS disconnected: ${code} ${reason}`);
},
},
wsServer
);
// Apollo Server
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use(
'/graphql',
cors({ origin: process.env.CORS_ORIGIN, credentials: true }),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
prisma,
currentUser: await getUserFromRequest(req),
loaders: createDataLoaders(prisma),
requestId: req.headers['x-request-id'] as string ?? crypto.randomUUID(),
}),
})
);
httpServer.listen(4000, () => {
console.log('🚀 GraphQL: http://localhost:4000/graphql');
console.log('🔌 WebSocket: ws://localhost:4000/graphql');
});
REACT SUBSCRIPTION
src/components/ProductDetail.tsx
// React Komponente mit Live-Updates via Subscription
import { useGetProductQuery, useOnProductUpdatedSubscription } from '../generated/apollo-hooks';
interface Props { productId: string }
export function ProductDetail({ productId }: Props) {
const { data, loading } = useGetProductQuery({
variables: { id: productId },
});
// Subscription — automatisches Cache-Update
useOnProductUpdatedSubscription({
variables: { id: productId },
onData: ({ client, data: subData }) => {
if (!subData.data?.productUpdated) return;
// Cache direkt schreiben — kein Re-fetch nötig
client.cache.writeFragment({
id: client.cache.identify({
__typename: 'Product',
id: productId,
}),
fragment: ProductCoreFragmentDoc,
data: subData.data.productUpdated,
});
},
});
if (loading) return <div>Lade...</div>;
const product = data?.product;
if (!product) return <div>Produkt nicht gefunden</div>;
return (
<div>
<h1>{product.title}</h1>
<span className={`status-badge ${product.status.toLowerCase()}`>
{product.status}
</span>
<p>{product.priceFormatted}</p>
<p>Zuletzt aktualisiert: {new Date(product.updatedAt).toLocaleString('de-DE')}</p>
</div>
);
}
⚠️ Production-Tipp: In-Memory PubSub funktioniert nur auf einer Server-Instanz. Für horizontale Skalierung (mehrere Pods) IMMER Redis PubSub verwenden. Claude Code erkennt diesen Fehler und schlägt die richtige Konfiguration vor wenn du nach "Skalierung" fragst.
💡 Subscription Security: Auth-Token NIEMALS in der WebSocket-URL mitgeben (wird geloggt!). Immer über connectionParams senden — genau wie im obigen Beispiel. Claude Code weist dich automatisch auf dieses Sicherheitsproblem hin.
GraphQL
TypeScript
Apollo
Pothos
DataLoader
Subscriptions
WebSocket
Code Generator
Prisma
Claude Code
AM
Agentic Movers Redaktion
Claude Code Mastery — praxisnahe Tutorials seit 2025
GraphQL-Modul im Kurs
Im Claude Code Mastery Kurs: vollständiges GraphQL-Modul mit Pothos Schema Builder, Code Generator, Apollo Client, Subscriptions und DataLoader-Optimierung.
14 Tage kostenlos testen →