GraphQL has emerged as a compelling alternative to REST for API design. After building production GraphQL APIs serving millions of requests, I’ve learned patterns that scale and pitfalls to avoid.
Why GraphQL?
GraphQL solves REST pain points:
- Over-fetching: Clients get exactly what they request
- Under-fetching: Single request replaces multiple REST calls
- Strong typing: Schema provides contracts and documentation
- Introspection: Self-documenting APIs
Schema Design Patterns
Good schema design is crucial:
# User domain
type User {
id: ID!
email: String!
profile: Profile
posts(first: Int, after: String): PostConnection!
}
type Profile {
name: String!
bio: String
avatarUrl: String
}
# Post domain
type Post {
id: ID!
title: String!
content: String!
author: User!
comments(first: Int, after: String): CommentConnection!
createdAt: DateTime!
}
# Pagination with connections
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
# Queries
type Query {
user(id: ID!): User
post(id: ID!): Post
posts(first: Int, after: String): PostConnection!
}
# Mutations
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(input: UpdatePostInput!): UpdatePostPayload!
}
input CreatePostInput {
title: String!
content: String!
}
type CreatePostPayload {
post: Post
errors: [Error!]
}
Resolver Implementation
Efficient resolvers prevent N+1 queries:
const { DataLoader } = require('dataloader');
// Batch loading users
const userLoader = new DataLoader(async (userIds) => {
const users = await db.users.findMany({
where: { id: { in: userIds } }
});
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id));
});
const resolvers = {
Query: {
user: (_, { id }) => userLoader.load(id),
posts: async (_, { first = 10, after }) => {
const posts = await db.posts.findMany({
take: first + 1,
cursor: after ? { id: after } : undefined,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post.id
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.cursor
}
};
}
},
Post: {
author: (post) => userLoader.load(post.authorId),
comments: async (post, { first = 10, after }) => {
const comments = await db.comments.findMany({
where: { postId: post.id },
take: first + 1,
cursor: after ? { id: after } : undefined
});
const hasNextPage = comments.length > first;
const edges = comments.slice(0, first).map(comment => ({
node: comment,
cursor: comment.id
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.cursor
}
};
}
},
Mutation: {
createPost: async (_, { input }, { user }) => {
if (!user) {
return {
errors: [{ message: 'Unauthorized' }]
};
}
const post = await db.posts.create({
data: {
...input,
authorId: user.id
}
});
return { post };
}
}
};
Authentication and Authorization
Implement auth at multiple levels:
const { ApolloServer } = require('apollo-server');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');
// Auth directive
const authDirectiveTypeDefs = `
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
USER
ADMIN
}
`;
function authDirective(schema, directiveName) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
if (!context.user) {
throw new Error('Unauthorized');
}
if (requires === 'ADMIN' && context.user.role !== 'ADMIN') {
throw new Error('Forbidden');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
}
});
}
// Usage in schema
const typeDefs = `
${authDirectiveTypeDefs}
type Query {
user(id: ID!): User @auth
adminUsers: [User!]! @auth(requires: ADMIN)
}
type Mutation {
deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
}
`;
Query Complexity and Rate Limiting
Prevent expensive queries:
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 10
})
],
context: async ({ req }) => {
const token = req.headers.authorization;
const user = await authenticateToken(token);
return {
user,
loaders: {
user: userLoader,
post: postLoader
}
};
}
});
Caching Strategies
Implement multi-level caching:
const { ApolloServer } = require('apollo-server');
const { Redis } = require('ioredis');
const { RedisCachePlugin } = require('apollo-server-cache-redis');
const redis = new Redis({
host: 'localhost',
port: 6379
});
const server = new ApolloServer({
schema,
plugins: [new RedisCachePlugin({ redis })],
resolvers: {
Query: {
user: async (_, { id }, { dataSources }) => {
const cacheKey = `user:${id}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Fetch from database
const user = await dataSources.userAPI.getUser(id);
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
}
}
});
Error Handling
Structured error handling:
const { ApolloError } = require('apollo-server');
class ValidationError extends ApolloError {
constructor(message, fields) {
super(message, 'VALIDATION_ERROR', { fields });
}
}
const resolvers = {
Mutation: {
createPost: async (_, { input }) => {
// Validate input
const errors = [];
if (!input.title || input.title.length < 3) {
errors.push({ field: 'title', message: 'Title too short' });
}
if (!input.content) {
errors.push({ field: 'content', message: 'Content required' });
}
if (errors.length > 0) {
return { errors };
}
try {
const post = await db.posts.create({ data: input });
return { post };
} catch (error) {
return {
errors: [{ message: 'Failed to create post' }]
};
}
}
}
};
Performance Monitoring
Track GraphQL performance:
const { ApolloServer } = require('apollo-server');
const { ApolloServerPluginUsageReporting } = require('apollo-server-core');
const server = new ApolloServer({
schema,
plugins: [
{
requestDidStart() {
const start = Date.now();
return {
willSendResponse({ metrics, context }) {
const duration = Date.now() - start;
console.log({
operation: context.operation?.name,
duration,
errors: metrics.errors?.length || 0
});
}
};
}
}
]
});
Subscription Pattern
Real-time updates via subscriptions:
type Subscription {
postAdded: Post!
commentAdded(postId: ID!): Comment!
}
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const resolvers = {
Mutation: {
createPost: async (_, { input }) => {
const post = await db.posts.create({ data: input });
// Publish event
pubsub.publish('POST_ADDED', { postAdded: post });
return { post };
}
},
Subscription: {
postAdded: {
subscribe: () => pubsub.asyncIterator(['POST_ADDED'])
},
commentAdded: {
subscribe: (_, { postId }) =>
pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
}
}
};
Testing
Comprehensive testing strategies:
const { createTestClient } = require('apollo-server-testing');
const { ApolloServer } = require('apollo-server');
describe('GraphQL API', () => {
let server, query, mutate;
beforeEach(() => {
server = new ApolloServer({
schema,
context: () => ({ user: { id: '1', role: 'USER' } })
});
const client = createTestClient(server);
query = client.query;
mutate = client.mutate;
});
it('fetches user', async () => {
const result = await query({
query: gql`
query GetUser($id: ID!) {
user(id: $id) {
id
email
}
}
`,
variables: { id: '1' }
});
expect(result.data.user).toHaveProperty('email');
});
});
Conclusion
GraphQL provides powerful API capabilities when designed well. Key principles:
- Design schemas for clients, not databases
- Use DataLoader to prevent N+1 queries
- Implement proper authentication and authorization
- Limit query complexity
- Cache aggressively
- Monitor performance
- Test thoroughly
GraphQL shines for client-driven applications where flexibility matters more than simplicity. Start with a clear schema, optimize resolvers, and monitor performance to build scalable GraphQL APIs.