What is GraphQL?
Understanding GraphQL from its core — what it is, why it exists, and how it thinks differently from REST.
The Definition
GraphQL is a query language for APIs and a runtime for executing those queries against your data. It was developed internally at Facebook in 2012 and open-sourced in 2015.
Think of it this way: REST gives you fixed endpoints that return fixed data structures. GraphQL gives you a single endpoint where the client says exactly what data it needs, and the server returns exactly that — nothing more, nothing less.
GraphQL is NOT a database query language (like SQL). It sits between your client and your data sources (databases, REST APIs, microservices, etc.) as an API layer.
React, Mobile, etc.
Single /graphql endpoint
Fetch data logic
DB, REST, gRPC, etc.
Why Was GraphQL Created?
Facebook created GraphQL to solve real problems they hit with REST APIs in their mobile app:
- Over-fetching: REST endpoints return all fields even when the client only needs 2-3. This wastes bandwidth, which is critical on mobile.
- Under-fetching: To build one screen, the client often needs to hit 3-5 different REST endpoints. Each round trip adds latency.
- Rapid iteration: Every new feature required backend changes to create or modify endpoints. GraphQL lets the frontend evolve independently.
- Type safety: REST has no built-in contract. GraphQL has a strongly-typed schema that acts as a contract between frontend and backend.
How GraphQL Works — The Mental Model
GraphQL works on a simple principle: the client describes the shape of the data it needs, and the server returns data in exactly that shape.
Client Sends This Query
// "Hey server, give me user 1's name
// and their posts' titles"
{
user(id: 1) {
name
posts {
title
}
}
}
Server Returns Exactly This
{
"data": {
"user": {
"name": "Yatin Dora",
"posts": [
{ "title": "Learning GraphQL" },
{ "title": "React + GraphQL" }
]
}
}
}
Notice: the response mirrors the query structure exactly. The client asked for name and posts.title, and that's exactly what it got. No extra fields. No missing fields.
REST vs GraphQL
A deep comparison to understand when to use which and why.
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints: /users, /posts, /comments | Single endpoint: /graphql |
| Data Fetching | Server decides what data to return | Client decides what data it needs |
| Over-fetching | Common — you get all fields | Eliminated — you get only what you request |
| Under-fetching | Common — need multiple calls | Eliminated — one query gets everything |
| Versioning | Often needs v1, v2, v3 | Evolves via field deprecation, no versioning needed |
| Caching | Easy — HTTP caching on URLs | Harder — needs client-side normalized cache |
| Error Handling | HTTP status codes (404, 500, etc.) | Always returns 200, errors in response body |
| File Upload | Native multipart support | Needs workarounds (multipart spec or presigned URLs) |
| Type System | None built-in (OpenAPI/Swagger is add-on) | Strong, built-in schema type system |
| Real-time | WebSockets (separate protocol) | Subscriptions (part of the spec) |
| Learning Curve | Low — most devs know HTTP methods | Medium — new query language, schema concepts |
The Over-fetching / Under-fetching Problem Visualized
Scenario: Build a user profile page that shows user name, avatar, and their 5 latest posts with comment counts.
REST Approach (3 round trips)
// Call 1: Get user (over-fetches email, address,
// phone, settings, etc.)
GET /api/users/1
// Response: 50+ fields, you need 2
// Call 2: Get posts (over-fetches post body,
// metadata, etc.)
GET /api/users/1/posts?limit=5
// Response: full post objects, you need titles
// Call 3: Get comment counts for each post
GET /api/posts/101/comments/count
GET /api/posts/102/comments/count
GET /api/posts/103/comments/count
// ... 5 more requests!
Total: 3-8 network round trips
GraphQL Approach (1 round trip)
# One query, exactly what we need
query UserProfile {
user(id: 1) {
name
avatarUrl
posts(limit: 5) {
title
commentCount
}
}
}
# Returns EXACTLY this shape
# No extra fields
# No extra round trips
Total: 1 network round trip
Use REST when: Simple CRUD apps, public APIs for third-party consumers, heavy caching requirements, team unfamiliar with GraphQL, file-upload-heavy services.
Use GraphQL when: Multiple client types (web, mobile, TV), complex data relationships, rapid frontend iteration, microservices aggregation layer, real-time features needed, you want a strong API contract.
Use both: Many production systems use GraphQL as a BFF (Backend For Frontend) that internally calls REST microservices. This is extremely common at scale.
Core Concepts
The fundamental building blocks that everything in GraphQL is built upon.
Schema — The Heart of GraphQL
The schema defines every piece of data your API can return. It's a contract between frontend and backend. It uses SDL (Schema Definition Language).
# ===== SCALAR TYPES (built-in primitives) =====
# Int - Signed 32-bit integer
# Float - Signed double-precision floating-point
# String - UTF-8 character sequence
# Boolean - true or false
# ID - Unique identifier (serialized as String)
# ===== CUSTOM SCALAR (for types not built-in) =====
scalar DateTime # You define how this is serialized/parsed
scalar JSON
# ===== ENUM (fixed set of values) =====
enum Role {
USER
ADMIN
MODERATOR
}
# ===== ENUM for status =====
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# ===== OBJECT TYPE (the main building block) =====
type User {
id: ID! # ! means non-nullable (required)
name: String!
email: String!
role: Role!
avatar: String # No ! means nullable (optional)
posts: [Post!]! # Non-null list of non-null Posts
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
status: PostStatus!
author: User! # Relationship back to User
comments: [Comment!]!
tags: [String!]!
createdAt: DateTime!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}
# ===== INPUT TYPE (for mutations) =====
# Input types are used for arguments. You can't use
# regular types as arguments — this is by design
# to separate "read shape" from "write shape"
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
input UpdatePostInput {
title: String # All optional for partial updates
body: String
tags: [String!]
}
# ===== INTERFACE (shared fields) =====
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
# ===== UNION TYPE (either this or that) =====
union SearchResult = User | Post | Comment
# ===== THE ROOT TYPES =====
type Query {
# These are the "read" entry points
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(status: PostStatus): [Post!]!
search(query: String!): [SearchResult!]!
}
type Mutation {
# These are the "write" entry points
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
register(email: String!, password: String!, name: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
}
type Subscription {
# These are the "real-time" entry points
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
type AuthPayload {
token: String!
user: User!
}
Understanding the ! (Non-Null) Modifier
This is one of the most confusing parts for beginners. Let's break it down:
| Declaration | Meaning | Example Value |
|---|---|---|
| String | Nullable string — can be null |
"hello" or null |
| String! | Non-null string — guaranteed to have a value | "hello" (never null) |
| [String] | Nullable list of nullable strings | null, [], ["a", null] |
| [String!] | Nullable list of non-null strings | null, [], ["a", "b"] |
| [String!]! | Non-null list of non-null strings | [], ["a", "b"] (never null) |
| [String]! | Non-null list of nullable strings | [], ["a", null] |
For return types: prefer nullable fields (no !) — this gives you graceful degradation if a resolver fails. A non-null field that fails will null-bubble up to the parent.
For arguments/inputs: use ! for required fields to enforce validation at the schema level.
The Three Root Operation Types
Query
Read operations. Analogous to GET in REST. Queries run in parallel by default.
Mutation
Write operations (create, update, delete). Analogous to POST/PUT/DELETE. Mutations run sequentially (in order).
Subscription
Real-time operations via WebSocket. Server pushes data to clients when events occur.
Schema Design Patterns
How senior engineers and architects design schemas that scale.
Relay-Style Pagination (Cursor-Based)
This is the industry standard for pagination in GraphQL. Used by Facebook, GitHub, Shopify, and most large-scale APIs.
# The Connection pattern
type Query {
posts(
first: Int # Number of items to fetch
after: String # Cursor: "fetch items AFTER this cursor"
last: Int # For backward pagination
before: String # Cursor: "fetch items BEFORE this cursor"
): PostConnection!
}
# The Connection type wraps the data + pagination info
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int! # Optional but useful
}
# Each Edge wraps a node + its cursor
type PostEdge {
node: Post! # The actual data
cursor: String! # Opaque cursor for this item
}
# Pagination metadata
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Client usage: get first 10 posts
query {
posts(first: 10) {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page: use the endCursor from previous response
query {
posts(first: 10, after: "Y3Vyc29yXzEw") {
edges {
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Offset pagination (LIMIT 10 OFFSET 20) breaks when items are added/removed during pagination — you skip or duplicate items.
Cursor pagination uses a stable pointer (usually base64-encoded ID + sort key) so it always picks up exactly where it left off, regardless of inserts/deletes.
Mutation Response Pattern
Always return enough data from mutations so the client can update its cache without making another query.
# BAD: Returns just a boolean
type Mutation {
createPost(input: CreatePostInput!): Boolean!
# Client has no data to update cache with!
}
# GOOD: Returns the created entity + possible errors
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
type CreatePostPayload {
post: Post # The created post (null on error)
errors: [UserError!]! # Business logic errors
}
type UserError {
field: [String!]! # Path to the field: ["input", "title"]
message: String! # Human-readable error
code: ErrorCode! # Machine-readable error code
}
enum ErrorCode {
VALIDATION_ERROR
NOT_FOUND
UNAUTHORIZED
DUPLICATE
}
Directives
Directives modify the behavior of fields or fragments at runtime. They start with @.
# Built-in directives
query UserProfile($withPosts: Boolean!, $skipAvatar: Boolean!) {
user(id: 1) {
name
avatar @skip(if: $skipAvatar) # Skip this field if true
posts @include(if: $withPosts) { # Include only if true
title
}
}
}
# Schema directives (custom)
directive @auth(requires: Role!) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION
type User {
email: String! @auth(requires: ADMIN) # Only admins see email
username: String! @deprecated(reason: "Use name instead")
}
Queries & Mutations Deep Dive
Everything about reading and writing data through GraphQL.
Query Anatomy
# === 1. Basic Query ===
query {
users {
id
name
}
}
# === 2. Named Query with Variables ===
# Naming queries is a best practice for:
# - Debugging (shows in network tab & server logs)
# - Caching (Apollo uses operation name)
# - APQ (Automatic Persisted Queries)
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
email
}
}
# Variables (sent as separate JSON):
# { "userId": "123" }
# === 3. Aliases (query same field multiple times) ===
query TwoUsers {
firstUser: user(id: "1") {
name
}
secondUser: user(id: "2") {
name
}
}
# Returns: { firstUser: { name: "..." }, secondUser: { name: "..." } }
# === 4. Fragments (reusable field sets) ===
fragment UserFields on User {
id
name
email
avatar
}
query Dashboard {
me {
...UserFields # Spread the fragment
posts {
title
author {
...UserFields # Reuse same fragment
}
}
}
}
# === 5. Inline Fragments (for unions/interfaces) ===
query Search($q: String!) {
search(query: $q) {
# SearchResult = User | Post | Comment
... on User {
name
avatar
}
... on Post {
title
body
}
... on Comment {
text
}
}
}
Mutations Anatomy
# === Create ===
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
body
author {
name
}
}
errors {
field
message
}
}
}
# Variables:
# {
# "input": {
# "title": "My First Post",
# "body": "Hello World!",
# "tags": ["graphql", "tutorial"]
# }
# }
# === Update ===
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
post {
id
title # Returns updated data for cache update
}
errors {
field
message
}
}
}
# === Delete ===
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
errors {
message
}
}
}
# === Multiple mutations in one request ===
# They execute SEQUENTIALLY (in order), unlike queries
mutation BatchOps {
first: createPost(input: { title: "A", body: "..." }) {
post { id }
}
second: createPost(input: { title: "B", body: "..." }) {
post { id }
}
}
Real-Time with Subscriptions
Push-based real-time data delivery over WebSocket.
Subscriptions let the server push data to subscribed clients when events happen. Under the hood, they use WebSocket (or SSE in newer implementations).
# Client subscribes to new comments on a post
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
text
author {
name
avatar
}
createdAt
}
}
# Server pushes this shape every time
# a new comment is added to that post
Subscriptions at scale require careful infrastructure — WebSocket connections are stateful and long-lived, which means you need sticky sessions or a pub/sub system (Redis, Kafka) to broadcast events across server instances. Many teams at scale use polling or server-sent events instead.
Resolvers Explained
Resolvers are functions that actually fetch the data. They're the bridge between your schema and your data sources.
Resolver Function Signature
Every resolver receives 4 arguments:
// Every resolver function has this signature:
const resolver = (
parent, // Result from the PARENT resolver
// (also called "root" or "obj")
// For top-level Query/Mutation: undefined
// For nested fields: the parent object
args, // Arguments passed to this field
// e.g., user(id: "1") => args = { id: "1" }
context, // Shared across ALL resolvers in a request
// Common: { db, user, loaders, req }
// Set once per request in server setup
info // AST of the query + schema info
// Advanced: used for optimizations like
// checking which fields were requested
) => {
// Return data (or a Promise)
};
Resolver Chain (How Nested Resolution Works)
This is crucial to understand. Resolvers form a chain:
// Given this query:
// query {
// user(id: "1") { ← Query.user resolver
// name ← User.name resolver (often default)
// posts { ← User.posts resolver
// title ← Post.title resolver (often default)
// author { ← Post.author resolver
// name ← User.name resolver (often default)
// }
// }
// }
// }
const resolvers = {
// Step 1: Top-level query resolver
Query: {
user: async (parent, args, context) => {
// parent is undefined for root queries
// args = { id: "1" }
return await context.db.user.findUnique({
where: { id: args.id }
});
// Returns: { id: "1", name: "Yatin", email: "..." }
// This return value becomes "parent" for child resolvers
},
},
// Step 2: User type resolvers
User: {
// "name" doesn't need a resolver!
// GraphQL has a DEFAULT resolver that does:
// (parent) => parent["name"]
// So if parent.name exists, it just works.
// "posts" DOES need a resolver because it's a relationship
posts: async (parent, args, context) => {
// parent = the user object from step 1
// parent = { id: "1", name: "Yatin", email: "..." }
return await context.db.post.findMany({
where: { authorId: parent.id }
});
// Returns: [{ id: "101", title: "...", authorId: "1" }, ...]
},
},
// Step 3: Post type resolvers
Post: {
// "title" uses default resolver: parent.title
author: async (parent, args, context) => {
// parent = a single post from step 2
// parent = { id: "101", title: "...", authorId: "1" }
return await context.db.user.findUnique({
where: { id: parent.authorId }
});
},
},
};
In the above example, if you query 10 users with their posts, the posts resolver runs 10 times — one for each user. That's 1 query for users + 10 queries for posts = 11 total queries. This is the N+1 problem. The solution is DataLoader (covered in Performance section).
Node.js Implementation
Complete backend setup with Apollo Server, Express, Prisma, and JWT authentication.
Project Setup
# Initialize project
mkdir graphql-api && cd graphql-api
npm init -y
# Core dependencies
npm install @apollo/server graphql express cors
# Database (Prisma ORM)
npm install prisma @prisma/client
npx prisma init
# Authentication
npm install jsonwebtoken bcryptjs
# Performance
npm install dataloader
# Subscriptions (real-time)
npm install graphql-ws ws graphql-subscriptions
# Dev dependencies
npm install -D nodemon typescript @types/node ts-node
Project Structure
graphql-api/
├── prisma/
│ └── schema.prisma # Database schema
├── src/
│ ├── index.js # Server entry point
│ ├── schema/
│ │ ├── typeDefs.js # GraphQL schema definitions
│ │ └── resolvers/
│ │ ├── index.js # Merge all resolvers
│ │ ├── userResolver.js
│ │ ├── postResolver.js
│ │ └── commentResolver.js
│ ├── middleware/
│ │ └── auth.js # JWT authentication
│ ├── utils/
│ │ ├── dataLoaders.js # DataLoader setup
│ │ └── errors.js # Custom error classes
│ └── context.js # Context builder
├── .env
└── package.json
Step 1: Database Schema (Prisma)
// prisma/schema.prisma
// This defines your DATABASE tables.
// Prisma generates a type-safe client from this.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "mysql", "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String // Hashed, never exposed in GraphQL
name String
role Role @default(USER)
avatar String? // ? means nullable
posts Post[] // One-to-many relation
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
body String
status PostStatus @default(DRAFT)
tags String[]
author User @relation(fields: [authorId], references: [id])
authorId String
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId]) // Index for faster lookups
}
model Comment {
id String @id @default(cuid())
text String
author User @relation(fields: [authorId], references: [id])
authorId String
post Post @relation(fields: [postId], references: [id])
postId String
createdAt DateTime @default(now())
@@index([postId])
@@index([authorId])
}
enum Role {
USER
ADMIN
MODERATOR
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Step 2: GraphQL Type Definitions
// src/schema/typeDefs.js
// This is where we define our GraphQL schema using SDL.
// The `gql` tag parses the string into an AST that Apollo understands.
const typeDefs = `#graphql
# ===== Custom Scalars =====
scalar DateTime
# ===== Enums =====
enum Role {
USER
ADMIN
MODERATOR
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum SortOrder {
ASC
DESC
}
# ===== Types =====
type User {
id: ID!
name: String!
email: String!
role: Role!
avatar: String
posts(limit: Int, offset: Int): [Post!]!
postCount: Int!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
status: PostStatus!
tags: [String!]!
author: User!
comments: [Comment!]!
commentCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}
# ===== Pagination Types =====
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
node: Post!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# ===== Input Types =====
input CreatePostInput {
title: String!
body: String!
tags: [String!]
status: PostStatus
}
input UpdatePostInput {
title: String
body: String
tags: [String!]
status: PostStatus
}
input RegisterInput {
name: String!
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
input PostFilters {
status: PostStatus
authorId: ID
tag: String
search: String
}
# ===== Payload Types (Mutation Responses) =====
type AuthPayload {
token: String!
user: User!
}
type PostPayload {
post: Post
errors: [UserError!]!
}
type DeletePayload {
success: Boolean!
errors: [UserError!]!
}
type UserError {
field: [String!]!
message: String!
}
# ===== Queries (Read Operations) =====
type Query {
# User queries
me: User # Current logged-in user
user(id: ID!): User # Get user by ID
users(limit: Int, offset: Int): [User!]!
# Post queries
post(id: ID!): Post
posts(
filters: PostFilters
first: Int
after: String
orderBy: SortOrder
): PostConnection!
}
# ===== Mutations (Write Operations) =====
type Mutation {
# Auth
register(input: RegisterInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
# Posts (require authentication)
createPost(input: CreatePostInput!): PostPayload!
updatePost(id: ID!, input: UpdatePostInput!): PostPayload!
deletePost(id: ID!): DeletePayload!
# Comments
addComment(postId: ID!, text: String!): Comment!
}
# ===== Subscriptions (Real-time) =====
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
`;
module.exports = { typeDefs };
Step 3: Authentication Middleware
// src/middleware/auth.js
// Extracts and verifies the JWT token from the request header.
// This runs on EVERY request to build the context object.
const jwt = require('jsonwebtoken');
// This function extracts the user from the JWT token.
// It does NOT throw if there's no token — some queries are public.
// Authorization (who can do what) happens in the resolvers.
function getUser(token) {
if (!token) return null;
try {
// Remove "Bearer " prefix if present
const cleanToken = token.startsWith('Bearer ')
? token.slice(7)
: token;
// jwt.verify() does two things:
// 1. Checks the signature (was this token created by us?)
// 2. Checks expiration (is the token still valid?)
const decoded = jwt.verify(cleanToken, process.env.JWT_SECRET);
return decoded; // { userId: "...", role: "..." }
} catch (err) {
return null; // Invalid/expired token = no user
}
}
// Helper to generate tokens
function generateToken(user) {
return jwt.sign(
{
userId: user.id,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: '7d' } // Token expires in 7 days
);
}
// Helper: throw if not authenticated
function requireAuth(context) {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.user;
}
module.exports = { getUser, generateToken, requireAuth };
Step 4: DataLoader (Solving N+1)
// src/utils/dataLoaders.js
// DataLoader batches and caches database queries within a single request.
// Instead of N individual queries, it batches them into 1 query.
//
// WITHOUT DataLoader (N+1 problem):
// SELECT * FROM users WHERE id = '1'
// SELECT * FROM users WHERE id = '2'
// SELECT * FROM users WHERE id = '3' ... N queries!
//
// WITH DataLoader (batched):
// SELECT * FROM users WHERE id IN ('1', '2', '3') ... 1 query!
const DataLoader = require('dataloader');
// IMPORTANT: Create new DataLoader instances PER REQUEST
// because the cache should not leak between requests.
function createLoaders(prisma) {
return {
// User loader: batch-fetch users by their IDs
user: new DataLoader(async (userIds) => {
// userIds = ['1', '2', '3'] (collected from all resolvers)
// One query fetches all users
const users = await prisma.user.findMany({
where: { id: { in: [...userIds] } },
});
// CRITICAL: DataLoader requires results in the SAME ORDER
// as the input IDs. If a user is not found, return null.
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
}),
// Posts by author loader
postsByAuthor: new DataLoader(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
// Group posts by authorId
const postsByAuthor = {};
posts.forEach(post => {
if (!postsByAuthor[post.authorId]) {
postsByAuthor[post.authorId] = [];
}
postsByAuthor[post.authorId].push(post);
});
return authorIds.map(id => postsByAuthor[id] || []);
}),
// Comment count by post loader
commentCountByPost: new DataLoader(async (postIds) => {
const counts = await prisma.comment.groupBy({
by: ['postId'],
where: { postId: { in: [...postIds] } },
_count: { id: true },
});
const countMap = new Map(
counts.map(c => [c.postId, c._count.id])
);
return postIds.map(id => countMap.get(id) || 0);
}),
};
}
module.exports = { createLoaders };
Step 5: Resolvers (Full Implementation)
// src/schema/resolvers/index.js
// Complete resolver implementation with explanations.
const { GraphQLError } = require('graphql');
const bcrypt = require('bcryptjs');
const { generateToken, requireAuth } = require('../../middleware/auth');
const { PubSub } = require('graphql-subscriptions');
// PubSub for subscriptions (use Redis-based in production!)
const pubsub = new PubSub();
const resolvers = {
// ==========================================
// CUSTOM SCALAR: DateTime
// ==========================================
DateTime: {
// How to serialize (server -> client)
serialize(value) {
return value.toISOString();
},
// How to parse from variable (client -> server)
parseValue(value) {
return new Date(value);
},
},
// ==========================================
// QUERIES (Read Operations)
// ==========================================
Query: {
// Get the currently logged-in user
me: async (_parent, _args, context) => {
// context.user was set by our auth middleware
if (!context.user) return null;
return context.prisma.user.findUnique({
where: { id: context.user.userId },
});
},
// Get user by ID
user: async (_parent, { id }, { prisma }) => {
// Destructuring: { id } from args, { prisma } from context
return prisma.user.findUnique({ where: { id } });
},
// List users with basic pagination
users: async (_parent, { limit = 10, offset = 0 }, { prisma }) => {
return prisma.user.findMany({
take: Math.min(limit, 50), // Cap at 50 to prevent abuse
skip: offset,
orderBy: { createdAt: 'desc' },
});
},
// Get single post
post: async (_parent, { id }, { prisma }) => {
return prisma.post.findUnique({ where: { id } });
},
// Get posts with cursor-based pagination + filters
posts: async (_parent, args, { prisma }) => {
const {
filters = {},
first = 10,
after,
orderBy = 'DESC',
} = args;
// Build WHERE clause from filters
const where = {};
if (filters.status) where.status = filters.status;
if (filters.authorId) where.authorId = filters.authorId;
if (filters.tag) where.tags = { has: filters.tag };
if (filters.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ body: { contains: filters.search, mode: 'insensitive' } },
];
}
// Cursor-based pagination with Prisma
const queryArgs = {
where,
take: Math.min(first, 50) + 1, // +1 to check hasNextPage
orderBy: { createdAt: orderBy.toLowerCase() },
};
if (after) {
// Decode cursor (base64-encoded ID)
const decodedCursor = Buffer.from(after, 'base64').toString();
queryArgs.cursor = { id: decodedCursor };
queryArgs.skip = 1; // Skip the cursor item itself
}
const [posts, totalCount] = await Promise.all([
prisma.post.findMany(queryArgs),
prisma.post.count({ where }),
]);
// Check if we got more than requested (means there's a next page)
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
// Cursor = base64-encoded ID (opaque to client)
cursor: Buffer.from(post.id).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
totalCount,
};
},
},
// ==========================================
// MUTATIONS (Write Operations)
// ==========================================
Mutation: {
// Register a new user
register: async (_parent, { input }, { prisma }) => {
const { name, email, password } = input;
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
throw new GraphQLError('Email already in use', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
// Hash the password (NEVER store plain text!)
// bcrypt adds a random salt automatically
const hashedPassword = await bcrypt.hash(password, 12);
// Create the user
const user = await prisma.user.create({
data: { name, email, password: hashedPassword },
});
// Generate JWT and return
const token = generateToken(user);
return { token, user };
},
// Login
login: async (_parent, { input }, { prisma }) => {
const { email, password } = input;
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Compare provided password with stored hash
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = generateToken(user);
return { token, user };
},
// Create a new post (requires auth)
createPost: async (_parent, { input }, context) => {
const authUser = requireAuth(context);
try {
const post = await context.prisma.post.create({
data: {
title: input.title,
body: input.body,
tags: input.tags || [],
status: input.status || 'DRAFT',
authorId: authUser.userId,
},
});
// Publish event for subscriptions
pubsub.publish('POST_CREATED', { postCreated: post });
return { post, errors: [] };
} catch (error) {
return {
post: null,
errors: [{
field: ['input'],
message: error.message,
}],
};
}
},
// Update a post (only author or admin)
updatePost: async (_parent, { id, input }, context) => {
const authUser = requireAuth(context);
// Check ownership
const post = await context.prisma.post.findUnique({
where: { id },
});
if (!post) {
return {
post: null,
errors: [{ field: ['id'], message: 'Post not found' }],
};
}
// Authorization: only author or admin can update
if (post.authorId !== authUser.userId && authUser.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
// Build update data (only include provided fields)
const data = {};
if (input.title !== undefined) data.title = input.title;
if (input.body !== undefined) data.body = input.body;
if (input.tags !== undefined) data.tags = input.tags;
if (input.status !== undefined) data.status = input.status;
const updated = await context.prisma.post.update({
where: { id },
data,
});
return { post: updated, errors: [] };
},
// Delete a post
deletePost: async (_parent, { id }, context) => {
const authUser = requireAuth(context);
const post = await context.prisma.post.findUnique({
where: { id },
});
if (!post) {
return {
success: false,
errors: [{ field: ['id'], message: 'Post not found' }],
};
}
if (post.authorId !== authUser.userId && authUser.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
// Delete comments first (cascade), then post
await context.prisma.comment.deleteMany({ where: { postId: id } });
await context.prisma.post.delete({ where: { id } });
return { success: true, errors: [] };
},
// Add a comment
addComment: async (_parent, { postId, text }, context) => {
const authUser = requireAuth(context);
const comment = await context.prisma.comment.create({
data: {
text,
postId,
authorId: authUser.userId,
},
});
// Publish for real-time subscriptions
pubsub.publish(`COMMENT_ADDED_${postId}`, {
commentAdded: comment,
});
return comment;
},
},
// ==========================================
// SUBSCRIPTIONS (Real-time)
// ==========================================
Subscription: {
postCreated: {
// subscribe returns an AsyncIterator
subscribe: () => pubsub.asyncIterableIterator(['POST_CREATED']),
},
commentAdded: {
subscribe: (_parent, { postId }) => {
return pubsub.asyncIterableIterator([`COMMENT_ADDED_${postId}`]);
},
},
},
// ==========================================
// TYPE RESOLVERS (Nested Field Resolution)
// ==========================================
// These resolve fields that don't directly exist on the
// database model (relationships, computed fields).
User: {
// Resolve the "posts" field on a User
posts: (parent, { limit = 10, offset = 0 }, { loaders }) => {
// Use DataLoader to batch this!
return loaders.postsByAuthor.load(parent.id);
},
// Computed field: count of user's posts
postCount: async (parent, _args, { prisma }) => {
return prisma.post.count({
where: { authorId: parent.id },
});
},
},
Post: {
// Resolve the "author" field on a Post
author: (parent, _args, { loaders }) => {
// parent.authorId exists on the Post record
// DataLoader batches all author lookups into one query
return loaders.user.load(parent.authorId);
},
// Resolve the "comments" field on a Post
comments: (parent, _args, { prisma }) => {
return prisma.comment.findMany({
where: { postId: parent.id },
orderBy: { createdAt: 'desc' },
});
},
// Computed field: comment count (uses DataLoader)
commentCount: (parent, _args, { loaders }) => {
return loaders.commentCountByPost.load(parent.id);
},
},
Comment: {
author: (parent, _args, { loaders }) => {
return loaders.user.load(parent.authorId);
},
post: (parent, _args, { prisma }) => {
return prisma.post.findUnique({
where: { id: parent.postId },
});
},
},
};
module.exports = { resolvers, pubsub };
Step 6: Server Entry Point (Putting It All Together)
// src/index.js
// This is the main server file that ties everything together.
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const {
ApolloServerPluginDrainHttpServer,
} = require('@apollo/server/plugin/drainHttpServer');
const express = require('express');
const http = require('http');
const cors = require('cors');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PrismaClient } = require('@prisma/client');
const { typeDefs } = require('./schema/typeDefs');
const { resolvers } = require('./schema/resolvers');
const { getUser } = require('./middleware/auth');
const { createLoaders } = require('./utils/dataLoaders');
// Initialize Prisma Client (database connection)
const prisma = new PrismaClient({
log: ['query'], // Log SQL queries in development
});
async function startServer() {
// 1. Create Express app and HTTP server
const app = express();
const httpServer = http.createServer(app);
// 2. Create executable schema (combines typeDefs + resolvers)
const schema = makeExecutableSchema({ typeDefs, resolvers });
// 3. Set up WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql', // Same path as HTTP endpoint
});
const serverCleanup = useServer(
{
schema,
// Context for subscription connections
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
const user = getUser(token);
return { user, prisma, loaders: createLoaders(prisma) };
},
},
wsServer
);
// 4. Create Apollo Server
const server = new ApolloServer({
schema,
plugins: [
// Graceful shutdown for HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
// Graceful shutdown for WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
// Format errors for the client
formatError(formattedError, error) {
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error' };
}
}
return formattedError;
},
});
// 5. Start Apollo Server
await server.start();
// 6. Mount middleware
app.use(
'/graphql',
cors({
origin: ['http://localhost:3000', 'https://yourdomain.com'],
credentials: true,
}),
express.json({ limit: '10mb' }),
expressMiddleware(server, {
// CONTEXT FUNCTION: runs on EVERY request
// This is where we set up everything resolvers need
context: async ({ req }) => {
// Extract JWT from Authorization header
const token = req.headers.authorization || '';
const user = getUser(token);
return {
// Current authenticated user (or null)
user,
// Prisma client for database queries
prisma,
// DataLoaders (fresh per request!)
loaders: createLoaders(prisma),
// Raw request (for IP, headers, etc.)
req,
};
},
})
);
// 7. Health check endpoint (for load balancers)
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
// 8. Start listening
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`Server ready at http://localhost:${PORT}/graphql`);
console.log(`Subscriptions at ws://localhost:${PORT}/graphql`);
});
}
// Start the server
startServer().catch(console.error);
Step 7: Environment Variables
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/graphql_db"
# Auth
JWT_SECRET="your-super-secret-key-change-in-production"
# Server
PORT=4000
NODE_ENV=development
React Frontend
Complete frontend setup with Apollo Client, hooks, caching, and optimistic UI.
Project Setup
# Create React app
npx create-react-app graphql-client
# OR with Vite (recommended)
npm create vite@latest graphql-client -- --template react
# Install Apollo Client
npm install @apollo/client graphql
Apollo Client Setup
// src/lib/apolloClient.js
// Apollo Client is the brain of GraphQL on the frontend.
// It handles: sending queries, caching, real-time, and state management.
import {
ApolloClient,
InMemoryCache,
createHttpLink,
split,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
// ===== HTTP Link (for queries & mutations) =====
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
// ===== Auth Link (adds JWT to every request) =====
const authLink = setContext((_, { headers }) => {
// Read token from localStorage
const token = localStorage.getItem('token');
return {
headers: {
...headers,
// If token exists, add it to the Authorization header
authorization: token ? `Bearer ${token}` : '',
},
};
});
// ===== WebSocket Link (for subscriptions) =====
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
// Send auth token through WebSocket connection
authorization: localStorage.getItem('token') || '',
},
})
);
// ===== Split Link (routes to HTTP or WS based on operation) =====
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink, // Subscriptions go through WebSocket
authLink.concat(httpLink) // Everything else goes through HTTP
);
// ===== Apollo Client Instance =====
const client = new ApolloClient({
link: splitLink,
// InMemoryCache is a normalized cache.
// It stores data by __typename + id, so if you fetch
// the same user in two different queries, it's stored once.
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Custom merge for paginated queries
posts: {
keyArgs: ['filters'], // Cache separately per filter
merge(existing, incoming, { args }) {
if (!args?.after) return incoming; // Fresh query
// Merge edges for infinite scroll
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
},
}),
// Default options for all queries
defaultOptions: {
watchQuery: {
// fetchPolicy determines where data comes from:
// 'cache-first' - check cache, then network (default)
// 'network-only' - always hit the network
// 'cache-and-network' - return cache, then update from network
// 'cache-only' - only use cache
fetchPolicy: 'cache-and-network',
},
},
});
export default client;
GraphQL Operations (Queries & Mutations)
// src/graphql/operations.js
// Define all GraphQL operations in one place.
// The `gql` tag parses these strings into document ASTs.
import { gql } from '@apollo/client';
// ===== FRAGMENTS (reusable field sets) =====
export const USER_FIELDS = gql`
fragment UserFields on User {
id
name
email
avatar
role
}
`;
export const POST_FIELDS = gql`
fragment PostFields on Post {
id
title
body
status
tags
commentCount
createdAt
author {
...UserFields
}
}
${USER_FIELDS}
`;
// ===== QUERIES =====
export const GET_ME = gql`
query GetMe {
me {
...UserFields
}
}
${USER_FIELDS}
`;
export const GET_POSTS = gql`
query GetPosts($first: Int, $after: String, $filters: PostFilters) {
posts(first: $first, after: $after, filters: $filters) {
edges {
node {
...PostFields
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
${POST_FIELDS}
`;
export const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
...PostFields
comments {
id
text
createdAt
author {
...UserFields
}
}
}
}
${POST_FIELDS}
`;
// ===== MUTATIONS =====
export const LOGIN = gql`
mutation Login($input: LoginInput!) {
login(input: $input) {
token
user {
...UserFields
}
}
}
${USER_FIELDS}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
...PostFields
}
errors {
field
message
}
}
}
${POST_FIELDS}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
errors {
field
message
}
}
}
`;
// ===== SUBSCRIPTIONS =====
export const COMMENT_ADDED = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
text
createdAt
author {
...UserFields
}
}
}
${USER_FIELDS}
`;
React Components (Using Apollo Hooks)
// src/App.jsx
import { ApolloProvider } from '@apollo/client';
import client from './lib/apolloClient';
import PostList from './components/PostList';
// ApolloProvider makes the client available to all components
// via React Context. Similar to Redux's Provider.
function App() {
return (
<ApolloProvider client={client}>
<div className="app">
<h1>My Blog</h1>
<PostList />
</div>
</ApolloProvider>
);
}
// src/components/PostList.jsx
// Demonstrates: useQuery, loading/error states, pagination
import { useQuery } from '@apollo/client';
import { GET_POSTS } from '../graphql/operations';
function PostList() {
// useQuery automatically:
// 1. Sends the query when component mounts
// 2. Returns loading/error/data states
// 3. Re-renders when data changes
// 4. Caches the result
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: {
first: 10,
filters: { status: 'PUBLISHED' },
},
// notifyOnNetworkStatusChange: true // for loading on fetchMore
});
// Loading state (first load)
if (loading && !data) return <p>Loading...</p>;
// Error state
if (error) return <p>Error: {error.message}</p>;
const { edges, pageInfo, totalCount } = data.posts;
// Load More handler (cursor-based pagination)
const handleLoadMore = () => {
fetchMore({
variables: {
after: pageInfo.endCursor, // "Give me items after this cursor"
},
// The merge function in cache config handles combining results
});
};
return (
<div>
<h2>Posts ({totalCount})</h2>
{edges.map(({ node: post }) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>By {post.author.name}</p>
<p>{post.commentCount} comments</p>
<div>{post.tags.map(t => <span key={t}>#{t}</span>)}</div>
</article>
))}
{pageInfo.hasNextPage && (
<button onClick={handleLoadMore}>
Load More
</button>
)}
</div>
);
}
// src/components/CreatePost.jsx
// Demonstrates: useMutation, optimistic UI, cache update
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_POST, GET_POSTS } from '../graphql/operations';
function CreatePost() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
// useMutation returns: [mutationFunction, { data, loading, error }]
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
// OPTION 1: refetchQueries - simple but slower
// Refetches the posts list after mutation completes
// refetchQueries: [{ query: GET_POSTS }],
// OPTION 2: update - manual cache update (faster, more control)
update(cache, { data: { createPost: result } }) {
if (!result.post) return; // Don't update cache on error
// Read existing posts from cache
const existing = cache.readQuery({
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
});
if (existing) {
// Write updated data to cache
cache.writeQuery({
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
data: {
posts: {
...existing.posts,
edges: [
{ node: result.post, cursor: btoa(result.post.id) },
...existing.posts.edges,
],
totalCount: existing.posts.totalCount + 1,
},
},
});
}
},
// OPTION 3: Optimistic Response (instant UI update)
// Shows the new post immediately, before server responds.
// If the mutation fails, Apollo reverts the optimistic update.
optimisticResponse: {
createPost: {
__typename: 'PostPayload',
post: {
__typename: 'Post',
id: 'temp-id-' + Date.now(), // Temporary ID
title,
body,
status: 'DRAFT',
tags: [],
commentCount: 0,
createdAt: new Date().toISOString(),
author: {
__typename: 'User',
id: 'current-user-id',
name: 'You',
email: '',
avatar: null,
role: 'USER',
},
},
errors: [],
},
},
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
await createPost({
variables: {
input: { title, body },
},
});
setTitle('');
setBody('');
} catch (err) {
console.error('Failed to create post:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Post title"
required
/>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="Post body"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p className="error">{error.message}</p>}
</form>
);
}
// src/components/PostComments.jsx
// Demonstrates: useSubscription for real-time updates
import { useQuery, useSubscription } from '@apollo/client';
import { GET_POST, COMMENT_ADDED } from '../graphql/operations';
function PostComments({ postId }) {
// Fetch initial comments
const { data } = useQuery(GET_POST, {
variables: { id: postId },
});
// Subscribe to new comments in real-time
useSubscription(COMMENT_ADDED, {
variables: { postId },
// onData fires every time the server pushes a new comment
onData({ client, data: subData }) {
const newComment = subData.data.commentAdded;
// Update the cache with the new comment
const existing = client.cache.readQuery({
query: GET_POST,
variables: { id: postId },
});
if (existing) {
client.cache.writeQuery({
query: GET_POST,
variables: { id: postId },
data: {
post: {
...existing.post,
comments: [newComment, ...existing.post.comments],
},
},
});
}
},
});
return (
<div>
<h3>Comments</h3>
{data?.post?.comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author.name}</strong>
<p>{comment.text}</p>
</div>
))}
</div>
);
}
Authentication & Authorization
How to secure your GraphQL API properly.
Authentication vs Authorization
Authentication (AuthN)
"Who are you?"
Verifying identity. Handled in the context function — extract JWT from headers, verify it, attach user to context.
Authorization (AuthZ)
"What can you do?"
Checking permissions. Handled in resolvers or middleware — check if user has the right role/permissions for this operation.
// PATTERN 1: Direct in resolver (simple)
const resolvers = {
Mutation: {
deleteUser: (_, { id }, context) => {
if (!context.user) throw new AuthenticationError('Login required');
if (context.user.role !== 'ADMIN') throw new ForbiddenError('Admin only');
// ... delete user
},
},
};
// PATTERN 2: Schema directive (scalable, used in production)
// In schema: type Mutation { deleteUser(id: ID!): User @auth(requires: ADMIN) }
// A directive transformer checks the role before the resolver runs.
// PATTERN 3: Middleware layer (using graphql-shield)
const { shield, rule, allow, deny } = require('graphql-shield');
const isAuthenticated = rule()((_, __, ctx) => ctx.user !== null);
const isAdmin = rule()((_, __, ctx) => ctx.user?.role === 'ADMIN');
const isOwner = rule()((_, { id }, ctx) => ctx.user?.userId === id);
const permissions = shield({
Query: {
me: isAuthenticated,
users: isAdmin,
},
Mutation: {
createPost: isAuthenticated,
deletePost: isOwner,
},
});
Error Handling
GraphQL handles errors differently from REST. Understanding this is critical.
GraphQL always returns HTTP 200, even on errors. Errors are in the response body under an "errors" key. The "data" key may be partially populated (partial success is possible!).
{
// Data can be PARTIALLY present even with errors!
"data": {
"user": {
"name": "Yatin",
"email": null // This field errored
}
},
"errors": [
{
"message": "Not authorized to view email",
"locations": [{ "line": 4, "column": 5 }],
"path": ["user", "email"], // Which field errored
"extensions": {
"code": "FORBIDDEN", // Machine-readable error code
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
const { GraphQLError } = require('graphql');
// Standard error codes that Apollo/clients understand:
// GRAPHQL_PARSE_FAILED - Query syntax error
// GRAPHQL_VALIDATION_FAILED - Query doesn't match schema
// BAD_USER_INPUT - Invalid argument values
// UNAUTHENTICATED - Not logged in
// FORBIDDEN - Logged in but not authorized
// PERSISTED_QUERY_NOT_FOUND
// INTERNAL_SERVER_ERROR - Catch-all
throw new GraphQLError('Post not found', {
extensions: {
code: 'NOT_FOUND',
argumentName: 'id',
// Add any extra context you want
},
});
Performance Optimization
From N+1 to caching — making GraphQL fast at scale.
The N+1 Problem & DataLoader
Already covered in the implementation section. Here's the summary:
Without DataLoader
10 users with posts = 1 query (users) + 10 queries (posts for each user) = 11 queries
With DataLoader
10 users with posts = 1 query (users) + 1 batched query (posts WHERE authorId IN [...]) = 2 queries
Query Complexity & Depth Limiting
Prevent abuse from deeply nested or expensive queries.
// A malicious query could look like this:
// query {
// users {
// posts {
// author {
// posts {
// author {
// posts { ... 50 levels deep ... }
// }
// }
// }
// }
// }
// }
// Solution 1: Depth limiting
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
validationRules: [depthLimit(7)], // Max 7 levels deep
});
// Solution 2: Query complexity analysis
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000), // Max complexity score of 1000
],
});
Automatic Persisted Queries (APQ)
Instead of sending the full query string every time, send a hash. The server has a lookup table of hash → query.
// Without APQ (every request):
// POST /graphql
// Body: { query: "query GetUser($id: ID!) { user(id: $id) { name email ... } }" }
// ^^ This string can be HUGE for complex queries
// With APQ (first request sends hash + query, subsequent just hash):
// POST /graphql
// Body: { extensions: { persistedQuery: { sha256Hash: "abc123..." } } }
// ^^ Much smaller payload
// Client setup:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 }).concat(httpLink);
Production Architecture
How companies like GitHub, Shopify, and Netflix run GraphQL in production.
Production Architecture Diagram
┌─────────────┐
│ Clients │
│ Web/Mobile │
└──────┬──────┘
│
┌──────▼──────┐
│ CDN │ ← APQ cache, static assets
│ CloudFront │
└──────┬──────┘
│
┌──────▼──────┐
│ Load │ ← Rate limiting, WAF
│ Balancer │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌────▼─────┐ ┌────▼─────┐
│ GraphQL │ │ GraphQL │ │ GraphQL │ ← Multiple instances
│ Server 1 │ │ Server 2 │ │ Server 3 │ (horizontal scaling)
└─────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────┼────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌──────▼─────┐
│ Postgres │ │ Redis │ │ Upstream │
│ DB │ │ Cache │ │ REST APIs │
└──────────┘ └──────────┘ └────────────┘
Production Checklist
Disable Introspection
In production, disable schema introspection to prevent attackers from discovering your full API surface.
new ApolloServer({
introspection: process.env.NODE_ENV !== 'production',
})
Rate Limiting
Apply per-IP and per-user rate limits. Consider query complexity in your rate limit budget.
// Simple rate limit: 100 queries/minute/IP
// Advanced: cost-based rate limiting
// where complex queries cost more
Query Allow-listing
Only allow pre-approved queries in production. Reject any unknown query string.
Monitoring & Observability
Track resolver execution time, error rates, and query patterns. Use Apollo Studio or custom tracing.
Error Masking
Never expose stack traces or internal errors to clients. Log the full error server-side, send generic message to client.
Connection Pooling
Use a connection pool for your database. Prisma does this automatically. For REST data sources, use keep-alive.
Caching Strategies in Production
// ===== 1. HTTP Caching (CDN-level) =====
// For public queries, you can use GET requests + HTTP cache headers
// Apollo Server supports this for queries (not mutations)
// In schema, add cache hints:
// type User @cacheControl(maxAge: 300) {
// name: String! # Inherits 300s
// email: String! @cacheControl(maxAge: 0) # Never cache
// }
// ===== 2. Server-side Redis Cache =====
const Redis = require('ioredis');
const redis = new Redis();
// In resolver:
async function getUser(id) {
// Check Redis first
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// Not in cache, fetch from DB
const user = await prisma.user.findUnique({ where: { id } });
// Store in Redis with TTL (Time To Live)
await redis.setex(`user:${id}`, 300, JSON.stringify(user)); // 5 min
return user;
}
// ===== 3. Client-side (Apollo Cache) =====
// Already covered in the React section.
// Apollo's InMemoryCache is a normalized cache that
// deduplicates entities by __typename + id.
Security
GraphQL-specific security concerns and how to address them.
1. Query Depth Attack
Risk: Deeply nested queries can crash the server (DoS).
Fix: graphql-depth-limit middleware, max depth of 7-10.
2. Query Complexity Attack
Risk: Requesting expensive fields (like connections with 1000 items).
Fix: Query complexity analysis. Assign cost to fields, reject if total exceeds budget.
3. Introspection Abuse
Risk: Attackers discover your entire schema structure.
Fix: Disable introspection in production.
4. Batch Attack
Risk: Sending 1000 mutations in one request.
Fix: Limit batch size, query cost analysis.
5. Injection
Risk: SQL/NoSQL injection through arguments.
Fix: Always use parameterized queries (Prisma does this by default). Validate inputs.
6. Information Disclosure
Risk: Error messages exposing stack traces or internal details.
Fix: Custom formatError that strips sensitive info in production.
Testing GraphQL
Unit testing resolvers, integration testing the full API, and mocking on the frontend.
Backend: Integration Testing
// __tests__/posts.test.js
const { ApolloServer } = require('@apollo/server');
const { typeDefs } = require('../src/schema/typeDefs');
const { resolvers } = require('../src/schema/resolvers');
// Create a test server
const testServer = new ApolloServer({ typeDefs, resolvers });
describe('Posts API', () => {
it('should fetch published posts', async () => {
const result = await testServer.executeOperation(
{
query: `
query GetPosts($first: Int) {
posts(first: $first) {
edges {
node { id title }
}
totalCount
}
}
`,
variables: { first: 5 },
},
{
// Provide mock context
contextValue: {
prisma: mockPrisma,
user: { userId: '1', role: 'USER' },
loaders: mockLoaders,
},
}
);
// Assert
expect(result.body.singleResult.errors).toBeUndefined();
expect(result.body.singleResult.data.posts.edges.length)
.toBeLessThanOrEqual(5);
});
it('should require auth for createPost', async () => {
const result = await testServer.executeOperation(
{
query: `
mutation { createPost(input: { title: "Test", body: "..." }) {
post { id } errors { message }
}}
`,
},
{
contextValue: {
prisma: mockPrisma,
user: null, // No auth!
loaders: mockLoaders,
},
}
);
// Should get UNAUTHENTICATED error
expect(result.body.singleResult.errors[0].extensions.code)
.toBe('UNAUTHENTICATED');
});
});
Frontend: MockedProvider
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import PostList from './PostList';
import { GET_POSTS } from '../graphql/operations';
const mocks = [
{
request: {
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
},
result: {
data: {
posts: {
edges: [
{
node: {
id: '1',
title: 'Test Post',
// ... all required fields
},
cursor: 'abc',
},
],
pageInfo: { hasNextPage: false, endCursor: 'abc' },
totalCount: 1,
},
},
},
},
];
test('renders posts', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<PostList />
</MockedProvider>
);
// Wait for data to load
const post = await screen.findByText('Test Post');
expect(post).toBeInTheDocument();
});
Apollo Federation
How large teams split GraphQL across microservices. The architecture pattern used by Netflix, Airbnb, Expedia.
What is Federation?
Federation lets you compose a single unified GraphQL API from multiple GraphQL services (subgraphs). Each team owns their slice of the schema.
┌─────────────┐
│ Client │ Sees ONE unified schema
└──────┬──────┘
│
┌──────▼──────┐
│ Apollo │ The "router" that composes
│ Gateway │ subgraphs into one API
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌──────▼─────┐
│ Users │ │ Posts │ │ Comments │
│ Subgraph │ │ Subgraph │ │ Subgraph │
│ (Team A) │ │ (Team B) │ │ (Team C) │
└──────────┘ └──────────┘ └────────────┘
# Users subgraph (owned by Team A)
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
# Posts subgraph (owned by Team B)
# Can EXTEND the User type from another subgraph!
type User @key(fields: "id") {
id: ID!
posts: [Post!]! # Team B adds posts to User
}
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
Don't federate until you have: multiple teams, clear domain boundaries, and the monolithic schema is becoming a bottleneck. Premature federation adds significant complexity with little benefit.
Do federate when: teams need independent deploy cycles, the schema is too large for one team to own, or you're composing existing microservice APIs.
Interview Questions & Answers
40+ questions organized by topic. Click to reveal answers.
Fundamentals
Junior What is GraphQL and how is it different from REST?
GraphQL is a query language for APIs developed by Facebook. Unlike REST where you have multiple endpoints returning fixed data structures, GraphQL has a single endpoint where the client specifies exactly what data it needs. This eliminates over-fetching (getting too much data) and under-fetching (needing multiple requests to get enough data).
Junior What are the three root operation types in GraphQL?
Query for reading data (like GET), Mutation for writing data (like POST/PUT/DELETE), and Subscription for real-time data via WebSocket. Queries run in parallel by default, while mutations run sequentially.
Junior What is a schema in GraphQL?
The schema is the contract between frontend and backend. It defines every type, field, and relationship in your API using SDL (Schema Definition Language). It includes object types, input types, enums, interfaces, unions, and custom scalars. The server validates all incoming queries against the schema.
Junior Explain the "!" (non-null) modifier in GraphQL.
String! means the field will never be null — the server guarantees a value. String (without !) means the field is nullable and might return null. For lists: [String!]! means a non-null list of non-null strings, while [String] means a nullable list of nullable strings.
Junior What are fragments and why are they useful?
Fragments are reusable sets of fields. Instead of repeating the same fields in multiple queries, you define a fragment once and spread it with ...FragmentName. They reduce duplication, make queries maintainable, and are also essential for querying union types (inline fragments: ... on TypeName { fields }).
Mid What is a resolver? Explain the four arguments.
A resolver is a function that fetches data for a specific field. It receives four arguments:
1. parent (or root): The return value of the parent resolver. For root Query/Mutation fields, this is undefined. For nested fields, it's the parent object.
2. args: Arguments passed to this field in the query (e.g., user(id: "1") gives args = { id: "1" }).
3. context: Shared object across all resolvers in a single request. Typically contains: database client, authenticated user, DataLoaders.
4. info: Advanced — contains the AST of the query and schema info. Used for optimizations like checking which fields were selected.
Mid What is the difference between input types and object types?
Object types (defined with type) are for output — they describe what the API returns. Input types (defined with input) are for arguments — they describe what the client sends. You cannot use an object type as an argument; GraphQL requires this separation by design. This allows the read shape (which may include computed fields, relationships) to be different from the write shape (which only needs writable fields).
Performance & N+1
Mid Explain the N+1 problem in GraphQL and how to solve it.
The N+1 problem occurs when querying a list with related data. For example, fetching 10 users with their posts: 1 query for users + 10 individual queries for each user's posts = 11 total queries.
Solution: DataLoader. DataLoader collects all the individual load(id) calls within a single tick of the event loop, then batches them into one query: SELECT * FROM posts WHERE authorId IN ('1', '2', ... '10'). This reduces 11 queries to 2. Important: create a new DataLoader instance per request to prevent cache leakage between users.
Senior How do you implement caching in GraphQL?
GraphQL caching operates at multiple layers:
1. Client-side: Apollo Client's InMemoryCache is a normalized cache that stores entities by __typename:id. If two queries fetch the same user, it's stored once.
2. CDN/HTTP level: Use @cacheControl directives on types/fields, and Apollo Server can set Cache-Control headers for GET-based queries.
3. Server-side: Redis cache layer in resolvers. Check cache before hitting the database, store results with TTL.
4. APQ (Automatic Persisted Queries): Cache the query document itself so clients only send a hash, reducing payload size.
5. DataLoader: Per-request caching — prevents the same entity from being fetched twice within one request.
Senior Why is caching harder in GraphQL than REST?
REST APIs use URL-based caching: GET /users/1 has a unique URL that HTTP infrastructure (browsers, CDNs, proxies) can cache natively. GraphQL uses a single POST endpoint with the query in the body — HTTP caches can't differentiate between different queries to the same URL. Solutions include: switching to GET for queries, Automatic Persisted Queries (hash-based lookup), and client-side normalized caches like Apollo's InMemoryCache.
Mid What are persisted queries?
Instead of sending the full query string on every request (which can be very large), the client sends a hash (SHA-256) of the query. The server maintains a hash-to-query lookup table. Benefits: smaller payloads, CDN caching becomes possible, and it acts as a security layer (only allow known queries). Apollo supports Automatic Persisted Queries where the first request sends hash + query, and subsequent requests send just the hash.
Schema Design
Mid Explain cursor-based pagination vs offset pagination.
Offset: LIMIT 10 OFFSET 20. Simple but breaks when data changes during pagination (duplicates/skips). Also gets slower for large offsets.
Cursor: Uses a stable pointer (opaque string, usually base64-encoded ID + sort key) to mark position. The query says "give me 10 items after this cursor." Stable regardless of inserts/deletes, and consistently fast. The Relay Connection spec (edges, node, pageInfo) is the standard for cursor pagination in GraphQL.
Senior What is the Relay specification?
Relay is a GraphQL client by Facebook that defines conventions for: 1. Node Interface: Every entity has a globally unique ID and can be refetched with node(id: ID!). 2. Connections: Standardized pagination with edges, node, cursor, and pageInfo. 3. Mutations: Use input types and return payload types. These conventions are widely adopted beyond just Relay — GitHub's API, Shopify's API, and Apollo Client all support them.
Senior How do you handle schema evolution without versioning?
GraphQL APIs don't use versioning (v1, v2). Instead:
1. Add new fields freely — existing clients won't break because they don't request them.
2. Deprecate old fields with @deprecated(reason: "Use newField"). Clients see warnings in tooling.
3. Monitor field usage to know when deprecated fields can be safely removed.
4. Never remove or rename fields without checking that zero clients are using them.
This is one of GraphQL's biggest advantages over REST — the API evolves continuously without breaking changes.
Mid What are unions and interfaces? When would you use each?
Interface: A set of fields that multiple types must implement. Use when types share common fields. Example: interface Node { id: ID! } implemented by User, Post, Comment.
Union: A type that could be one of several types with NO shared fields required. Use for search results: union SearchResult = User | Post | Comment. Queried with inline fragments: ... on User { name }.
Key difference: interfaces require shared fields, unions don't. Use interfaces for "is-a" relationships, unions for "one-of" relationships.
Security
Senior What are the main security concerns with GraphQL?
1. Query depth attacks: Infinitely nested queries can DoS the server. Fix: depth limiting.
2. Query complexity attacks: Requesting expensive computed fields at scale. Fix: complexity analysis with cost budgets.
3. Batch attacks: Hundreds of mutations in one request. Fix: batch size limits.
4. Introspection abuse: Discovering the full schema. Fix: disable in production.
5. Authorization bypass: Accessing fields you shouldn't. Fix: field-level auth in resolvers.
6. Information disclosure: Stack traces in error messages. Fix: error masking in production.
Mid How do you handle authentication in GraphQL?
Authentication is handled in the context function, which runs before any resolver. Extract the JWT from the Authorization header, verify it, and attach the user to the context object. Every resolver then has access to context.user. If the token is invalid, set context.user = null (don't throw — some queries are public). Throw UNAUTHENTICATED error only in resolvers that require auth.
Senior How do you implement field-level authorization?
Three common patterns:
1. Direct in resolver: Check context.user.role before returning data. Simple but scattered.
2. Schema directives: @auth(requires: ADMIN) on fields. A directive transformer wraps the resolver with an auth check. Clean and declarative.
3. graphql-shield: A middleware layer that defines permission rules as a separate tree: shield({ Query: { users: isAdmin } }). Centralizes all auth logic.
Real-Time & Subscriptions
Mid How do GraphQL subscriptions work?
Subscriptions use WebSocket (or SSE) for persistent, bidirectional connections. The client sends a subscription query, and the server pushes data whenever the subscribed event occurs. Implementation: a PubSub system (in-memory for dev, Redis/Kafka for production). When a mutation fires (e.g., new comment), it publishes an event. The subscription resolver listens via asyncIterableIterator and pushes data to all subscribed clients.
Senior What are the challenges of subscriptions at scale?
WebSocket connections are stateful and long-lived, which creates several challenges:
1. Load balancing: Need sticky sessions or a shared pub/sub layer so events reach the right server.
2. Memory: Each connection consumes server memory. 100K connections = significant RAM.
3. Reconnection: Clients need robust reconnection logic with exponential backoff.
4. Scaling: Need Redis/Kafka as a message broker between server instances.
Alternative: Many teams use polling (refetchQueries at intervals) or Server-Sent Events instead of WebSocket subscriptions for simplicity at scale.
Apollo Client & Frontend
Mid What is Apollo Client's normalized cache?
Apollo's InMemoryCache stores data in a flat, normalized structure keyed by __typename:id. If a user appears in two different queries, it's stored once and both queries reference the same cache entry. When a mutation updates that user, all queries showing that user automatically reflect the change. This is similar to a client-side database with automatic denormalization for the UI.
Mid Explain the different fetch policies in Apollo Client.
cache-first (default): Return cached data if available; otherwise query network. Best for mostly-static data.
network-only: Always query the network, ignore cache. Use for data that changes frequently.
cache-and-network: Return cached data immediately for fast UI, then fetch from network and update. Best balance of speed and freshness.
cache-only: Only return cached data, never hit network. For offline or pre-loaded data.
no-cache: Always network, don't store in cache. For one-off sensitive data.
Mid What is optimistic UI in Apollo?
Optimistic UI updates the cache immediately with the expected result of a mutation, before the server responds. This makes the UI feel instant. If the mutation fails, Apollo automatically reverts the optimistic update. Implemented via the optimisticResponse option in useMutation. You provide a fake response with a temporary ID, and Apollo merges it into the cache. When the real response arrives, it replaces the optimistic data.
Junior What are the main Apollo Client hooks?
useQuery: Fetch data. Returns { loading, error, data, refetch, fetchMore }. Executes on mount.
useMutation: Returns [mutationFn, { data, loading, error }]. Executes when you call the function.
useSubscription: Subscribes to real-time events. Returns { data, loading, error }.
useLazyQuery: Like useQuery but doesn't execute on mount. Returns [queryFn, { data, loading }]. Good for search/on-demand fetching.
Production & Architecture
Lead What is Apollo Federation and when would you use it?
Apollo Federation lets multiple teams build separate GraphQL services (subgraphs) that compose into one unified API through a Gateway/Router. Each subgraph owns a portion of the schema and can extend types from other subgraphs using @key directives.
Use when: Multiple teams need independent deploy cycles, clear domain boundaries exist, monolithic schema becomes a coordination bottleneck.
Don't use when: Small team, single domain, or the overhead of running a gateway isn't justified. Start monolithic and federate when you hit real pain.
Lead How do you handle schema changes in a team environment?
1. Schema registry: Use Apollo Studio or similar to track schema versions and check for breaking changes in CI.
2. Schema checks: Before merging a PR, automatically check if schema changes would break any known client queries.
3. Schema reviews: Treat schema changes like API contract changes — require review from frontend and backend teams.
4. Field usage tracking: Monitor which clients use which fields before deprecating/removing.
5. Deprecation flow: Mark deprecated → monitor usage → notify teams → remove after zero usage.
VP When would you choose GraphQL over REST for a new project?
Choose GraphQL when:
Multiple client types (web, mobile, TV, watch) need different data shapes from the same backend. Complex, interconnected data model with deep relationships. Frontend teams need to iterate rapidly without backend changes. You need a strong typed contract between teams. You're building a BFF layer over microservices.
Choose REST when:
Simple CRUD without complex relationships. Public API for third-party consumers (REST is more universally understood). File-upload-heavy services. Team has no GraphQL experience and timeline is tight. Caching requirements are heavy and HTTP caching suffices.
The pragmatic answer: GraphQL excels as a BFF (Backend For Frontend) layer that sits in front of REST microservices. This gives clients the flexibility of GraphQL while keeping backend services simple. Many production systems at Netflix, Airbnb, and Shopify use this hybrid approach.
VP What are the organizational implications of adopting GraphQL?
Schema ownership: Who owns the schema? In monolithic GraphQL, it's often a "schema guild" or the backend team. In federation, each team owns their subgraph.
Team structure: GraphQL encourages "vertical" teams (frontend + backend owning a domain) rather than horizontal layers.
Developer experience: Strong typing means better tooling, autocomplete, and documentation generation. Reduces frontend-backend communication overhead.
Costs: Training investment, tooling investment (Apollo Studio, schema registry), and potential performance debugging complexity. The ROI comes from faster feature development and better client experiences.
Governance: Need clear processes for schema reviews, breaking change policies, and deprecation timelines. Without governance, the schema becomes a mess.
Senior How do you monitor and observe a GraphQL API in production?
1. Operation-level metrics: Track latency, error rate, and throughput per named operation (not per endpoint, since GraphQL has one).
2. Resolver tracing: Apollo's tracing extension reports execution time per resolver, helping identify slow resolvers.
3. Query complexity tracking: Log query complexity scores to identify expensive queries.
4. Client awareness: Track which clients send which queries (via operation names, client headers).
5. Error tracking: Aggregate errors by path (e.g., user.posts.author) to identify failing resolvers.
Tools: Apollo Studio, Grafana + Prometheus, DataDog, custom Apollo plugins that emit metrics.
Error Handling
Mid How does error handling differ in GraphQL vs REST?
REST uses HTTP status codes (404, 401, 500, etc.). GraphQL always returns HTTP 200. Errors are in the response body under an "errors" array alongside the "data" object. Crucially, GraphQL supports partial success — some fields can succeed while others fail. Each error includes message, path (which field failed), locations (line/column in query), and extensions (custom error codes).
Senior What is null bubbling in GraphQL?
When a non-null field (marked with !) returns null (e.g., due to an error), GraphQL can't return null for that field. Instead, it "bubbles" the null up to the nearest nullable parent. If user.name is String! and fails, the entire user field becomes null (if it's nullable). If user is also non-null, it continues bubbling up. This can cascade and null out large portions of your response. This is why many production schemas prefer nullable fields for better error resilience.
Advanced
Senior What are custom scalars and when would you use them?
Custom scalars extend GraphQL's built-in types (String, Int, Float, Boolean, ID) with domain-specific types like DateTime, Email, URL, JSON, UUID. You define how they serialize (server to client), parse values (client to server via variables), and parse literals (inline in query). Use them for validation (Email scalar rejects invalid emails at the schema level) and documentation (the schema clearly shows what type of string is expected).
Senior What are schema directives and how do they work?
Directives are annotations that modify schema behavior. Built-in: @deprecated, @skip, @include. Custom directives like @auth(requires: ADMIN) or @cacheControl(maxAge: 300) are defined in the schema and implemented as transformer functions that wrap resolvers. They're powerful for cross-cutting concerns (auth, caching, validation, rate limiting) without cluttering resolver logic. In Apollo Server v4, they're implemented via @graphql-tools/schema directive transformers.
Senior Explain the difference between code-first and schema-first approaches.
Schema-first (SDL-first): Write the schema in .graphql files, then implement resolvers that match. Pros: schema is the contract, readable, tool-friendly. Cons: schema and resolvers can drift apart.
Code-first: Define types programmatically (e.g., with Nexus, TypeGraphQL, or Pothos). The schema is generated from code. Pros: type-safe, no drift between schema and resolvers, better refactoring. Cons: harder to read, schema is implicit.
In practice: Schema-first is more popular for its readability. Code-first is preferred in TypeScript-heavy projects where type safety is paramount. Tools like GraphQL Code Generator bridge the gap by generating TypeScript types from SDL schemas.
Mid How do you handle file uploads in GraphQL?
GraphQL doesn't natively support file uploads. Common approaches:
1. graphql-upload: Implements the GraphQL Multipart Request Spec. Adds an Upload scalar that handles multipart form data. Simple but not suitable for large files.
2. Presigned URLs (recommended): Mutation returns a presigned upload URL (S3/GCS), client uploads directly to storage, then sends the URL back to the server. Better for large files and doesn't burden the GraphQL server.
3. Separate REST endpoint: Use a dedicated POST /upload endpoint for files. Pragmatic and simple.
Lead What is a GraphQL Gateway / BFF pattern?
A BFF (Backend For Frontend) is a server that sits between clients and backend services. The GraphQL server acts as the BFF: it exposes a clean, client-focused GraphQL API, and internally calls REST APIs, gRPC services, databases, etc. Benefits: clients get a tailored API, backend services stay simple and domain-focused, and you have a single place for cross-cutting concerns (auth, caching, monitoring). This is the most common production pattern for GraphQL.
Mid What are GraphQL variables and why should you always use them?
Variables are dynamic values passed separately from the query string. Instead of string interpolation (which is vulnerable to injection and prevents caching), you define typed variables: query GetUser($id: ID!) { user(id: $id) { name } } and pass values as JSON: { "id": "123" }. Benefits: 1. Prevents injection attacks. 2. Enables query caching (same query string, different variables). 3. Type validation at the schema level. 4. Required for persisted queries.
Senior How does GraphQL handle backward compatibility?
GraphQL's type system enables graceful evolution without versioning. Additions are always safe: new fields, new types, new enum values, new arguments with defaults. Removals/changes are breaking: removing fields, changing types, removing enum values. The process is: 1. Mark field @deprecated. 2. Add the replacement field. 3. Monitor usage of deprecated field. 4. Remove when usage hits zero. Tools like Apollo Studio track field usage across all clients, making this data-driven.
Senior What is the "info" argument in resolvers used for?
The info argument contains the parsed AST of the incoming query and schema metadata. Advanced uses include:
1. Look-ahead optimization: Check which fields were requested to optimize database queries. If posts was requested on a user, eagerly join it in the SQL query instead of relying on a separate resolver.
2. Complexity analysis: Calculate query cost based on requested fields.
3. Generating efficient SQL: Libraries like graphql-fields or graphql-parse-resolve-info extract selected fields from info to build optimized SELECT statements with only needed columns.
Lead Compare Apollo Server, Yoga, Mercurius, and Hasura.
Apollo Server: Most popular, feature-rich, strong ecosystem (Apollo Studio, Federation). Heavy but well-documented.
GraphQL Yoga: By The Guild. Lightweight, built on standard Request/Response. Plugin-based, great for serverless. Supports Envelop plugins ecosystem.
Mercurius: Built on Fastify. Extremely fast, JIT compilation, great for performance-critical applications. Less ecosystem than Apollo.
Hasura: Instant GraphQL API from your database. Auto-generates schema, resolvers, subscriptions. Great for CRUD-heavy apps. Custom logic via Actions (webhooks) or Remote Schemas. Less flexible for complex business logic.
Choice depends on: team size (Apollo for large teams), performance needs (Mercurius), speed of development (Hasura), and ecosystem requirements.
Mid Why do mutations execute sequentially but queries execute in parallel?
Queries are read-only and have no side effects, so running them in parallel is safe and faster. Mutations have side effects (creating, updating, deleting data), so they must run in order to ensure predictable outcomes. If you send three mutations, the second might depend on the first's result. The GraphQL spec guarantees mutations execute top-to-bottom. This is important for operations like "create user, then create their profile" in a single request.
VP What is the total cost of ownership for a GraphQL API?
Initial costs: Training, tooling setup (schema registry, monitoring), migration from existing APIs.
Ongoing costs: Schema governance, performance monitoring (N+1 detection, query complexity), client library maintenance, federation infrastructure (if applicable).
Benefits that offset costs: Faster frontend development (no waiting for backend endpoint changes), reduced bandwidth (especially mobile), self-documenting API (schema is the docs), fewer integration issues (strong typing catches errors at build time).
Hidden costs: Debugging is harder (single endpoint, must parse query), error tracking requires GraphQL-aware tools, caching requires more thought than REST.
ROI is typically positive for teams with 3+ frontend developers or 2+ client platforms (web + mobile).