GraphQL API Design: Building Efficient GraphQL APIs
GraphQL provides a flexible alternative to REST, allowing clients to request exactly the data they need. This guide covers everything from schema design to production best practices.
What is GraphQL?
GraphQL is a query language and runtime for APIs that was developed by Facebook. Unlike REST, where the server defines the response structure, GraphQL lets clients specify exactly what data they need.
REST vs GraphQL
# REST: Multiple endpoints, fixed response structure
GET /users/123 # Returns user + posts
GET /users/123/posts # Returns posts
GET /users/123/followers # Returns followers
# GraphQL: Single endpoint, client defines response
POST /graphql
{
user(id: "123") {
name
email
posts(first: 5) {
title
createdAt
}
followers(first: 10) {
name
avatar
}
}
}
GraphQL Schema Design
The schema is the foundation of your GraphQL API.
Basic Schema Structure
# Define types
type User {
id: ID!
name: String!
email: String!
age: Int
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: DateTime!
published: Boolean!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}
# Define inputs for mutations
input CreateUserInput {
name: String!
email: String!
age: Int
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
published: Boolean = false
}
# Define queries
type Query {
users: [User!]!
user(id: ID!): User
posts(published: Boolean, limit: Int): [Post!]!
post(id: ID!): Post
}
# Define mutations
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: CreateUserInput!): User
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post
}
# Root schema
schema {
query: Query
mutation: Mutation
}
Scalar Types
scalar DateTime
scalar Email
scalar URL
scalar JSON
type User {
id: ID!
email: Email! # Custom scalar for email validation
website: URL # Custom scalar for URL validation
metadata: JSON # Store arbitrary JSON
createdAt: DateTime! # Custom datetime
}
# Custom scalar implementation (JavaScript)
const { GraphQLScalarType, Kind } = require('graphql');
const DateTime = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO 8601 DateTime',
serialize(value) {
return value.toISOString();
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
return null;
}
});
Enums
enum Role {
ADMIN
MODERATOR
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type User {
id: ID!
name: String!
role: Role!
posts(status: PostStatus): [Post!]!
}
Connections (Relay Specification)
# Use Connection pattern for pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection
}
Resolvers
Resolvers are functions that fetch data for each field in your schema.
Basic Resolvers
const resolvers = {
Query: {
users: () => {
return db.users.findAll();
},
user: (parent, { id }, context) => {
return db.users.findById(id);
},
posts: (parent, { published, limit = 10 }, context) => {
const query = published !== undefined ? { published } : {};
return db.posts.find(query).limit(limit);
}
},
Mutation: {
createUser: (parent, { input }, context) => {
return db.users.create(input);
},
deleteUser: (parent, { id }, context) => {
return db.users.delete(id);
}
},
// Type resolvers
User: {
posts: (user, args, context) => {
return db.posts.findByAuthor(user.id);
}
},
Post: {
author: (post, args, context) => {
return db.users.findById(post.authorId);
},
comments: (post, args, context) => {
return db.comments.findByPost(post.id);
}
}
};
Resolver Function Signature
// resolver(parent, args, context, info)
//
// parent - Result from the parent resolver
// args - Arguments passed to the field
// context - Shared context (auth, db, etc.)
// info - AST and execution info
const resolvers = {
Query: {
// Parent is undefined for root queries
user: (parent, { id }, context, info) => {
console.log('Field name:', info.fieldName);
console.log('Auth user:', context.user);
return db.users.findById(id);
}
},
// Nested resolvers receive parent
User: {
// posts receives the user object from the parent resolver
posts: (user, { limit }, context) => {
// user.id comes from parent resolver result
return db.posts.findByAuthor(user.id).limit(limit);
}
}
};
Solving the N+1 Problem
The N+1 problem occurs when you fetch a list of items, then make separate queries for each item’s related data.
The Problem
# Client requests users with their posts
query {
users {
name
posts {
title
}
}
}
# Bad: Generates N+1 queries
# SELECT * FROM users -- 1 query
# SELECT * FROM posts WHERE user_id = 1 -- N queries
# SELECT * FROM posts WHERE user_id = 2
# SELECT * FROM posts WHERE user_id = 3
# ... and so on
Solution 1: DataLoader
const DataLoader = require('dataloader');
const db = require('./db');
// Create loaders
const createLoaders = () => ({
userLoader: new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
// Return results in same order as userIds
return userIds.map(id => users.find(u => u.id === id));
}),
postLoader: new DataLoader(async (userIds) => {
const posts = await db.posts.findByAuthors(userIds);
// Group posts by userId
const postsByUser = posts.reduce((acc, post) => {
(acc[post.userId] ||= []).push(post);
return acc;
}, {});
// Return posts for each userId in order
return userIds.map(id => postsByUser[id] || []);
})
});
// Use in resolvers
const resolvers = {
Query: {
users: () => db.users.findAll()
},
User: {
posts: (user, args, { loaders }) => {
return loaders.postLoader.load(user.id);
}
}
};
// Add to context
app.use('/graphql', (req, res) => {
const loaders = createLoaders();
const context = { user: req.user, loaders };
graphqlHTTP({
schema: schema,
rootValue: resolvers,
context
})(req, res);
});
Solution 2: Resolve in Parent
// Fetch related data in parent resolver
const resolvers = {
Query: {
users: async () => {
const users = await db.users.findAll();
const userIds = users.map(u => u.id);
// Batch load posts for all users
const allPosts = await db.posts.findByAuthors(userIds);
const postsByUser = allPosts.reduce((acc, post) => {
(acc[post.userId] ||= []).push(post);
return acc;
}, {});
// Attach posts to users
return users.map(user => ({
...user,
posts: postsByUser[user.id] || []
}));
}
},
// Now posts resolver just returns pre-fetched data
User: {
posts: (user) => user.posts || []
}
};
Mutations
Mutations modify data on the server.
Basic Mutation
input CreatePostInput {
title: String!
content: String!
authorId: ID!
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post
}
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
// Validate authentication
if (!context.user) {
throw new AuthenticationError('Not authenticated');
}
// Validate input
if (input.title.length < 3) {
throw new UserInputError('Title must be at least 3 characters');
}
// Create post
const post = await db.posts.create({
...input,
authorId: context.user.id
});
return post;
},
updatePost: async (parent, { id, input }, context) => {
// Check ownership
const post = await db.posts.findById(id);
if (!post) {
throw new NotFoundError('Post not found');
}
if (post.authorId !== context.user.id && !context.user.isAdmin) {
throw new ForbiddenError('Not authorized');
}
return db.posts.update(id, input);
},
deletePost: async (parent, { id }, context) => {
const post = await db.posts.findById(id);
if (post.authorId !== context.user.id && !context.user.isAdmin) {
throw new ForbiddenError('Not authorized');
}
return db.posts.delete(id);
}
}
};
Batch Mutations
type Mutation {
# Delete multiple posts
deletePosts(ids: [ID!]!): DeletePostsResult!
# Publish multiple posts
publishPosts(ids: [ID!]!): [Post!]!
}
type DeletePostsResult {
success: Boolean!
deletedCount: Int!
}
Subscriptions
Subscriptions enable real-time updates via WebSockets.
Schema for Subscriptions
type Subscription {
# Subscribe to new posts
postCreated: Post!
# Subscribe to posts in a category
postCreatedInCategory(categoryId: ID!): Post!
# Subscribe to user's notifications
notificationReceived(userId: ID!): Notification!
}
Implementation
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
// Define events
const POST_CREATED = 'POST_CREATED';
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED])
},
postCreatedInCategory: {
subscribe: (parent, { categoryId }) => {
const iterator = pubsub.asyncIterator([POST_CREATED]);
// Filter by category
return {
[Symbol.asyncIterator]() {
return {
async next() {
const event = await iterator.next();
if (event.value.post.categoryId === categoryId) {
return { value: event.value, done: false };
}
return this.next();
}
};
}
};
}
}
}
};
// Publish events from mutations
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
const post = await db.posts.create(input);
// Publish event
pubsub.publish(POST_CREATED, { postCreated: post });
return post;
}
}
};
// Client subscription
const POST_SUBSCRIPTION = gql`
subscription OnPostCreated {
postCreated {
id
title
author {
name
}
}
}
`;
// Client usage (Apollo)
import { useSubscription, gql } from '@apollo/client';
function PostFeed() {
const { data, loading } = useSubscription(POST_SUBSCRIPTION);
if (loading) return <Loading />;
return (
<ul>
{data.postCreated.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Error Handling
# Define custom error types
type Error {
field: String!
message: String!
}
type UserError {
message: String!
code: String!
field: String
}
type MutationResult {
success: Boolean!
errors: [UserError!]!
user: User
}
type Mutation {
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserResult!
}
# GraphQL errors are separate from business errors
type Query {
user(id: ID!): User
}
# Response with both GraphQL errors and data
{
"errors": [
{
"message": "Variable \"$id\" expected value",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"]
}
],
"data": null
}
// Custom error handling
const resolvers = {
Query: {
user: (parent, { id }, context) => {
try {
return db.users.findById(id);
} catch (error) {
throw new ApolloError(
'Database error',
'DATABASE_ERROR',
{ originalError: error }
);
}
}
},
Mutation: {
createUser: (parent, { input }, context) => {
// Validate
const errors = [];
if (!input.email.includes('@')) {
errors.push({ field: 'email', message: 'Invalid email' });
}
if (errors.length > 0) {
return { success: false, errors, user: null };
}
const user = db.users.create(input);
return { success: true, errors: [], user };
}
}
};
Security Best Practices
// 1. Query complexity analysis
const { createComplexityRule } = require('graphql-complexity');
const complexityRule = createComplexityRule({
maximumComplexity: 1000,
variables: variables,
operationName: operationName,
onComplete: (complexity) => {
console.log('Query Complexity:', complexity);
}
});
// 2. Depth limiting
const { createQueryDepthLimit } = require('graphql-max-depth');
const depthLimitRule = createQueryDepthLimit(10);
// 3. Rate limiting
const { RateLimitDirective } = require('graphql-rate-limit-directive');
const schemaWithLimits = gql`
directive @rateLimit(
limit: Int,
duration: Int
) on FIELD_DEFINITION
type Query {
users: [User!]! @rateLimit(limit: 100, duration: 60)
}
`;
// 4. Disable introspection in production
const isProduction = process.env.NODE_ENV === 'production';
const schema = new GraphQLSchema({
query: RootQuery,
mutation: RootMutation,
...(isProduction ? {} : { directives: { ...graphQLBuiltInDirectives } })
});
// 5. CORS configuration
app.use('/graphql', cors({
origin: ['https://yourapp.com'],
credentials: true
}));
Performance Optimization
// 1. Persistent queries (POST /graphql?queryId=...)
const { getOperationalStore } = require('graphql-modules');
const store = getOperationalStore();
// Store query on server
store.storeQuery('GetUserPosts', `
query GetUserPosts($userId: ID!) {
user(id: $userId) {
name
posts { title }
}
}
`);
// Client sends
POST /graphql?queryId=GetUserPosts
{ "variables": { "userId": "123" } }
// 2. Response caching
const { responseCachePlugin } = require('apollo-server-plugin-response-cache');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [responseCachePlugin()]
});
// 3. Persistent connections
const http = require('http');
const { ApolloServer } = require('apollo-server-express');
const server = new ApolloServer({ typeDefs, resolvers });
const httpServer = http.createServer(app);
server.installSubscriptionHandlers(httpServer);
httpServer.listen(4000, () => {
console.log('Server ready at ws://localhost:4000/graphql');
});
External Resources
- GraphQL Official Documentation
- Apollo GraphQL
- GraphQL Voyager - Visualize schemas
- GraphQL Code Generator
- GraphQL Scale Best Practices
Comments