GraphQL API Design - Production Best Practices for Scalable Applications
Master GraphQL API design with schema design patterns, resolvers optimization, N+1 query prevention, authentication, caching strategies, error handling, and production best practices.
GraphQL revolutionizes API design by allowing clients to request exactly the data they need, eliminating over-fetching and under-fetching problems inherent in REST APIs. This comprehensive guide covers production-ready GraphQL patterns from schema design to performance optimization, authentication, caching, and error handling used by companies processing billions of GraphQL queries daily.
Why GraphQL
Flexible Data Fetching: Clients specify required fields, reducing bandwidth and improving performance for mobile apps.
Single Endpoint: Unlike REST's multiple endpoints, GraphQL uses one endpoint for all operations, simplifying API maintenance.
Strong Typing: Schema-first approach provides compile-time type safety and excellent developer experience with autocomplete and validation.
Efficient Relationships: Fetch related data in a single request instead of multiple REST calls.
GitHub's GraphQL API reduced mobile app data transfer by 60% compared to REST, while Shopify handles 50M+ GraphQL requests/hour with sub-100ms p95 latency.
Schema Design Best Practices
Type System Fundamentals
# Object types represent entities
type User {
id: ID!
email: String!
username: String!
profile: Profile
posts(first: Int = 10, after: String): PostConnection!
createdAt: DateTime!
updatedAt: DateTime!
}
type Profile {
id: ID!
bio: String
avatarUrl: String
location: String
website: String
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments(first: Int = 10, after: String): CommentConnection!
publishedAt: DateTime
createdAt: DateTime!
}
Input types for mutations
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
}
Enums for fixed sets of values
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Interfaces for shared fields
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node {
id: ID!
title: String!
content: String!
createdAt: DateTime!
updatedAt: DateTime!
}
Pagination Patterns
Cursor-based pagination for consistent results when data changes:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(
first: Int
after: String
last: Int
before: String
orderBy: PostOrderBy
): PostConnection!
}
input PostOrderBy {
field: PostOrderField!
direction: OrderDirection!
}
enum PostOrderField {
CREATED_AT
UPDATED_AT
TITLE
}
enum OrderDirection {
ASC
DESC
}
Mutations Design
type Mutation {
# Create operations
createPost(input: CreatePostInput!): CreatePostPayload!
Update operations
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
Delete operations
deletePost(id: ID!): DeletePostPayload!
Bulk operations
publishPosts(ids: [ID!]!): PublishPostsPayload!
}
Payload types with errors
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
message: String!
field: String
code: String!
}
type DeletePostPayload {
deletedPostId: ID
errors: [UserError!]!
}
Resolver Implementation
Basic Resolvers with TypeScript
import { GraphQLResolveInfo } from 'graphql';
interface Context {
prisma: PrismaClient;
user?: User;
loaders: DataLoaders;
}
const resolvers = {
Query: {
user: async (
_parent: unknown,
args: { id: string },
context: Context,
_info: GraphQLResolveInfo
) => {
return context.prisma.user.findUnique({
where: { id: args.id }
});
},
posts: async (
_parent: unknown,
args: {
first?: number;
after?: string;
orderBy?: { field: string; direction: string };
},
context: Context
) => {
const limit = args.first || 10;
const cursor = args.after ? { id: args.after } : undefined;
const posts = await context.prisma.post.findMany({
take: limit + 1,
cursor,
orderBy: {
[args.orderBy?.field || 'createdAt']:
args.orderBy?.direction?.toLowerCase() || 'desc'
}
});
const hasNextPage = posts.length > limit;
const edges = posts.slice(0, limit).map(post => ({
node: post,
cursor: post.id
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!cursor,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await context.prisma.post.count()
};
}
},
Mutation: {
createPost: async (
_parent: unknown,
args: { input: { title: string; content: string } },
context: Context
) => {
if (!context.user) {
return {
post: null,
errors: [{
message: 'Authentication required',
code: 'UNAUTHENTICATED'
}]
};
}
try {
const post = await context.prisma.post.create({
data: {
title: args.input.title,
content: args.input.content,
authorId: context.user.id
}
});
return { post, errors: [] };
} catch (error) {
return {
post: null,
errors: [{
message: 'Failed to create post',
code: 'INTERNAL_ERROR'
}]
};
}
}
},
User: {
posts: async (
parent: User,
args: { first?: number; after?: string },
context: Context
) => {
const limit = args.first || 10;
const posts = await context.prisma.post.findMany({
where: { authorId: parent.id },
take: limit + 1,
cursor: args.after ? { id: args.after } : undefined,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = posts.length > limit;
const edges = posts.slice(0, limit).map(post => ({
node: post,
cursor: post.id
}));
return {
edges,
pageInfo: {
hasNextPage,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
}
};
},
profile: async (
parent: User,
_args: unknown,
context: Context
) => {
// Use DataLoader to batch and cache profile lookups
return context.loaders.profile.load(parent.id);
}
},
Post: {
author: async (
parent: Post,
_args: unknown,
context: Context
) => {
// Use DataLoader to prevent N+1 queries
return context.loaders.user.load(parent.authorId);
}
}
};
Solving the N+1 Problem with DataLoader
The N+1 problem occurs when fetching a list plus related data requires 1 query for the list + N queries for each item's relations.
DataLoader Implementation
import DataLoader from 'dataloader';
interface DataLoaders {
user: DataLoader<string, User>;
profile: DataLoader<string, Profile>;
post: DataLoader<string, Post>;
}
function createLoaders(prisma: PrismaClient): DataLoaders {
return {
user: new DataLoader<string, User>(async (userIds) => {
const users = await prisma.user.findMany({
where: { id: { in: [...userIds] } }
});
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id)!);
}),
profile: new DataLoader<string, Profile>(async (userIds) => {
const profiles = await prisma.profile.findMany({
where: { userId: { in: [...userIds] } }
});
const profileMap = new Map(profiles.map(p => [p.userId, p]));
return userIds.map(id => profileMap.get(id)!);
}),
post: new DataLoader<string, Post>(async (postIds) => {
const posts = await prisma.post.findMany({
where: { id: { in: [...postIds] } }
});
const postMap = new Map(posts.map(p => [p.id, p]));
return postIds.map(id => postMap.get(id)!);
})
};
}
// Apollo Server setup
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({
typeDefs,
resolvers
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const user = await authenticateUser(req);
return {
prisma,
user,
loaders: createLoaders(prisma) // Fresh loaders per request
};
}
});
Before DataLoader (N+1 queries):
-- Query 1: Fetch posts
SELECT * FROM posts LIMIT 10;
-- Query 2-11: Fetch author for each post
SELECT * FROM users WHERE id = ?; -- x10
After DataLoader (2 queries):
-- Query 1: Fetch posts
SELECT * FROM posts LIMIT 10;
-- Query 2: Batch fetch all authors
SELECT * FROM users WHERE id IN (?, ?, ?, ...); -- Single query
Authentication and Authorization
JWT Authentication
import jwt from 'jsonwebtoken';
async function authenticateUser(req: Request): Promise<User | undefined> {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return undefined;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
};
return await prisma.user.findUnique({
where: { id: decoded.userId }
});
} catch (error) {
return undefined;
}
}
Field-Level Authorization
import { shield, rule, allow } from 'graphql-shield';
const isAuthenticated = rule()((_parent, _args, context: Context) => {
return context.user !== undefined;
});
const isPostAuthor = rule()(async (parent, _args, context: Context) => {
const post = await context.prisma.post.findUnique({
where: { id: parent.id }
});
return post?.authorId === context.user?.id;
});
const permissions = shield({
Query: {
me: isAuthenticated,
users: allow
},
Mutation: {
createPost: isAuthenticated,
updatePost: isPostAuthor,
deletePost: isPostAuthor
},
User: {
email: isAuthenticated // Only authenticated users see emails
}
});
// Apply to server
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [applyMiddleware(permissions)]
});
Caching Strategies
HTTP Caching
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({
defaultMaxAge: 300, // 5 minutes
calculateHttpHeaders: true
}),
responseCachePlugin()
]
});
// In schema, specify cache hints
type Query {
posts: [Post!]! @cacheControl(maxAge: 3600) # 1 hour
user(id: ID!): User @cacheControl(maxAge: 300) # 5 minutes
me: User @cacheControl(maxAge: 0, scope: PRIVATE) # No cache
}
Redis Caching
import { Redis } from 'ioredis';
import { createHash } from 'crypto';
const redis = new Redis(process.env.REDIS_URL);
async function cachedResolver<T>(
key: string,
ttl: number,
resolver: () => Promise<T>
): Promise<T> {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Execute resolver
const result = await resolver();
// Cache result
await redis.setex(key, ttl, JSON.stringify(result));
return result;
}
// Usage in resolver
const resolvers = {
Query: {
posts: async (_parent, args, context) => {
const cacheKey = createHash('md5')
.update(JSON.stringify(args))
.digest('hex');
return cachedResolver(
`posts:${cacheKey}`,
3600,
async () => {
return context.prisma.post.findMany({
take: args.first || 10
});
}
);
}
}
};
Automatic Persisted Queries (APQ)
Reduce bandwidth by caching queries by hash:
import { ApolloServerPluginInlineTrace } from '@apollo/server/plugin/inlineTrace';
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new Map() // Use Redis in production
}
});
// Client sends query hash instead of full query
// First request: sends full query + hash
// Subsequent requests: sends only hash (saves bandwidth)
Error Handling
Custom Error Types
import { GraphQLError } from 'graphql';
class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 }
}
});
}
}
class ValidationError extends GraphQLError {
constructor(message: string, fields: Record<string, string>) {
super(message, {
extensions: {
code: 'BAD_USER_INPUT',
fields,
http: { status: 400 }
}
});
}
}
class NotFoundError extends GraphQLError {
constructor(resource: string, id: string) {
super(${resource} with id ${id} not found, {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 }
}
});
}
}
// Usage
const resolvers = {
Mutation: {
updatePost: async (_parent, args, context) => {
if (!context.user) {
throw new AuthenticationError('You must be logged in');
}
const post = await context.prisma.post.findUnique({
where: { id: args.id }
});
if (!post) {
throw new NotFoundError('Post', args.id);
}
if (post.authorId !== context.user.id) {
throw new GraphQLError('You do not have permission to edit this post', {
extensions: { code: 'FORBIDDEN' }
});
}
if (!args.input.title || args.input.title.length < 3) {
throw new ValidationError('Invalid input', {
title: 'Title must be at least 3 characters'
});
}
return context.prisma.post.update({
where: { id: args.id },
data: args.input
});
}
}
};
Error Formatting
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log internal errors
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
console.error('GraphQL Internal Error:', error);
// Don't expose internal details
return {
message: 'An internal error occurred',
extensions: {
code: 'INTERNAL_SERVER_ERROR'
}
};
}
return formattedError;
}
});
Query Complexity and Rate Limiting
Query Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)] // Max 7 levels deep
});
// Prevents deeply nested queries like:
// {
// user {
// posts {
// author {
// posts {
// author { ... } # Too deep!
// }
// }
// }
// }
// }
Query Cost Analysis
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
variables: {},
onComplete: (complexity: number) => {
console.log('Query Complexity:', complexity);
},
estimators: [
// Simple fields cost 1
fieldExtensionsEstimator(),
// Connections cost multiplier
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
// In schema, assign complexity
type Query {
posts(first: Int = 10): [Post!]! @complexity(multiplier: "first", value: 1)
users(first: Int = 10): [User!]! @complexity(multiplier: "first", value: 2)
}
Rate Limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const limiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:graphql:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // 1000 requests per window
keyGenerator: (req) => {
// Rate limit by user ID if authenticated, IP otherwise
return req.context?.user?.id || req.ip;
}
});
app.use('/graphql', limiter);
Subscriptions for Real-Time Features
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Schema
const typeDefs = type Subscription { postCreated: Post! commentAdded(postId: ID!): Comment! };
// Resolvers
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},
commentAdded: {
subscribe: (_parent, args) => {
return pubsub.asyncIterator([`COMMENT_ADDED_${args.postId}`]);
}
}
},
Mutation: {
createPost: async (_parent, args, context) => {
const post = await context.prisma.post.create({
data: args.input
});
// Publish event
pubsub.publish('POST_CREATED', { postCreated: post });
return { post, errors: [] };
},
addComment: async (_parent, args, context) => {
const comment = await context.prisma.comment.create({
data: args.input
});
// Publish event to specific post
pubsub.publish(`COMMENT_ADDED_${args.input.postId}`, {
commentAdded: comment
});
return { comment, errors: [] };
}
}
};
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
useServer({ schema }, wsServer);
Real-World Examples
GitHub GraphQL API
GitHub's GraphQL API handles 10M+ queries/day:
- Node interface: Every object implements Node with global ID
- Relay pagination: Cursor-based with edges/nodes pattern
- Rate limiting: 5,000 points/hour with dynamic cost calculation
- Deprecation: Fields marked @deprecated with migration guides
Their API reduced mobile data usage 60% vs REST while providing flexible querying.
Shopify GraphQL API
Shopify processes 50M+ GraphQL requests/hour:
- Bulk operations: Special mutations for batch processing (10K+ products)
- Throttling: Adaptive rate limiting based on shop size
- Webhook subscriptions: GraphQL queries define webhook payloads
- Versioning: Quarterly releases with 12-month deprecation periods
GraphQL enables third-party apps to fetch exactly needed data, reducing API calls 70%.
Netflix GraphQL Federation
Netflix uses federated GraphQL across 200+ microservices:
- Apollo Federation: Each service owns its domain
- Gateway: Stitches schemas from multiple services
- Caching: Distributed caching with Redis
- Monitoring: Detailed metrics per field resolver
Federation allows frontend teams to query data from any service without backend coordination.
Conclusion
GraphQL provides powerful, flexible APIs when implemented with production best practices. Design schemas around business domains with proper pagination and error handling. Solve N+1 problems with DataLoader, implement field-level authorization with graphql-shield, and optimize performance through caching and query complexity analysis.
Key patterns - cursor-based pagination for consistency, payload types with errors for mutations, DataLoader for batching, HTTP caching with @cacheControl directives, and subscriptions for real-time features - create robust GraphQL APIs that scale to billions of requests.
Start simple with schema-first design, add DataLoader early to prevent N+1 issues, implement authentication before launch, and monitor query complexity to prevent abuse. GraphQL's flexibility comes with complexity - follow these patterns to build maintainable, performant APIs.
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.