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

CriterionGraphQLREST
Over/under-fetchingNone — client specifies exact fieldsCommon — endpoint returns fixed shape
CachingHard — single POST endpoint, custom CDN logicEasy — HTTP GET cache by URL
N+1 problemBuilt-in risk, requires DataLoaderLess common (endpoints are more specific)
Type safetyFirst-class schema, auto-generated clientsRequires OpenAPI + codegen
Learning curveHigher (schema, resolvers, DataLoader)Lower (HTTP methods + JSON)
VersioningEvolve schema with deprecationsURL versioning (/v1/, /v2/)
Best forComplex UIs, mobile, multiple clientsSimple APIs, webhooks, public APIs