Back to Blog
GraphQLAPIBest PracticesPerformance

GraphQL Best Practices for Production APIs

Build maintainable GraphQL APIs. From schema design to performance optimization to security considerations.

B
Bootspring Team
Engineering
November 5, 2024
5 min read

GraphQL provides flexibility that REST can't match, but that flexibility requires discipline. Here's how to build GraphQL APIs that scale.

Schema Design#

Think in Graphs, Not Endpoints#

1# ❌ REST-like thinking 2type Query { 3 getUser(id: ID!): User 4 getUserPosts(userId: ID!): [Post] 5 getUserComments(userId: ID!): [Comment] 6} 7 8# ✅ Graph thinking 9type Query { 10 user(id: ID!): User 11} 12 13type User { 14 id: ID! 15 name: String! 16 email: String! 17 posts(first: Int, after: String): PostConnection! 18 comments(first: Int, after: String): CommentConnection! 19}

Use Connections for Pagination#

1# Relay-style pagination 2type PostConnection { 3 edges: [PostEdge!]! 4 pageInfo: PageInfo! 5 totalCount: Int! 6} 7 8type PostEdge { 9 node: Post! 10 cursor: String! 11} 12 13type PageInfo { 14 hasNextPage: Boolean! 15 hasPreviousPage: Boolean! 16 startCursor: String 17 endCursor: String 18} 19 20# Query 21query GetUserPosts($userId: ID!, $first: Int!, $after: String) { 22 user(id: $userId) { 23 posts(first: $first, after: $after) { 24 edges { 25 node { 26 id 27 title 28 } 29 cursor 30 } 31 pageInfo { 32 hasNextPage 33 endCursor 34 } 35 } 36 } 37}

Nullable vs Non-Nullable#

1type User { 2 # Required fields - never null 3 id: ID! 4 email: String! 5 createdAt: DateTime! 6 7 # Optional fields - can be null 8 name: String 9 bio: String 10 avatarUrl: String 11 12 # Lists - prefer non-null list with non-null items 13 posts: [Post!]! # Never null, items never null (can be empty) 14 15 # Avoid 16 posts: [Post] # List could be null, items could be null 17}

Resolvers#

DataLoader for N+1 Prevention#

1import DataLoader from 'dataloader'; 2 3// Create loaders per request 4function createLoaders() { 5 return { 6 userLoader: new DataLoader(async (ids: string[]) => { 7 const users = await db.users.findMany({ 8 where: { id: { in: ids } }, 9 }); 10 // Return in same order as requested 11 const userMap = new Map(users.map(u => [u.id, u])); 12 return ids.map(id => userMap.get(id) || null); 13 }), 14 15 postsByUserLoader: new DataLoader(async (userIds: string[]) => { 16 const posts = await db.posts.findMany({ 17 where: { authorId: { in: userIds } }, 18 }); 19 // Group by user 20 const postsByUser = new Map<string, Post[]>(); 21 posts.forEach(post => { 22 const existing = postsByUser.get(post.authorId) || []; 23 postsByUser.set(post.authorId, [...existing, post]); 24 }); 25 return userIds.map(id => postsByUser.get(id) || []); 26 }), 27 }; 28} 29 30// Use in resolvers 31const resolvers = { 32 User: { 33 posts: (user, args, { loaders }) => { 34 return loaders.postsByUserLoader.load(user.id); 35 }, 36 }, 37 Post: { 38 author: (post, args, { loaders }) => { 39 return loaders.userLoader.load(post.authorId); 40 }, 41 }, 42};

Error Handling#

1import { GraphQLError } from 'graphql'; 2 3// Custom error codes 4const resolvers = { 5 Mutation: { 6 createPost: async (_, { input }, { user }) => { 7 if (!user) { 8 throw new GraphQLError('Authentication required', { 9 extensions: { 10 code: 'UNAUTHENTICATED', 11 }, 12 }); 13 } 14 15 if (!input.title) { 16 throw new GraphQLError('Title is required', { 17 extensions: { 18 code: 'BAD_USER_INPUT', 19 field: 'title', 20 }, 21 }); 22 } 23 24 try { 25 return await db.posts.create({ data: input }); 26 } catch (error) { 27 throw new GraphQLError('Failed to create post', { 28 extensions: { 29 code: 'INTERNAL_ERROR', 30 }, 31 }); 32 } 33 }, 34 }, 35};

Performance#

Query Complexity Analysis#

1import { createComplexityLimitRule } from 'graphql-validation-complexity'; 2 3const complexityRule = createComplexityLimitRule(1000, { 4 scalarCost: 1, 5 objectCost: 10, 6 listFactor: 10, 7}); 8 9const server = new ApolloServer({ 10 typeDefs, 11 resolvers, 12 validationRules: [complexityRule], 13});

Query Depth Limiting#

1import depthLimit from 'graphql-depth-limit'; 2 3const server = new ApolloServer({ 4 typeDefs, 5 resolvers, 6 validationRules: [depthLimit(10)], 7});

Persisted Queries#

1// Client sends hash instead of full query 2// Reduces bandwidth and prevents arbitrary queries 3 4import { createHash } from 'crypto'; 5 6const queryMap = new Map<string, string>(); 7 8function registerQuery(query: string): string { 9 const hash = createHash('sha256').update(query).digest('hex'); 10 queryMap.set(hash, query); 11 return hash; 12} 13 14// Server looks up query by hash 15function getQuery(hash: string): string | undefined { 16 return queryMap.get(hash); 17}

Security#

Authentication & Authorization#

1// Context setup 2const server = new ApolloServer({ 3 typeDefs, 4 resolvers, 5 context: async ({ req }) => { 6 const token = req.headers.authorization?.replace('Bearer ', ''); 7 const user = token ? await verifyToken(token) : null; 8 return { user, loaders: createLoaders() }; 9 }, 10}); 11 12// Field-level authorization 13const resolvers = { 14 User: { 15 email: (user, _, { user: currentUser }) => { 16 // Only return email to self or admins 17 if (currentUser?.id === user.id || currentUser?.isAdmin) { 18 return user.email; 19 } 20 return null; 21 }, 22 }, 23}; 24 25// Directive-based authorization 26const typeDefs = gql` 27 directive @auth(requires: Role = USER) on FIELD_DEFINITION 28 29 enum Role { 30 ADMIN 31 USER 32 } 33 34 type Query { 35 users: [User!]! @auth(requires: ADMIN) 36 me: User @auth 37 } 38`;

Input Validation#

1import { z } from 'zod'; 2 3const CreatePostInput = z.object({ 4 title: z.string().min(1).max(200), 5 content: z.string().min(1).max(50000), 6 tags: z.array(z.string()).max(10).optional(), 7}); 8 9const resolvers = { 10 Mutation: { 11 createPost: async (_, { input }) => { 12 const validated = CreatePostInput.parse(input); 13 return db.posts.create({ data: validated }); 14 }, 15 }, 16};

Subscriptions#

1import { PubSub } from 'graphql-subscriptions'; 2 3const pubsub = new PubSub(); 4 5const resolvers = { 6 Subscription: { 7 postCreated: { 8 subscribe: () => pubsub.asyncIterator(['POST_CREATED']), 9 }, 10 commentAdded: { 11 subscribe: (_, { postId }) => { 12 return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]); 13 }, 14 }, 15 }, 16 Mutation: { 17 createPost: async (_, { input }) => { 18 const post = await db.posts.create({ data: input }); 19 pubsub.publish('POST_CREATED', { postCreated: post }); 20 return post; 21 }, 22 }, 23};

Schema Evolution#

1# Deprecate fields instead of removing 2type User { 3 id: ID! 4 name: String! @deprecated(reason: "Use firstName and lastName") 5 firstName: String! 6 lastName: String! 7} 8 9# Add optional fields (backward compatible) 10type Post { 11 id: ID! 12 title: String! 13 content: String! 14 summary: String # New optional field 15}

Conclusion#

GraphQL's flexibility is both its strength and its challenge. Use DataLoader to prevent N+1 queries, implement proper pagination, and secure your API at every level. Design your schema as a graph, not a collection of endpoints.

Start simple and add complexity only when needed. A well-designed GraphQL API can serve many use cases without modification.

Share this article

Help spread the word about Bootspring