REST APIs have dominated the web for two decades. However, as mobile apps and complex SPAs grew, REST's rigid structure led to two major problems: Over-fetching (downloading a 50KB user object just to get the user's name) and Under-fetching (having to make 5 sequential API calls to gather related data). GraphQL solves this by providing a single endpoint that accepts a query string, allowing the client to explicitly dictate the exact shape and depth of the data it needs.
Module 1: The Schema Definition Language (SDL)
Unlike REST, GraphQL is strictly typed. The contract between the frontend and backend is defined using SDL. Every piece of data your API can return must be mapped here.
# The exclamation mark (!) means the field is non-nullable (Required)
type User {
id: ID!
name: String!
email: String!
# A user can have multiple posts. The array itself is required, and items must be Posts.
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
# Query defines the read-only entry points for the client
type Query {
me: User
user(id: ID!): User
feed: [Post!]!
}
# Mutation defines operations that modify data
type Mutation {
createPost(title: String!, content: String!): Post!
}Module 2: Setting up Apollo Server
Apollo Server is the industry-standard GraphQL server for Node.js.
npm install @apollo/server graphqlimport { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
// The context function runs once per request.
// It is used to attach authentication data and database connections.
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const token = req.headers.authorization || '';
const user = await verifyToken(token);
return { user, db: myDatabaseConnection };
},
});
console.log(`🚀 Server ready at ${url}`);Module 3: Writing Resolvers
A Schema is just an empty map. Resolvers are the functions that actually fetch the data from your database or external APIs.
export const resolvers = {
// Top-level Query resolvers
Query: {
// Signature: (parent, args, context, info)
user: async (_, { id }, context) => {
return await context.db.users.findById(id);
},
feed: async (_, __, context) => {
return await context.db.posts.getRecent(20);
}
},
Mutation: {
createPost: async (_, { title, content }, context) => {
if (!context.user) throw new Error("Authentication required");
return await context.db.posts.insert({ title, content, authorId: context.user.id });
}
},
// Field-level Resolvers (The magic of GraphQL)
// If the client asks for a User's 'posts', this function runs.
User: {
posts: async (parent, args, context) => {
// 'parent' is the User object returned by the Query resolver
return await context.db.posts.findByAuthorId(parent.id);
}
},
Post: {
author: async (parent, args, context) => {
return await context.db.users.findById(parent.authorId);
}
}
};Module 4: The Infamous N+1 Problem
GraphQL's greatest strength (field-level resolvers) is also its greatest weakness. Consider a client fetching the feed of 100 posts, and requesting the author for each post.
query {
feed {
title
author { name }
}
}The execution flow:
1. Query.feed runs once (1 SQL query: SELECT * FROM posts LIMIT 100).
2. The Post.author resolver runs 100 times, once for each post (100 SQL queries: SELECT * FROM users WHERE id = ?).
Total Database Queries: 101. This will instantly crash your database under load.
Module 5: The Solution — DataLoaders
DataLoader (created by Facebook) solves this by batching and caching requests within a single tick of the Node.js event loop.
import DataLoader from 'dataloader';
// 1. The Batch Function: Receives an array of IDs [1, 2, 2, 3, 5]
const batchUsers = async (userIds, db) => {
// Make ONE database query: SELECT * FROM users WHERE id IN (1, 2, 3, 5)
const users = await db.users.findMany({ where: { id: { in: userIds } } });
// DataLoader requires the returned array to strictly match the length and order of the input keys.
const userMap = {};
users.forEach(u => userMap[u.id] = u);
return userIds.map(id => userMap[id]);
};
// 2. Initialize DataLoader inside the Context (CRITICAL: Must be per-request)
export const getContext = ({ req }) => {
return {
db,
userLoader: new DataLoader(keys => batchUsers(keys, db))
};
}; Post: {
author: async (parent, args, context) => {
// Instead of hitting the DB directly, we push the ID into the DataLoader.
// DataLoader waits 1 tick, batches all IDs, runs the batch function, and returns the specific user.
return await context.userLoader.load(parent.authorId);
}
}