0% read
Skip to main content
GraphQL API Design - Production Best Practices for Scalable Applications

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.

S
StaticBlock Editorial
22 min read

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 =&gt; [u.id, u]));
  return userIds.map(id =&gt; userMap.get(id)!);
}),

profile: new DataLoader&lt;string, Profile&gt;(async (userIds) =&gt; {
  const profiles = await prisma.profile.findMany({
    where: { userId: { in: [...userIds] } }
  });

  const profileMap = new Map(profiles.map(p =&gt; [p.userId, p]));
  return userIds.map(id =&gt; profileMap.get(id)!);
}),

post: new DataLoader&lt;string, Post&gt;(async (postIds) =&gt; {
  const posts = await prisma.post.findMany({
    where: { id: { in: [...postIds] } }
  });

  const postMap = new Map(posts.map(p =&gt; [p.id, p]));
  return postIds.map(id =&gt; 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 () =&gt; {
      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 &lt; 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) =&gt; {
    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) =&gt; {
  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.

Found this helpful? Share it!

Related Articles

S

Written by StaticBlock Editorial

StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.