Why GraphQL Exists and When to Choose It
GraphQL was created by Facebook (Meta) to solve a specific problem at mobile scale: REST APIs returned too much data (over-fetching) or required multiple round-trips (under-fetching). With GraphQL, the client specifies exactly which fields it needs in a single query. This is compelling for mobile apps and complex UIs, but GraphQL introduces its own complexity — particularly around caching, security, and the N+1 problem. Understanding the trade-offs before committing is essential.
Schema Design: Types, Queries, Mutations, Subscriptions
# schema.graphql — SDL (Schema Definition Language)
# Object types define your data model
type User {
id: ID! # "!" means non-nullable (required)
email: String!
name: String!
role: UserRole!
posts: [Post!]! # Always use [T!]! for lists — no null items, no null list
createdAt: String!
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
type Post {
id: ID!
title: String!
body: String!
published: Boolean!
author: User!
tags: [String!]!
publishedAt: String
viewCount: Int!
}
# Query type — all read operations
type Query {
me: User # Returns null if unauthenticated
user(id: ID!): User # Returns null if not found
posts(
cursor: String # Cursor-based pagination
limit: Int = 20
filter: PostFilter
): PostConnection!
}
input PostFilter {
published: Boolean
authorId: ID
tag: String
}
# Connection pattern for pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Mutation type — all write operations
type Mutation {
createPost(input: CreatePostInput!): PostResult!
updatePost(id: ID!, input: UpdatePostInput!): PostResult!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): PostResult!
}
# Input types for mutations — separate from object types
input CreatePostInput {
title: String!
body: String!
tags: [String!]!
}
input UpdatePostInput {
title: String
body: String
tags: [String!]
}
# Union result type for mutations — handles success and errors
union PostResult = Post | ValidationError | AuthorizationError
type ValidationError { message: String!; field: String }
type AuthorizationError { message: String! }
# Subscription type — real-time updates via WebSocket
type Subscription {
postPublished: Post!
commentAdded(postId: ID!): Comment!
}
Resolvers: The Execution Engine
// resolvers/index.ts (Apollo Server example)
import { GraphQLContext } from './context'
import { PostDataLoader } from './dataloaders'
export const resolvers = {
Query: {
me: (_: unknown, __: unknown, ctx: GraphQLContext) => {
if (!ctx.user) return null
return ctx.db.users.findOne({ id: ctx.user.id })
},
posts: async (_: unknown, args: { cursor?: string; limit: number; filter?: PostFilter }, ctx: GraphQLContext) => {
return ctx.db.posts.findWithCursor({ cursor: args.cursor, limit: args.limit, ...args.filter })
},
},
Mutation: {
createPost: async (_: unknown, { input }: { input: CreatePostInput }, ctx: GraphQLContext) => {
if (!ctx.user) return { __typename: 'AuthorizationError', message: 'Unauthenticated' }
try {
const post = await ctx.db.posts.create({ ...input, authorId: ctx.user.id })
return { __typename: 'Post', ...post }
} catch (err) {
if (err instanceof ValidationError) {
return { __typename: 'ValidationError', message: err.message, field: err.field }
}
throw err
}
},
},
// Field resolver for User.posts — this triggers the N+1 problem if unoptimized
User: {
posts: (user: User, _: unknown, ctx: GraphQLContext) => {
// Without DataLoader: N queries for N users
return ctx.db.posts.findMany({ authorId: user.id })
// With DataLoader: batched into 1 query for all users
return ctx.loaders.postsByAuthor.load(user.id)
},
},
}
Solving the N+1 Problem with DataLoader
The N+1 problem is particularly acute in GraphQL because nested field resolvers fire independently. A query for 10 users with their posts triggers 10 separate database queries for posts — one per user. DataLoader solves this by batching all those individual loads into a single query.
import DataLoader from 'dataloader'
import { db } from './db'
// A DataLoader takes a batch function: given N keys, return N values (in the same order)
export const createPostsByAuthorLoader = () => new DataLoader<string, Post[]>(
async (authorIds: readonly string[]) => {
// One query for all requested author IDs
const posts = await db.posts.findMany({
where: { authorId: { in: [...authorIds] } }
})
// Group by authorId — DataLoader requires results in same order as keys
const postsByAuthor = new Map<string, Post[]>()
authorIds.forEach(id => postsByAuthor.set(id, []))
posts.forEach(post => {
postsByAuthor.get(post.authorId)!.push(post)
})
return authorIds.map(id => postsByAuthor.get(id) ?? [])
}
)
// Create fresh DataLoaders per request (in your context factory)
// Loaders cache within a single request — creating per-request prevents cross-request data leaks
export function createContext({ req }): GraphQLContext {
return {
user: extractUser(req),
db,
loaders: {
postsByAuthor: createPostsByAuthorLoader(),
// Create one loader per relationship you need to batch
}
}
}
Authentication and Authorization via Context
// context.ts — built fresh for every request
import jwt from 'jsonwebtoken'
import { Request } from 'express'
export async function createContext({ req }: { req: Request }): Promise<GraphQLContext> {
const token = req.headers.authorization?.replace('Bearer ', '')
let user = null
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ['HS256'] })
user = await db.users.findOne({ id: (payload as any).userId })
} catch {
// Invalid token — user remains null (unauthenticated)
}
}
return { user, db, loaders: createLoaders() }
}
// In a resolver: check ctx.user before returning sensitive data
Query: {
adminDashboard: (_: unknown, __: unknown, ctx: GraphQLContext) => {
if (!ctx.user) throw new GraphQLError('Unauthenticated', { extensions: { code: 'UNAUTHENTICATED' } })
if (ctx.user.role !== 'ADMIN') throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } })
return ctx.db.getAdminStats()
}
}
GraphQL vs REST: Trade-offs Table
| Criterion | GraphQL | REST |
|---|---|---|
| Over/under-fetching | None — client specifies exact fields | Common — endpoint returns fixed shape |
| Caching | Hard — single POST endpoint, custom CDN logic | Easy — HTTP GET cache by URL |
| N+1 problem | Built-in risk, requires DataLoader | Less common (endpoints are more specific) |
| Type safety | First-class schema, auto-generated clients | Requires OpenAPI + codegen |
| Learning curve | Higher (schema, resolvers, DataLoader) | Lower (HTTP methods + JSON) |
| Versioning | Evolve schema with deprecations | URL versioning (/v1/, /v2/) |
| Best for | Complex UIs, mobile, multiple clients | Simple APIs, webhooks, public APIs |