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:

  1. Design schemas for clients, not databases
  2. Use DataLoader to prevent N+1 queries
  3. Implement proper authentication and authorization
  4. Limit query complexity
  5. Cache aggressively
  6. Monitor performance
  7. 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.