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.