Skip to main content

GraphQL

GraphQL is a query language and runtime for APIs that enables clients to request exactly the data they need, nothing more, nothing less. It provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need, and makes it easier to evolve APIs over time.

Overview

GraphQL serves as a flexible alternative to REST APIs, offering a single endpoint that can handle complex data requirements efficiently. It uses a type system to describe your data and allows clients to compose queries that fetch related data in a single request.

Core Concepts

  1. Single Endpoint: All requests go to one URL, typically /graphql
  2. Strongly Typed: Schema defines exactly what's available and what types are expected
  3. Hierarchical: Follows relationships between data naturally
  4. Introspective: Clients can query the schema itself to understand capabilities
  5. Client-Specified Queries: Clients request only the fields they need

Query Types

# Query - Read data
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
publishedAt
}
}
}

# Mutation - Write data
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
publishedAt
author {
name
}
}
}

# Subscription - Real-time data
subscription PostUpdates {
postUpdated {
id
title
content
}
}

When to Use GraphQL

Ideal for:

  • Mobile Applications: Reduce over-fetching and minimize data usage
  • Complex Data Relationships: Need to fetch related data efficiently
  • Multiple Client Types: Different clients need different data shapes
  • Rapid Frontend Development: Enables independent frontend iteration
  • Real-time Features: Built-in subscription support
  • API Evolution: Add fields without versioning

Consider REST When:

  • Simple CRUD Operations: REST might be more straightforward
  • File Uploads: REST with multipart forms is more mature
  • Caching Requirements: HTTP caching is simpler with REST
  • Team Familiarity: REST has broader adoption and tooling
  • Simple Data Requirements: GraphQL complexity may not be justified

Schema Design

Type Definitions

# Basic types
type User {
id: ID!
email: String!
name: String!
avatar: String
createdAt: DateTime!
updatedAt: DateTime!

# Relationships
posts: [Post!]!
comments: [Comment!]!
profile: UserProfile
}

type Post {
id: ID!
title: String!
content: String!
publishedAt: DateTime
status: PostStatus!

# Relationships
author: User!
comments: [Comment!]!
tags: [Tag!]!
}

type Comment {
id: ID!
content: String!
createdAt: DateTime!

# Relationships
author: User!
post: Post!
}

# Enums
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}

# Input types for mutations
input CreatePostInput {
title: String!
content: String!
tagIds: [ID!]
}

input UpdatePostInput {
id: ID!
title: String
content: String
status: PostStatus
tagIds: [ID!]
}

# Custom scalars
scalar DateTime
scalar EmailAddress
scalar URL

Root Types

type Query {
# Single resource queries
user(id: ID!): User
post(id: ID!): Post

# Collection queries with filtering/pagination
users(
first: Int
after: String
filter: UserFilter
orderBy: UserOrderBy
): UserConnection!

posts(
first: Int
after: String
filter: PostFilter
orderBy: PostOrderBy
): PostConnection!

# Search
searchPosts(query: String!): [Post!]!
}

type Mutation {
# User mutations
createUser(input: CreateUserInput!): User!
updateUser(input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!

# Post mutations
createPost(input: CreatePostInput!): Post!
updatePost(input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!

# Comment mutations
createComment(input: CreateCommentInput!): Comment!
updateComment(input: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
}

type Subscription {
# Real-time updates
postCreated: Post!
postUpdated(id: ID): Post!
postDeleted: ID!

commentAdded(postId: ID!): Comment!
userOnline: User!
}

# Connection types 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
}

Implementation Examples

Node.js with Apollo Server

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLScalarType } from 'graphql';
import { DateTimeResolver } from 'graphql-scalars';

// Type definitions
const typeDefs = `#graphql
scalar DateTime

type User {
id: ID!
email: String!
name: String!
createdAt: DateTime!
posts: [Post!]!
}

type Post {
id: ID!
title: String!
content: String!
publishedAt: DateTime
author: User!
comments: [Comment!]!
}

type Comment {
id: ID!
content: String!
createdAt: DateTime!
author: User!
post: Post!
}

input CreatePostInput {
title: String!
content: String!
}

type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}

type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
}

type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
`;

// Resolvers
const resolvers = {
DateTime: DateTimeResolver,

Query: {
users: async () => {
return await User.findAll({
order: [['createdAt', 'DESC']]
});
},

user: async (_, { id }) => {
const user = await User.findByPk(id);
if (!user) {
throw new Error(`User with ID ${id} not found`);
}
return user;
},

posts: async (_, __, { user }) => {
// Apply authorization
if (!user) {
throw new Error('Authentication required');
}

return await Post.findAll({
where: { status: 'PUBLISHED' },
order: [['publishedAt', 'DESC']]
});
},

post: async (_, { id }) => {
return await Post.findByPk(id);
}
},

Mutation: {
createPost: async (_, { input }, { user }) => {
if (!user) {
throw new Error('Authentication required');
}

const post = await Post.create({
...input,
authorId: user.id,
status: 'DRAFT'
});

// Trigger subscription
pubsub.publish('POST_CREATED', { postCreated: post });

return post;
},

updatePost: async (_, { id, input }, { user }) => {
const post = await Post.findByPk(id);

if (!post) {
throw new Error('Post not found');
}

if (post.authorId !== user.id) {
throw new Error('Not authorized to update this post');
}

await post.update(input);
return post;
},

deletePost: async (_, { id }, { user }) => {
const post = await Post.findByPk(id);

if (!post) {
throw new Error('Post not found');
}

if (post.authorId !== user.id) {
throw new Error('Not authorized to delete this post');
}

await post.destroy();
return true;
}
},

Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},

commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
}
}
},

// Field resolvers for relationships
User: {
posts: async (user) => {
return await Post.findAll({
where: { authorId: user.id },
order: [['createdAt', 'DESC']]
});
}
},

Post: {
author: async (post) => {
return await User.findByPk(post.authorId);
},

comments: async (post) => {
return await Comment.findAll({
where: { postId: post.id },
order: [['createdAt', 'ASC']]
});
}
},

Comment: {
author: async (comment) => {
return await User.findByPk(comment.authorId);
},

post: async (comment) => {
return await Post.findByPk(comment.postId);
}
}
};

// Context function for authentication
const context = async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');

if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findByPk(decoded.userId);
return { user };
} catch (error) {
// Invalid token - continue without user
}
}

return {};
};

// Create server
const server = new ApolloServer({
typeDefs,
resolvers,
context
});

// Start server
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 }
});

console.log(`Server ready at ${url}`);

Advanced Patterns

DataLoader for N+1 Problem Prevention

import DataLoader from 'dataloader';

// Create data loaders to batch database queries
const createLoaders = () => ({
userLoader: new DataLoader(async (userIds: string[]) => {
const users = await User.findAll({
where: { id: { [Op.in]: userIds } }
});

// Return users in the same order as requested IDs
return userIds.map(id => users.find(user => user.id === id));
}),

postsByUserLoader: new DataLoader(async (userIds: string[]) => {
const posts = await Post.findAll({
where: { authorId: { [Op.in]: userIds } },
order: [['createdAt', 'DESC']]
});

// Group posts by author ID
return userIds.map(userId =>
posts.filter(post => post.authorId === userId)
);
})
});

// Use in context
const context = async ({ req }) => {
return {
user: await authenticateUser(req),
loaders: createLoaders()
};
};

// Use in resolvers
const resolvers = {
User: {
posts: async (user, _, { loaders }) => {
return await loaders.postsByUserLoader.load(user.id);
}
},

Post: {
author: async (post, _, { loaders }) => {
return await loaders.userLoader.load(post.authorId);
}
}
};

Cursor-based Pagination

const resolvers = {
Query: {
posts: async (_, { first = 10, after }) => {
const limit = Math.min(first, 100); // Cap at 100
const whereClause = after ? {
id: { [Op.gt]: Buffer.from(after, 'base64').toString() }
} : {};

const posts = await Post.findAll({
where: whereClause,
limit: limit + 1, // Fetch one extra to check if there's a next page
order: [['id', 'ASC']]
});

const hasNextPage = posts.length > limit;
const nodes = hasNextPage ? posts.slice(0, -1) : posts;

const edges = nodes.map(node => ({
node,
cursor: Buffer.from(node.id).toString('base64')
}));

return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await Post.count()
};
}
}
};

Error Handling

import { 
AuthenticationError,
ForbiddenError,
UserInputError,
ApolloError
} from '@apollo/server';

const resolvers = {
Mutation: {
createPost: async (_, { input }, { user }) => {
// Authentication check
if (!user) {
throw new AuthenticationError('You must be logged in to create a post');
}

// Input validation
if (!input.title || input.title.trim().length === 0) {
throw new UserInputError('Post title is required', {
invalidArgs: ['title']
});
}

if (input.title.length > 200) {
throw new UserInputError('Post title must be 200 characters or less', {
invalidArgs: ['title']
});
}

try {
const post = await Post.create({
...input,
authorId: user.id
});

return post;
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
throw new UserInputError('A post with this title already exists');
}

// Log unexpected errors
console.error('Failed to create post:', error);
throw new ApolloError('Failed to create post', 'POST_CREATION_FAILED');
}
}
}
};

// Custom error formatting
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// Log server errors
if (!(error.originalError instanceof UserInputError)) {
console.error('GraphQL Error:', error);
}

// Don't expose internal error details in production
if (process.env.NODE_ENV === 'production' && !error.originalError) {
return new Error('Internal server error');
}

return error;
}
});

Subscriptions with Redis

import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const pubsub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL)
});

const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},

commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
}
},

userTyping: {
subscribe: withFilter(
() => pubsub.asyncIterator(['USER_TYPING']),
(payload, variables, context) => {
// Only send typing indicators to users in the same chat
return payload.chatId === variables.chatId &&
payload.userId !== context.user.id;
}
)
}
},

Mutation: {
createComment: async (_, { input }, { user }) => {
const comment = await Comment.create({
...input,
authorId: user.id
});

// Trigger subscription
pubsub.publish(`COMMENT_ADDED_${input.postId}`, {
commentAdded: comment
});

return comment;
}
}
};

Client-Side Usage

React with Apollo Client

import { ApolloClient, InMemoryCache, gql, useQuery, useMutation } from '@apollo/client';

// Apollo Client setup
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache(),
headers: {
authorization: `Bearer ${localStorage.getItem('token')}`
}
});

// Queries
const GET_POSTS = gql`
query GetPosts($first: Int, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
publishedAt
author {
name
avatar
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;

const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
id
title
content
publishedAt
author {
id
name
email
}
comments {
id
content
createdAt
author {
name
}
}
}
}
`;

// Mutations
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
author {
name
}
}
}
`;

// Components
const PostList = () => {
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 }
});

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;

const loadMore = () => {
if (data.posts.pageInfo.hasNextPage) {
fetchMore({
variables: {
after: data.posts.pageInfo.endCursor
}
});
}
};

return (
<div>
{data.posts.edges.map(({ node: post }) => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>By {post.author.name}</p>
<small>{new Date(post.publishedAt).toLocaleDateString()}</small>
</div>
))}

{data.posts.pageInfo.hasNextPage && (
<button onClick={loadMore}>Load More</button>
)}
</div>
);
};

const CreatePost = () => {
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
update(cache, { data: { createPost } }) {
// Update the cache to include the new post
const existingPosts = cache.readQuery({ query: GET_POSTS });

cache.writeQuery({
query: GET_POSTS,
data: {
posts: {
...existingPosts.posts,
edges: [
{ node: createPost, cursor: createPost.id },
...existingPosts.posts.edges
]
}
}
});
}
});

const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);

try {
await createPost({
variables: {
input: {
title: formData.get('title'),
content: formData.get('content')
}
}
});
} catch (error) {
console.error('Error creating post:', error);
}
};

return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Post content" required />
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
};

Subscriptions in React

import { useSubscription } from '@apollo/client';

const POST_CREATED_SUBSCRIPTION = gql`
subscription PostCreated {
postCreated {
id
title
author {
name
}
}
}
`;

const LivePostFeed = () => {
const { data: newPost } = useSubscription(POST_CREATED_SUBSCRIPTION);
const [recentPosts, setRecentPosts] = useState([]);

useEffect(() => {
if (newPost?.postCreated) {
setRecentPosts(prev => [newPost.postCreated, ...prev.slice(0, 9)]);
}
}, [newPost]);

return (
<div>
<h3>Live Posts</h3>
{recentPosts.map(post => (
<div key={post.id}>
<strong>{post.title}</strong> by {post.author.name}
</div>
))}
</div>
);
};

Best Practices

1. Schema Design

# Use descriptive names
type BlogPost { # Not just "Post"
id: ID!
title: String!
content: String!
}

# Use enums for limited values
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}

# Use input types for mutations
input CreatePostInput {
title: String!
content: String!
categoryId: ID
}

# Use connections for pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}

2. Security

// Query depth limiting
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)]
});

// Query complexity analysis
import costAnalysis from 'graphql-cost-analysis';

const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
costAnalysis({
maximumCost: 1000,
introspection: true,
createError: (max, actual) => {
return new Error(`Query cost ${actual} exceeds maximum cost ${max}`);
}
})
]
});

// Field-level authorization
const resolvers = {
User: {
email: (user, _, { user: currentUser }) => {
// Only show email to the user themselves or admins
if (user.id === currentUser?.id || currentUser?.role === 'ADMIN') {
return user.email;
}
return null;
}
}
};

3. Performance Optimization

// Cache at multiple levels
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new RedisCache({
host: 'localhost',
port: 6379
}),
cacheControl: {
defaultMaxAge: 300 // 5 minutes
}
});

// Use cache hints in resolvers
const resolvers = {
Query: {
posts: async (_, __, { cacheControl }) => {
cacheControl.setCacheHint({ maxAge: 600 }); // 10 minutes
return await Post.findAll();
}
}
};

// Implement query whitelisting in production
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: process.env.NODE_ENV === 'production' ?
[require('graphql-query-whitelist')(whitelist)] :
undefined
});

4. Error Handling Best Practices

// Create custom error types
class NotFoundError extends Error {
constructor(resource: string, id: string) {
super(`${resource} with ID ${id} not found`);
this.name = 'NotFoundError';
}
}

// Use specific error codes
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await User.findByPk(id);
if (!user) {
throw new UserInputError('User not found', {
code: 'USER_NOT_FOUND',
id
});
}
return user;
}
}
};

Testing GraphQL APIs

Unit Testing Resolvers

import { createTestClient } from 'apollo-server-testing';
import { ApolloServer } from 'apollo-server';

describe('GraphQL API', () => {
let server: ApolloServer;
let query: any;
let mutate: any;

beforeEach(() => {
server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
user: { id: '1', role: 'USER' },
loaders: createLoaders()
})
});

const testClient = createTestClient(server);
query = testClient.query;
mutate = testClient.mutate;
});

test('should fetch user by ID', async () => {
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;

const response = await query({
query: GET_USER,
variables: { id: '1' }
});

expect(response.errors).toBeUndefined();
expect(response.data.user).toEqual({
id: '1',
name: 'John Doe',
email: 'john@example.com'
});
});

test('should create post', async () => {
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
}
}
`;

const response = await mutate({
mutation: CREATE_POST,
variables: {
input: {
title: 'Test Post',
content: 'This is a test post'
}
}
});

expect(response.errors).toBeUndefined();
expect(response.data.createPost).toMatchObject({
title: 'Test Post',
content: 'This is a test post'
});
});

test('should handle authentication errors', async () => {
// Test without authentication context
const unauthenticatedServer = new ApolloServer({
typeDefs,
resolvers,
context: () => ({}) // No user
});

const { query: unauthQuery } = createTestClient(unauthenticatedServer);

const response = await unauthQuery({
query: CREATE_POST,
variables: {
input: { title: 'Test', content: 'Test' }
}
});

expect(response.errors).toHaveLength(1);
expect(response.errors[0].message).toContain('Authentication required');
});
});

Documentation and Tooling

Schema Documentation

"""
Represents a user in the system
"""
type User {
"Unique identifier for the user"
id: ID!

"User's email address (unique)"
email: String!

"Display name for the user"
name: String!

"Profile image URL"
avatar: String

"Timestamp when the user was created"
createdAt: DateTime!

"All posts authored by this user"
posts(
"Maximum number of posts to return"
first: Int = 10

"Cursor for pagination"
after: String
): PostConnection!
}

"""
Input for creating a new post
"""
input CreatePostInput {
"Title of the post (required, max 200 characters)"
title: String!

"Main content of the post (required)"
content: String!

"Optional list of tag IDs to associate with the post"
tagIds: [ID!]
}

Apollo Studio Integration

// Enable schema reporting
const server = new ApolloServer({
typeDefs,
resolvers,
apollo: {
key: process.env.APOLLO_KEY,
graphRef: process.env.APOLLO_GRAPH_REF
}
});

// Add usage reporting
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';

const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginUsageReporting({
sendVariableValues: { all: true },
sendHeaders: { all: true }
})
]
});

Migration Strategies

From REST to GraphQL

// Gradual migration approach
const resolvers = {
Query: {
// Start by wrapping existing REST endpoints
users: async () => {
const response = await fetch('/api/users');
return response.json();
},

// Gradually move to direct database access
posts: async () => {
return await Post.findAll();
}
}
};

// Apollo Federation for service composition
const server = new ApolloServer({
schema: buildFederatedSchema([
{ typeDefs, resolvers }
])
});