- Published on
GraphQL Complete Guide 2025: Beyond REST — From Schema Design to Federation and Performance Optimization
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Introduction: Why GraphQL?
Since Facebook open-sourced GraphQL in 2015, it has become a standard choice for API design. Major tech companies including GitHub, Shopify, Netflix, and Airbnb have adopted it, demonstrating a new paradigm that transcends the limitations of REST.
This article covers everything from GraphQL basics to microservice integration with Federation and performance optimization — all practical knowledge you can apply immediately.
1. REST vs GraphQL: Fundamental Differences
REST Limitations
REST APIs have been the web API standard for years, but they suffer from several persistent issues.
Over-fetching: Responses include data you don't need. You only need a username but get the entire profile.
Under-fetching: You need multiple endpoint calls to render a single screen. User info + posts + comments = 3 requests.
Endpoint management: Every time client requirements change, you must create new endpoints or modify existing ones.
How GraphQL Solves These
# Get exactly the data you need in a single request
query GetUserWithPosts {
user(id: "123") {
name
email
posts(first: 5) {
title
commentCount
}
}
}
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Separate URL per resource | Single endpoint |
| Data amount | Server decides | Client decides |
| Versioning | v1, v2 URL branching | Schema evolution (deprecation) |
| Type system | Augmented with Swagger/OpenAPI | Built-in type system (SDL) |
| Real-time | Separate WebSocket impl | Built-in Subscriptions |
| Caching | Easy HTTP cache utilization | Requires separate strategy |
| Learning curve | Low | Medium to high |
Core Difference: Declarative Data Fetching
REST specifies WHERE to get data from, while GraphQL declares WHAT data to get.
2. Schema Design (Schema Definition Language)
Basic Type System
The GraphQL schema serves as the API contract. You define types using SDL.
# Scalar types: String, Int, Float, Boolean, ID
# ! means Non-null
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!]!
tags: [String!]
publishedAt: DateTime
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
Query, Mutation, Subscription
GraphQL has three operation types.
type Query {
# Data retrieval
user(id: ID!): User
users(filter: UserFilter, pagination: PaginationInput): UserConnection!
post(id: ID!): Post
searchPosts(query: String!): [Post!]!
}
type Mutation {
# Data modification
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
likePost(postId: ID!): Post!
}
type Subscription {
# Real-time updates
postCreated: Post!
commentAdded(postId: ID!): Comment!
userStatusChanged(userId: ID!): User!
}
Input Types and Enums
input CreateUserInput {
name: String!
email: String!
age: Int
role: UserRole = USER
}
input UserFilter {
role: UserRole
nameContains: String
createdAfter: DateTime
}
input PaginationInput {
first: Int = 20
after: String
}
enum UserRole {
ADMIN
EDITOR
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Relay-Style Connections (Cursor-based 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
}
3. Resolver Patterns
Basic Resolver Structure
Resolvers connect each schema field to actual data.
// Resolver's 4 arguments: parent, args, context, info
const resolvers = {
Query: {
user: async (parent, args, context, info) => {
const { id } = args;
const { dataSources, currentUser } = context;
return dataSources.userAPI.getUser(id);
},
users: async (_, { filter, pagination }, { dataSources }) => {
return dataSources.userAPI.getUsers(filter, pagination);
},
searchPosts: async (_, { query }, { dataSources }) => {
return dataSources.postAPI.search(query);
},
},
Mutation: {
createUser: async (_, { input }, { dataSources, currentUser }) => {
if (!currentUser?.isAdmin) {
throw new ForbiddenError('Admin access required');
}
return dataSources.userAPI.createUser(input);
},
likePost: async (_, { postId }, { dataSources, currentUser }) => {
if (!currentUser) {
throw new AuthenticationError('Login required');
}
return dataSources.postAPI.likePost(postId, currentUser.id);
},
},
// Type resolver - resolving relationship fields
User: {
posts: async (user, _, { dataSources }) => {
return dataSources.postAPI.getPostsByAuthor(user.id);
},
},
Post: {
author: async (post, _, { dataSources }) => {
return dataSources.userAPI.getUser(post.authorId);
},
comments: async (post, _, { dataSources }) => {
return dataSources.commentAPI.getByPost(post.id);
},
},
};
Context Setup
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use(
'/graphql',
expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization || '';
const currentUser = await getUserFromToken(token);
return {
currentUser,
dataSources: {
userAPI: new UserDataSource(),
postAPI: new PostDataSource(),
commentAPI: new CommentDataSource(),
},
};
},
})
);
4. The N+1 Problem and DataLoader
What Is the N+1 Problem?
When you fetch a list of posts and then fetch each post's author individually, the N+1 problem occurs.
Query: posts(first: 10) -> 1 query (10 posts)
Post.author (post 1) -> query 2
Post.author (post 2) -> query 3
...
Post.author (post 10) -> query 11
That's 11 total database queries.
Solving with DataLoader
DataLoader batches requests within the same event loop tick.
import DataLoader from 'dataloader';
// Batch function: receives array of IDs, returns results in same order
const userLoader = new DataLoader(async (userIds: string[]) => {
const users = await db.users.findMany({
where: { id: { in: userIds } },
});
// Map results in the order of requested IDs
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
});
// Usage in resolvers
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
},
},
};
Result: 11 queries reduced to 2.
Query: posts(first: 10) -> 1 query
DataLoader batch: users([1,2,3,...,10]) -> 2 queries (batched)
DataLoader Context Pattern
// Create new DataLoader instances per request
function createLoaders() {
return {
userLoader: new DataLoader((ids) => batchGetUsers(ids)),
postLoader: new DataLoader((ids) => batchGetPosts(ids)),
commentLoader: new DataLoader((ids) => batchGetComments(ids)),
// Loader for 1:N relationships
postsByAuthorLoader: new DataLoader(async (authorIds) => {
const posts = await db.posts.findMany({
where: { authorId: { in: authorIds } },
});
const grouped = groupBy(posts, 'authorId');
return authorIds.map((id) => grouped[id] || []);
}),
};
}
// Create fresh loaders per request in context
context: async ({ req }) => ({
currentUser: await getUser(req),
loaders: createLoaders(),
}),
5. Apollo Server and Client
Apollo Server Setup
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const server = new ApolloServer({
schema,
plugins: [
// Disable introspection in production
ApolloServerPluginLandingPageDisabled(),
// Usage reporting
ApolloServerPluginUsageReporting(),
],
formatError: (formattedError, error) => {
// Error formatting - hide internal info
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error' };
}
return formattedError;
},
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({
currentUser: await authenticateUser(req),
loaders: createLoaders(),
}),
});
console.log(`Server ready at ${url}`);
Apollo Client (React)
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
useMutation,
gql,
} from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// Relay-style cursor pagination merge
keyArgs: ['filter'],
merge(existing, incoming, { args }) {
if (!args?.after) return incoming;
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
// Usage in React components
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
author {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostList() {
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error occurred</p>;
return (
<div>
{data.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data.posts.pageInfo.hasNextPage && (
<button
onClick={() =>
fetchMore({
variables: { after: data.posts.pageInfo.endCursor },
})
}
>
Load More
</button>
)}
</div>
);
}
6. Apollo Federation v2
What Is Federation?
Apollo Federation combines multiple GraphQL services (Subgraphs) into a single unified API (Supergraph). Each team develops services independently while providing clients with a single GraphQL endpoint.
Subgraph Design
# --- Users Subgraph ---
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
role: UserRole!
}
type Query {
user(id: ID!): User
me: User
}
# --- Posts Subgraph ---
type Post @key(fields: "id") {
id: ID!
title: String!
content: String!
author: User!
publishedAt: DateTime
}
# Extending User type from another subgraph
type User @key(fields: "id") {
id: ID!
posts: [Post!]! # Field added by Posts service
postCount: Int!
}
type Query {
post(id: ID!): Post
feed(first: Int = 20, after: String): PostConnection!
}
# --- Reviews Subgraph ---
type Review @key(fields: "id") {
id: ID!
rating: Int!
comment: String
author: User!
post: Post!
}
type Post @key(fields: "id") {
id: ID!
reviews: [Review!]! # Added by Reviews service
averageRating: Float
}
Router Configuration (Apollo Router)
# router.yaml
supergraph:
listen: 0.0.0.0:4000
subgraphs:
users:
routing_url: http://users-service:4001/graphql
posts:
routing_url: http://posts-service:4002/graphql
reviews:
routing_url: http://reviews-service:4003/graphql
headers:
all:
request:
- propagate:
named: authorization
Subgraph Reference Resolver
// Posts Subgraph
const resolvers = {
User: {
// Called when Federation needs to resolve a User
__resolveReference: async (user, { loaders }) => {
// Only user.id is passed
return loaders.postsByUserLoader.load(user.id);
},
posts: async (user, _, { dataSources }) => {
return dataSources.postAPI.getByAuthor(user.id);
},
postCount: async (user, _, { dataSources }) => {
return dataSources.postAPI.countByAuthor(user.id);
},
},
};
7. Subscriptions (Real-Time Features)
Server Setup
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
useServer(
{
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
const user = await authenticateUser(token);
return { currentUser: user, pubsub };
},
},
wsServer
);
// Resolvers
const resolvers = {
Subscription: {
commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
},
},
postCreated: {
subscribe: () => pubsub.asyncIterator('POST_CREATED'),
},
},
Mutation: {
createComment: async (_, { input }, { pubsub, currentUser }) => {
const comment = await db.comments.create({
data: { ...input, authorId: currentUser.id },
});
// Notify subscribers
pubsub.publish(`COMMENT_ADDED_${input.postId}`, {
commentAdded: comment,
});
return comment;
},
},
};
Client Subscription
import { useSubscription, gql } from '@apollo/client';
const COMMENT_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
body
author {
name
}
createdAt
}
}
`;
function CommentFeed({ postId }) {
const { data, loading } = useSubscription(COMMENT_SUBSCRIPTION, {
variables: { postId },
});
// data updates automatically with each new comment
if (data?.commentAdded) {
return <NewComment comment={data.commentAdded} />;
}
return null;
}
8. Authentication and Authorization
Directive-Based Authorization
# Custom directive definition
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role {
ADMIN
EDITOR
USER
GUEST
}
type Query {
publicPosts: [Post!]!
me: User! @auth
adminDashboard: Dashboard! @auth(requires: ADMIN)
userManagement: [User!]! @auth(requires: ADMIN)
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
updateProfile(input: UpdateProfileInput!): User! @auth
}
Directive Implementation
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const requiredRole = authDirective.requires || 'USER';
const originalResolve = fieldConfig.resolve || defaultFieldResolver;
fieldConfig.resolve = async (source, args, context, info) => {
const { currentUser } = context;
if (!currentUser) {
throw new AuthenticationError('Not authenticated');
}
if (!hasRole(currentUser, requiredRole)) {
throw new ForbiddenError('Insufficient permissions');
}
return originalResolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
9. Performance Optimization
Query Complexity Limiting
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
formatErrorMessage: (cost) =>
`Query too complex: cost ${cost} exceeds maximum 1000`,
}),
],
});
Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)],
});
Persisted Queries (APQ)
// Server
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
const server = new ApolloServer({
schema,
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 60 })],
persistedQueries: {
cache: new KeyValueCache(), // Redis, etc.
},
});
// Client
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({
link: link.concat(httpLink),
cache: new InMemoryCache(),
});
Cache Control
type Query {
posts: [Post!]! @cacheControl(maxAge: 300)
user(id: ID!): User @cacheControl(maxAge: 60)
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Post @cacheControl(maxAge: 600) {
id: ID!
title: String!
author: User! @cacheControl(inheritMaxAge: true)
viewCount: Int! @cacheControl(maxAge: 30)
}
10. Caching Strategies
Server-Side Caching
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
const server = new ApolloServer({
schema,
cache: new KeyvAdapter(new Keyv({ store: new KeyvRedis('redis://localhost:6379') })),
});
// DataSource-level caching
class CachedUserAPI {
private cache: RedisClient;
private ttl = 300; // 5 minutes
async getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`;
const cached = await this.cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findUnique({ where: { id } });
await this.cache.set(cacheKey, JSON.stringify(user), 'EX', this.ttl);
return user;
}
}
Client-Side Caching (Apollo Cache)
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
fields: {
fullName: {
read(_, { readField }) {
const first = readField('firstName');
const last = readField('lastName');
return `${first} ${last}`;
},
},
},
},
Post: {
keyFields: ['id'],
},
Query: {
fields: {
post(_, { args, toReference }) {
return toReference({ __typename: 'Post', id: args?.id });
},
},
},
},
});
11. Testing
Resolver Unit Tests
import { describe, it, expect, vi } from 'vitest';
describe('User Resolvers', () => {
it('should return user by id', async () => {
const mockUser = { id: '1', name: 'Test User', email: 'test@example.com' };
const context = {
dataSources: {
userAPI: { getUser: vi.fn().mockResolvedValue(mockUser) },
},
};
const result = await resolvers.Query.user(null, { id: '1' }, context, null);
expect(result).toEqual(mockUser);
expect(context.dataSources.userAPI.getUser).toHaveBeenCalledWith('1');
});
it('should throw if not admin for createUser', async () => {
const context = {
currentUser: { role: 'USER' },
dataSources: { userAPI: { createUser: vi.fn() } },
};
await expect(
resolvers.Mutation.createUser(null, { input: {} }, context, null)
).rejects.toThrow('Admin access required');
});
});
Integration Tests
import { ApolloServer } from '@apollo/server';
describe('GraphQL Integration', () => {
let server: ApolloServer;
beforeAll(async () => {
server = new ApolloServer({ typeDefs, resolvers });
});
it('should execute GetUser query', async () => {
const response = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: '1' },
});
expect(response.body.kind).toBe('single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.user).toBeDefined();
});
});
12. When NOT to Use GraphQL
GraphQL is not the right fit for every situation.
REST is better when:
- Simple CRUD APIs with clear resource structures
- File upload/download-centric services
- Public APIs where HTTP caching is critical
- Small teams that cannot absorb the learning curve
- Server-to-server internal communication (gRPC is more suitable)
GraphQL shines when:
- Multiple clients (web, mobile, TV) share the same API
- Frontend teams need fast iteration
- Complex data relationships (social networks, e-commerce)
- Unifying microservices under a single API
- Applications requiring real-time features
13. Interview Questions: 15 Essential Topics
Basics (1-5)
Q1. Explain the core differences between GraphQL and REST.
GraphQL is a query language where clients specify the exact structure of data they need. While REST returns fixed responses from server-defined endpoints, GraphQL lets clients selectively request only desired fields from a single endpoint.
Q2. Explain over-fetching and under-fetching.
Over-fetching occurs when an API returns more data than the client needs. Under-fetching occurs when the client must make multiple API calls to get all the data required for a single view.
Q3. Describe the main types in SDL (Schema Definition Language).
Object types (custom field definitions), Scalar types (String, Int, Float, Boolean, ID), Enums (enumerated values), Input (input objects), Interface (common field definitions), and Union (one of several types).
Q4. Explain the differences between Query, Mutation, and Subscription.
Query is for data retrieval (similar to GET), Mutation is for data modification (similar to POST/PUT/DELETE), and Subscription is for real-time event streams via WebSocket.
Q5. Explain Non-null (!) and list type combinations.
[String] is a nullable array of nullable strings, [String!] is a nullable array of non-null strings, [String!]! is a non-null array of non-null strings.
Intermediate (6-10)
Q6. What is the N+1 problem and how do you solve it?
When fetching a parent list and then individually querying each item's related data. DataLoader collects requests within the same tick and executes them as a single batch query.
Q7. Explain the core concepts of Apollo Federation.
Multiple Subgraphs are combined by a Router into a single Supergraph. @key identifies entities, and __resolveReference resolves entities from other services.
Q8. How do you implement authentication and authorization in GraphQL?
Authentication verifies tokens in context to set currentUser. Authorization uses custom directives (@auth) or role-based checks within resolvers.
Q9. What are the advantages of Persisted Queries?
They reduce network payload by sending query hashes instead of strings, and enhance security by only executing server-registered queries.
Q10. What's the difference between Cursor-based and Offset-based Pagination?
Offset pagination uses page numbers and can cause data duplication/loss during insertions/deletions. Cursor pagination uses opaque cursors based on specific positions and remains stable even with real-time data changes.
Advanced (11-15)
Q11. Explain Query Complexity and Depth Limiting.
Query Complexity assigns costs to fields and rejects queries exceeding the total cost threshold. Depth Limiting restricts nesting depth to prevent malicious recursive queries.
Q12. Explain Entity and Reference Resolver behavior in Federation.
An Entity is a type identified by @key. When the Router needs data from another subgraph, it calls __resolveReference to resolve the full entity using only key fields.
Q13. Why is GraphQL caching harder than REST?
REST directly leverages URL-based HTTP caching, but GraphQL uses a single endpoint with POST requests so HTTP caching cannot be used. Separate strategies like APQ, CDN cache key configuration, and normalized client caches are needed.
Q14. How do you scale Subscriptions in production?
Use Redis PubSub or Kafka instead of in-memory PubSub. Share messages across server instances, and configure sticky sessions or a dedicated WebSocket gateway for load balancing WebSocket connections.
Q15. Explain schema evolution strategy.
Use @deprecated directive to mark fields for deprecation and add new fields. Monitor client usage before removing fields, and verify change compatibility with a schema registry. This is more flexible than REST URL versioning.
14. Practice Quiz: 5 Questions
Q1. Which type represents "a non-null array of non-null strings" in GraphQL?
Answer: [String!]!
[String!]! means both the array itself is non-null (!) and each element is non-null (String!). [String] means both the array and elements can be null.
Q2. Explain the core operating principle of DataLoader.
Answer: It collects .load() calls within the same event loop tick and executes the batch function once.
DataLoader queues requests and when the current tick ends, passes all IDs to a single batch function. This reduces N+1 queries to 1+1 queries.
Q3. What is the role of the @key directive in Apollo Federation?
Answer: It specifies the fields that uniquely identify an Entity.
@key(fields: "id") declares the type as a Federation entity and enables the Router to resolve this entity from other subgraphs using the id field.
Q4. What protocol does GraphQL Subscription use internally?
Answer: WebSocket
Subscriptions use bidirectional connections via WebSocket rather than HTTP request-response. The graphql-ws protocol pushes real-time events from server to client.
Q5. Explain the security benefit of Persisted Queries.
Answer: Only server-registered queries can be executed, preventing arbitrary (malicious) query execution.
APQ (Automatic Persisted Queries) sends SHA256 hashes of queries. The server only executes queries mapped to known hashes, preventing attackers from sending arbitrary complex queries to consume server resources.
References
- GraphQL Official Documentation
- Apollo GraphQL Documentation
- Apollo Federation Guide
- GraphQL Spec (2021)
- DataLoader GitHub
- graphql-ws Protocol
- How to GraphQL - Tutorial
- Relay Official Documentation
- GraphQL Best Practices
- Production-Ready GraphQL (Marc-Andre Giroux)
- The Guild - GraphQL Tools
- GraphQL Security (OWASP)
- Apollo Client React Guide
- Netflix: GraphQL at Scale
- Shopify: GraphQL Design Tutorial
- GitHub GraphQL API