GraphQL: Schema, Queries, Mutations, and Subscriptions
GraphQL fundamentally changes how you think about API design. Instead of building multiple endpoints that return fixed data structures, you define a typed schema and let clients request exactly what...
Key Insights
- GraphQL replaces multiple REST endpoints with a single endpoint where clients specify exactly what data they need, eliminating over-fetching and under-fetching problems
- The strongly-typed schema serves as a contract between frontend and backend, enabling better tooling, validation, and developer experience than REST’s ad-hoc documentation
- Subscriptions enable real-time features through WebSocket connections, but require careful architecture decisions around pub/sub infrastructure and scaling
Introduction to GraphQL
GraphQL fundamentally changes how you think about API design. Instead of building multiple endpoints that return fixed data structures, you define a typed schema and let clients request exactly what they need. This shifts complexity from the server (maintaining dozens of endpoints) to a more manageable schema definition.
Consider fetching a user’s profile with their recent posts. In REST, you’d either make multiple requests or create a specialized endpoint:
// REST approach: Multiple requests
const user = await fetch('/api/users/123');
const posts = await fetch('/api/users/123/posts?limit=5');
// Or a specialized endpoint
const profile = await fetch('/api/users/123/profile-with-posts');
With GraphQL, you specify your data requirements in a single request:
// GraphQL approach: Single request, exact data
const query = `
query {
user(id: "123") {
name
email
posts(limit: 5) {
title
publishedAt
}
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
The response contains exactly what you requested—no more, no less. This eliminates the over-fetching problem where REST endpoints return unnecessary data and the under-fetching problem where you need multiple requests to assemble a view.
Defining Your GraphQL Schema
Your schema is the contract between clients and server. It defines what data exists, how it’s structured, and what operations are available. Use GraphQL’s Schema Definition Language (SDL) to declare types, fields, and relationships.
Start with scalar types (String, Int, Float, Boolean, ID) and build object types:
// schema.graphql
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
status: PostStatus!
author: User!
publishedAt: String
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
The exclamation mark (!) indicates non-nullable fields. Square brackets define arrays. Enums restrict values to a specific set, providing type safety that REST can’t match.
Every field needs a resolver—a function that returns the field’s value. For simple fields, GraphQL uses default resolvers that return the property from the parent object. For complex fields, write custom resolvers:
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
return await context.db.users.findById(id);
},
posts: async (parent, { status, limit }, context) => {
return await context.db.posts.find({ status }).limit(limit);
}
},
User: {
posts: async (parent, args, context) => {
return await context.db.posts.find({ authorId: parent.id });
}
},
Post: {
author: async (parent, args, context) => {
return await context.db.users.findById(parent.authorId);
}
}
};
Resolvers receive four arguments: the parent object, query arguments, context (shared across all resolvers), and field info. The context typically contains database connections, authentication data, and request-specific information.
Queries: Fetching Data
Queries read data without side effects. Define available queries in your schema’s root Query type:
type Query {
user(id: ID!): User
users(role: UserRole, limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(status: PostStatus, authorId: ID): [Post!]!
}
Client queries can be simple or complex. Request nested data in a single query:
query GetUserWithPosts {
user(id: "123") {
name
email
posts(limit: 3) {
id
title
status
publishedAt
}
}
}
Use query arguments for filtering and pagination:
query GetPublishedPosts {
posts(status: PUBLISHED, limit: 10, offset: 20) {
id
title
author {
name
}
}
}
Fragments reduce repetition when requesting the same fields multiple times:
fragment PostFields on Post {
id
title
content
status
publishedAt
}
query GetUserData {
user(id: "123") {
name
posts {
...PostFields
}
}
recentPosts: posts(limit: 5) {
...PostFields
}
}
Notice the alias recentPosts—it lets you query the same field multiple times with different arguments in a single request.
Mutations: Modifying Data
Mutations modify server-side data. Define them in the root Mutation type:
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): DeleteResponse!
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post!
}
input CreateUserInput {
name: String!
email: String!
role: UserRole!
}
input UpdateUserInput {
name: String
email: String
role: UserRole
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
type DeleteResponse {
success: Boolean!
message: String
}
Input types group mutation arguments and enforce validation. Notice how UpdateUserInput makes all fields optional—you only send what you want to change.
Implement mutation resolvers with proper error handling:
const resolvers = {
Mutation: {
createUser: async (parent, { input }, context) => {
const existingUser = await context.db.users.findByEmail(input.email);
if (existingUser) {
throw new Error('Email already exists');
}
return await context.db.users.create({
...input,
createdAt: new Date().toISOString()
});
},
updateUser: async (parent, { id, input }, context) => {
const user = await context.db.users.findById(id);
if (!user) {
throw new Error('User not found');
}
return await context.db.users.update(id, input);
},
deleteUser: async (parent, { id }, context) => {
const result = await context.db.users.delete(id);
return {
success: result.deletedCount > 0,
message: result.deletedCount > 0 ? 'User deleted' : 'User not found'
};
}
}
};
Client mutations look similar to queries:
mutation CreateNewUser {
createUser(input: {
name: "Jane Doe"
email: "jane@example.com"
role: EDITOR
}) {
id
name
email
createdAt
}
}
Subscriptions: Real-time Updates
Subscriptions enable real-time data flow from server to client over WebSocket connections. They’re perfect for chat applications, live dashboards, and collaborative editing.
Define subscriptions in your schema:
type Subscription {
postCreated(authorId: ID): Post!
postUpdated(id: ID!): Post!
messageAdded(chatId: ID!): Message!
}
type Message {
id: ID!
chatId: ID!
text: String!
sender: User!
createdAt: String!
}
Server-side subscriptions use a pub/sub system. With Apollo Server:
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const POST_CREATED = 'POST_CREATED';
const MESSAGE_ADDED = 'MESSAGE_ADDED';
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
const post = await context.db.posts.create(input);
// Publish to subscribers
pubsub.publish(POST_CREATED, { postCreated: post });
return post;
},
addMessage: async (parent, { chatId, text }, context) => {
const message = await context.db.messages.create({
chatId,
text,
senderId: context.userId,
createdAt: new Date().toISOString()
});
pubsub.publish(MESSAGE_ADDED, {
messageAdded: message,
chatId
});
return message;
}
},
Subscription: {
postCreated: {
subscribe: (parent, { authorId }) => {
if (authorId) {
// Filter by author
return pubsub.asyncIterator([POST_CREATED]);
}
return pubsub.asyncIterator([POST_CREATED]);
}
},
messageAdded: {
subscribe: (parent, { chatId }) =>
pubsub.asyncIterator([MESSAGE_ADDED])
}
}
};
Clients subscribe using WebSocket protocols:
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:4000/graphql'
});
const subscription = client.subscribe({
query: `
subscription OnMessageAdded($chatId: ID!) {
messageAdded(chatId: $chatId) {
id
text
sender {
name
}
createdAt
}
}
`,
variables: { chatId: '456' }
}, {
next: (data) => {
console.log('New message:', data.messageAdded);
},
error: (error) => {
console.error('Subscription error:', error);
},
complete: () => {
console.log('Subscription completed');
}
});
Best Practices and Tooling
The N+1 query problem kills GraphQL performance. When fetching users and their posts, naive resolvers make one query for users, then one query per user for posts:
// BAD: N+1 queries
const resolvers = {
User: {
posts: async (user, args, context) => {
// Called once per user!
return await context.db.posts.find({ authorId: user.id });
}
}
};
Use DataLoader to batch and cache database calls:
const DataLoader = require('dataloader');
const createLoaders = (db) => ({
postsByAuthorId: new DataLoader(async (authorIds) => {
const posts = await db.posts.find({
authorId: { $in: authorIds }
});
// Group posts by authorId
const postsByAuthor = {};
authorIds.forEach(id => postsByAuthor[id] = []);
posts.forEach(post => {
postsByAuthor[post.authorId].push(post);
});
return authorIds.map(id => postsByAuthor[id]);
})
});
// In your server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
db,
loaders: createLoaders(db)
})
});
// In resolvers
const resolvers = {
User: {
posts: async (user, args, context) => {
return await context.loaders.postsByAuthorId.load(user.id);
}
}
};
DataLoader batches all load() calls in a single execution tick into one database query.
For error handling, throw descriptive errors in resolvers and use extensions for error codes:
const { ApolloError } = require('apollo-server');
const resolvers = {
Mutation: {
updatePost: async (parent, { id, input }, context) => {
const post = await context.db.posts.findById(id);
if (!post) {
throw new ApolloError('Post not found', 'NOT_FOUND');
}
if (post.authorId !== context.userId) {
throw new ApolloError('Not authorized', 'FORBIDDEN');
}
return await context.db.posts.update(id, input);
}
}
};
GraphQL isn’t a silver bullet. It adds complexity around caching (no HTTP cache by default), requires more sophisticated backend infrastructure, and can expose performance issues if clients request deeply nested data. Use it when you need flexible data fetching, have multiple clients with different data needs, or want strong typing and better developer experience. Stick with REST for simple CRUD APIs or when HTTP caching is critical.