01 What is GraphQL
Section 01

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.

Key Insight

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.

Client
React, Mobile, etc.
GraphQL Server
Single /graphql endpoint
Resolvers
Fetch data logic
Data Sources
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

GraphQL
// "Hey server, give me user 1's name
// and their posts' titles"
{
  user(id: 1) {
    name
    posts {
      title
    }
  }
}

Server Returns Exactly This

JSON Response
{
  "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.

Section 02

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)

HTTP
// 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)

GraphQL
# 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

When to Use What? (VP-Level Decision)

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.

Section 03

Core Concepts

The fundamental building blocks that everything in GraphQL is built upon.

S

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).

GraphQL SDL schema.graphql
# ===== 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]
Production Rule of Thumb

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.

Section 04

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.

GraphQL SDL
# 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
}
GraphQL Query
# 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
    }
  }
}
Why Cursor-Based Over Offset?

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.

GraphQL SDL
# 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 @.

GraphQL
# 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")
}
Section 05

Queries & Mutations Deep Dive

Everything about reading and writing data through GraphQL.

Query Anatomy

GraphQL
# === 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

GraphQL
# === 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 }
  }
}
Section 06

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).

GraphQL
# 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
Production Consideration

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.

Section 07

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:

JavaScript
// 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:

JavaScript
// 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 }
      });
    },
  },
};
The N+1 Problem

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).

Section 08

Node.js Implementation

Complete backend setup with Apollo Server, Express, Prisma, and JWT authentication.

Project Setup

BashTerminal
# 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

Folder 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)

Prismaprisma/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

JavaScriptsrc/schema/typeDefs.js
// 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

JavaScriptsrc/middleware/auth.js
// 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)

JavaScriptsrc/utils/dataLoaders.js
// 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)

JavaScriptsrc/schema/resolvers/index.js
// 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)

JavaScriptsrc/index.js
// 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

Env.env
# 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
Section 09

React Frontend

Complete frontend setup with Apollo Client, hooks, caching, and optimistic UI.

Project Setup

Bash
# 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

JavaScriptsrc/lib/apolloClient.js
// 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)

JavaScriptsrc/graphql/operations.js
// 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)

JSXsrc/App.jsx
// 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>
  );
}
JSXsrc/components/PostList.jsx
// 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>
  );
}
JSXsrc/components/CreatePost.jsx
// 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>
  );
}
JSXsrc/components/PostComments.jsx
// 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>
  );
}
Section 10

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.

JavaScriptAuthorization patterns
// 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,
  },
});
Section 11

Error Handling

GraphQL handles errors differently from REST. Understanding this is critical.

Critical Difference from REST

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!).

JSONError Response Structure
{
  // 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"
      }
    }
  ]
}
JavaScriptServer-side error throwing
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
  },
});
Section 12

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.

JavaScript
// 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.

JavaScript
// 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);
Section 13

Production Architecture

How companies like GitHub, Shopify, and Netflix run GraphQL in production.

Production Architecture Diagram

Architecture

                    ┌─────────────┐
                    │   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

JavaScript
// ===== 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.
Section 14

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.

Section 15

Testing GraphQL

Unit testing resolvers, integration testing the full API, and mocking on the frontend.

Backend: Integration Testing

JavaScript__tests__/posts.test.js
// __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

JSXPostList.test.jsx
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();
});
Section 16

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.

Architecture

                    ┌─────────────┐
                    │   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)   │
    └──────────┘    └──────────┘    └────────────┘
GraphQL SDLUsers Subgraph
# Users subgraph (owned by Team A)
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

type Query {
  user(id: ID!): User
}
GraphQL SDLPosts Subgraph
# 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!
}
VP Decision: When to Federate

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.

Section 17

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).