graphql-expert
Production GraphQL API design. Relay pagination spec, mutation payload types, N+1 prevention with DataLoader, subscriptions, Apollo Federation for microservices, persisted queries, depth limiting, and complexity analysis.
Installation
npx clawhub@latest install graphql-expertView the full skill documentation and source below.
Documentation
GraphQL Production Expert
Schema Design Best Practices
# Use Relay pagination spec throughout
# Use mutation payload types with errors array
# Name mutations as verb + noun
# Use enums for finite sets
# Make scalar types explicit
type Query {
node(id: ID!): Node # Global node interface (Relay spec)
users(
first: Int
after: String
last: Int
before: String
filter: UserFilter
orderBy: UserOrderBy = { field: CREATED_AT, direction: DESC }
): UserConnection!
user(id: ID!): User
}
interface Node {
id: ID!
}
type User implements Node {
id: ID!
email: String!
displayName: String!
createdAt: DateTime!
# Nested connections — implement with DataLoader
posts(first: Int, after: String): PostConnection!
followers(first: Int): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Mutation payload pattern — structured errors
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePostPayload!
}
type CreatePostPayload {
post: Post # null if errors
errors: [UserError!]!
clientMutationId: String # Relay spec
}
type UserError {
field: [String!] # Which field (null = form-level)
code: String!
message: String!
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
status: PostStatus = DRAFT
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
scalar DateTime
scalar JSON
Resolvers with TypeScript
// Apollo Server 4 with TypeScript
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
interface Context {
user: AuthUser | null;
loaders: ReturnType<typeof createLoaders>;
db: Database;
}
const resolvers = {
Query: {
users: async (_, { first = 20, after, filter }, ctx: Context) => {
const users = await ctx.db.users.findMany({
take: first + 1, // Fetch one extra to determine hasNextPage
cursor: after ? { id: decodeCursor(after) } : undefined,
where: buildUserFilter(filter),
orderBy: { createdAt: 'desc' },
});
const hasNextPage = users.length > first;
const nodes = hasNextPage ? users.slice(0, -1) : users;
return {
edges: nodes.map(user => ({
node: user,
cursor: encodeCursor(user.id),
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1)!.id) : null,
},
totalCount: () => ctx.db.users.count({ where: buildUserFilter(filter) }),
};
},
user: async (_, { id }, ctx: Context) => {
return ctx.loaders.user.load(id); // DataLoader for batching
},
},
User: {
// N+1 solution: use DataLoader
posts: async (user, { first = 10, after }, ctx: Context) => {
return ctx.loaders.userPosts.load({ userId: user.id, first, after });
},
// Computed field
displayName: (user) => user.name || user.email.split('@')[0],
},
Mutation: {
createPost: async (_, { input }, ctx: Context) => {
if (!ctx.user) {
return {
post: null,
errors: [{ code: 'UNAUTHENTICATED', message: 'Must be logged in', field: null }],
};
}
// Validate
const errors = validateCreatePost(input);
if (errors.length > 0) return { post: null, errors };
try {
const post = await ctx.db.posts.create({
data: { ...input, authorId: ctx.user.id }
});
return { post, errors: [] };
} catch (e) {
return {
post: null,
errors: [{ code: 'DATABASE_ERROR', message: 'Failed to create post', field: null }]
};
}
},
},
};
DataLoader (N+1 Prevention)
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
export function createLoaders(db: PrismaClient) {
return {
// Batch user lookups
user: new DataLoader<string, User | null>(
async (ids) => {
const users = await db.user.findMany({
where: { id: { in: ids as string[] } },
});
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
},
{
cache: true, // Cache per request (default)
maxBatchSize: 100, // Max batch size
batchScheduleFn: (callback) => setTimeout(callback, 0), // Next tick
}
),
// Batch load posts by userId
userPosts: new DataLoader<string, Post[]>(
async (userIds) => {
const posts = await db.post.findMany({
where: { authorId: { in: userIds as string[] } },
orderBy: { createdAt: 'desc' },
});
// Group by authorId, preserving order
const grouped = userIds.map(userId =>
posts.filter(p => p.authorId === userId)
);
return grouped;
},
{ cache: false } // No caching for time-sensitive data
),
};
}
// Create fresh loaders per request (prevents cache bleeding between users)
// In Apollo context:
const server = new ApolloServer({ resolvers, typeDefs });
startStandaloneServer(server, {
context: async ({ req }) => ({
user: await getUserFromToken(req.headers.authorization),
loaders: createLoaders(prisma), // NEW loaders per request
db: prisma,
}),
});
Subscriptions
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// In production, use Redis-based PubSub for multi-server:
// import { RedisPubSub } from 'graphql-redis-subscriptions';
// const pubsub = new RedisPubSub({ publisher: redisClient, subscriber: redisClient });
const resolvers = {
Subscription: {
postCreated: {
subscribe: (_, args, ctx) => {
// Authorization check
if (!ctx.user) throw new Error('Unauthorized');
return pubsub.asyncIterator(['POST_CREATED']);
},
resolve: (payload) => payload.postCreated,
},
// Filter subscription events
postUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['POST_UPDATED']),
(payload, variables) => payload.postUpdated.authorId === variables.authorId
),
},
},
Mutation: {
createPost: async (_, { input }, ctx) => {
const post = await ctx.db.posts.create({ data: input });
// Publish to subscribers
await pubsub.publish('POST_CREATED', { postCreated: post });
return { post, errors: [] };
},
},
};
GraphQL Federation (Microservices)
// Subgraph: users service
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';
const userSchema = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3")
type Query {
me: User
user(id: ID!): User
}
type User @key(fields: "id") { # This type is "owned" by users service
id: ID!
email: String!
name: String!
}
`;
// Subgraph: posts service
const postSchema = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3")
type Query {
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User! # References User from users service
}
# Extend User to add posts field (without owning the User type)
extend type User @key(fields: "id") {
id: ID! @external # ID comes from users service
posts: [Post!]! # Added by posts service
}
`;
const resolvers = {
User: {
// Federation: resolve User from just its key
__resolveReference: async ({ id }) => {
return db.users.findUnique({ where: { id } });
},
posts: async (user) => {
return db.posts.findMany({ where: { authorId: user.id } });
},
},
};
Persisted Queries (Production Performance)
// Automatic Persisted Queries (APQ) reduce bandwidth
// Client sends hash first, server responds with error if not found, then client sends full query
import { ApolloServer } from '@apollo/server';
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { generatePersistedQueryIdsFromManifest } from "@apollo/persisted-query-lists";
import { sha256 } from 'crypto-hash';
// Server-side: allow only registered operations (safelisting)
const server = new ApolloServer({
typeDefs,
resolvers,
// Only allow pre-registered operations in production
allowBatchedHttpRequests: true,
csrfPrevention: true,
});
// Client-side APQ link
const link = createPersistedQueryLink({ sha256 }).concat(httpLink);
Security Patterns
// Depth limiting — prevent deeply nested queries (DoS protection)
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // Max query depth
createComplexityLimitRule(1000, { // Max complexity score
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
plugins: [
{
// Log expensive queries
requestDidStart: async () => ({
willSendResponse: async ({ response, metrics }) => {
if (metrics.executionStarted) {
const duration = Date.now() - metrics.executionStarted;
if (duration > 1000) {
logger.warn('Slow GraphQL query', {
duration,
operationName: response.http.headers.get('x-apollo-operation-name'),
});
}
}
},
}),
},
],
});