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.

What is itGraphQL is a query language for APIs and a runtime for executing those queries against a typed schema. Created by Facebook in 2012 and open-sourced in 2015, it was born out of frustration with REST's chatty round-trips and over-fetching on mobile clients. Instead of many endpoints each returning a fixed shape, GraphQL exposes a single endpoint (typically /graphql) where the client sends a query, mutation, or subscription describing exactly the fields it wants, and the server returns a JSON response shaped identically to the query. It's transport-agnostic (HTTP, WebSocket, even gRPC) and language-agnostic — servers exist in every major language.
Key features
  • Strongly typed schema: Every field has a type (String, Int, Float, Boolean, ID, custom objects, enums, interfaces, unions). Non-nullable types use ! (e.g. String!).
  • Single endpoint: No URL sprawl — one POST /graphql handles everything.
  • Client-specified queries: Clients declare the exact shape of data they want, eliminating over/under-fetching.
  • Introspection: The schema is queryable at runtime, enabling tools like GraphiQL, Apollo Studio, and code generators.
  • Three operation types: query (read), mutation (write), subscription (real-time push).
How it differs
  • vs REST: REST has many endpoints returning fixed shapes; GraphQL has one endpoint returning exactly what you ask for. REST versions via /v1, /v2; GraphQL evolves via schema deprecation.
  • vs gRPC: gRPC uses Protocol Buffers over HTTP/2 with generated stubs — very fast, but requires codegen and is harder to explore. GraphQL is more flexible for UI clients and self-describing.
  • vs SOAP: SOAP is XML-based, verbose, with WSDL contracts. GraphQL is JSON, lightweight, with a more ergonomic schema language (SDL).
  • vs OData: OData is a Microsoft standard for REST with query params like $filter, $select. GraphQL's SDL and tooling ecosystem are far richer.
Why use itGraphQL shines when you have many clients with different data needs (web, iOS, Android, TV, watch), complex relational data where REST would require many round trips, or rapidly evolving product teams that need to ship UI changes without backend redeploys. The self-documenting schema acts as a contract between frontend and backend, and introspection powers code generation (graphql-codegen) giving type-safe clients automatically.
Common gotchasThe N+1 query problem is GraphQL's most infamous pitfall — a naive resolver for users { posts { comments } } can fire thousands of DB queries. Fix with DataLoader batching. Other traps: unbounded query depth enabling DoS attacks, exposing sensitive fields via introspection in production, and the fact that HTTP caching (which REST gets for free) requires explicit work with persisted queries or APQ.
Real-world examplesMeta/Facebook (creator, powers the entire news feed), GitHub (public v4 API), Shopify (Storefront and Admin APIs), Netflix (Studio APIs and federation), Airbnb, Twitter/X, Pinterest, Coursera, The New York Times, Atlassian, PayPal, Walmart, Intuit, Expedia, KLM.
?

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.

What is itREST (Representational State Transfer) is an architectural style defined by Roy Fielding in 2000 that models resources as URLs, uses HTTP verbs (GET, POST, PUT, PATCH, DELETE) for actions, and relies on HTTP status codes and caching. GraphQL, in contrast, is a query language and runtime where the client sends a single typed document to one endpoint and gets back only the fields it requested. Both solve the same problem — client-server data exchange — but make opposite tradeoffs around flexibility vs simplicity.
Key differences
  • Endpoints: REST has many (/users, /users/:id, /users/:id/posts); GraphQL has one (/graphql).
  • Data fetching: REST returns fixed payloads — you often get too much or too little. GraphQL lets the client pick fields: { user(id:1) { name email } }.
  • Versioning: REST uses /v1, /v2 URL segments. GraphQL evolves by adding fields and marking old ones @deprecated.
  • Type system: REST is untyped over the wire (OpenAPI/Swagger is bolted on). GraphQL has a built-in, enforced SDL schema.
  • Caching: REST gets HTTP caching (ETags, Cache-Control) for free. GraphQL needs client-side normalized caches (Apollo, Relay) or persisted queries with GET.
  • Error handling: REST uses HTTP status codes (404, 500). GraphQL always returns 200 OK with an errors array in the body.
When REST winsPublic APIs that need broad tool support, file uploads/downloads, CDN-friendly content, simple CRUD microservices, webhook callbacks, and anything where HTTP caching matters (product catalogs, blog posts). REST is also the right choice when clients are out of your control and you want them to "just use curl".
When GraphQL winsMobile apps that need to minimize round-trips on slow networks, complex relational UIs (dashboards, feeds, social graphs), BFF (Backend-For-Frontend) layers that aggregate multiple microservices, and teams where frontend devs need to evolve the data shape without backend redeploys. The GitHub API v4 is the textbook example: query { repository(owner:"facebook", name:"react") { issues(first:10) { nodes { title author { login } } } } } in one request.
Common misconception"GraphQL replaces REST" — it doesn't. Many production systems use both: REST for simple CRUD and internal services, GraphQL as a BFF layer exposed to clients. Shopify, GitHub, and Stripe maintain both APIs for different audiences.
Real-world examplesCompanies using both: GitHub (v3 REST + v4 GraphQL), Shopify (Admin REST + Storefront GraphQL), Yelp, Coursera. GraphQL-first: Facebook, Airbnb Home (internal). REST-first: Stripe, Twilio, AWS (most services).
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.

What is itThe GraphQL type system is defined in SDL (Schema Definition Language) — a small DSL where you declare the shape of your API. Every GraphQL server has a schema rooted at three special types: Query (reads), Mutation (writes), and Subscription (real-time). The schema is the contract between client and server — it's validated at schema build time, queries are validated against it before execution, and tools use it to generate docs, types, and clients.
Key building blocks
  • Scalar types: Built-in String, Int, Float, Boolean, ID. Custom scalars like DateTime, JSON, EmailAddress.
  • Object types: type User { id: ID! name: String! posts: [Post!]! } — the meat of a schema.
  • Enums: enum Role { ADMIN USER GUEST } — fixed set of values.
  • Interfaces: Shared fields across types — interface Node { id: ID! }.
  • Unions: "Either/or" types — union SearchResult = User | Post | Comment.
  • Input types: Used for mutation arguments — input CreateUserInput { name: String! email: String! }.
  • Non-null & lists: ! means required, [T] means list, [T!]! means non-null list of non-null items.
  • Directives: Metadata on fields — @deprecated, @skip, @include, plus custom ones like @auth.
Operations vs typesClients send operations (query, mutation, subscription) that reference the schema's types. Queries are read-only and can be parallelized by the server; mutations run sequentially to guarantee order; subscriptions stream data over WebSocket (or SSE). Fragments (fragment UserFields on User { id name }) let clients reuse field selections across queries.
How it differs
  • vs REST/OpenAPI: OpenAPI describes existing REST endpoints after the fact; SDL defines the API contract upfront and is enforced at runtime.
  • vs gRPC/Protobuf: Protobuf is similar (typed schema, codegen) but binary and RPC-style. GraphQL is text-based, query-flexible, and more introspection-friendly.
  • vs TypeScript interfaces: TS types only exist at build time; GraphQL types are enforced by the runtime.
Why it mattersThe schema is the single source of truth. Frontend teams generate TypeScript types from it (graphql-codegen); backend teams implement resolvers against it; QA uses it to write integration tests; docs tools like Apollo Studio and GraphiQL render it automatically. A well-designed schema decouples frontend and backend teams and enables parallel work.
Real-world examplesThe GitHub v4 API schema is public and viewable in GraphiQL — explore it to see interfaces (Node, Actor), unions (IssueOrPullRequest), and the Relay-style pagination (Connection/Edge). Shopify's Admin API and Stripe's GraphQL (beta) are other excellent references.
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.

What is itSchema design is the art of modeling your domain in GraphQL's type system so it's ergonomic for clients, performant for the server, and evolvable without breaking changes. Because the schema is a public contract, early decisions compound — a poorly modeled field can live forever (or force painful deprecations). Senior GraphQL engineers treat schema design with the same rigor as database normalization.
Key patterns
  • Node interface (Relay spec): Every entity implements interface Node { id: ID! } — enables global object identification and cache normalization.
  • Connection/Edge pagination: Instead of posts: [Post!]!, use posts(first:10, after:"cursor"): PostConnection with edges { cursor node { ... } } and pageInfo.
  • Input types for mutations: Wrap arguments in a single input type — createUser(input: CreateUserInput!): CreateUserPayload. Makes evolution easier.
  • Payload types for mutations: Return { user, errors, clientMutationId } rather than just the user — lets you surface validation errors without HTTP errors.
  • Nullable by default: Fields that could legitimately be missing should be nullable; use ! only when you're sure.
  • Schema stitching vs federation: For splitting schemas across services.
How it differs from DB modelingA GraphQL schema is a client-facing view, not a database schema. You should not blindly mirror your tables. Hide internal fields (password_hash, deleted_at), compose across tables into natural objects, and expose computed fields (fullName, isFollowing) as first-class schema members. Think "what does the UI need?" not "what does Postgres store?".
Common gotchasOver-nesting: Deeply nested types make queries slow and hard to cache. Abuse of unions: Clients have to write ... on TypeA / ... on TypeB for every branch. Leaky abstractions: Exposing DB IDs (user_id) instead of opaque ID scalars. Boolean explosion: Five Boolean flags that should be an enum. Breaking changes: Renaming or removing fields — use @deprecated(reason: "Use newField instead").
Why use these patternsRelay's Node + Connection specs aren't just Facebook-isms — they solve real problems. Global IDs enable normalized caches; connections enable cursor pagination that's stable under inserts; input/payload types enable forward-compatible mutations. Following them also means your schema works out-of-the-box with Relay, Apollo Client, and most codegen tools.
Real-world examplesGitHub v4 is the gold standard for Relay-style schemas. Shopify publishes schema design guidelines and uses pagination cursors religiously. Apollo's "Principled GraphQL" and the Relay specification are the canonical references. Tools: graphql-eslint, graphql-schema-linter.

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.

What is itQueries and mutations are the two main operation types a client sends to a GraphQL server. A query is read-only and idempotent — the server can execute fields in parallel. A mutation is for writes — fields run sequentially to preserve order. Both use the same SDL syntax on the server side; the distinction is a contract: "this operation may change server state, so run it carefully". The client encodes operations in a document like query GetUser($id: ID!) { user(id:$id) { name } }.
Key features
  • Variables: Never string-concatenate values. Use $id: ID! in the operation and pass { "id": "1" } separately — prevents injection and enables caching.
  • Aliases: { me: user(id:"1") { name } them: user(id:"2") { name } } — request the same field twice under different names.
  • Fragments: fragment UserCard on User { id name avatar } — reusable field sets across queries.
  • Directives: @skip(if: $hide) and @include(if: $show) conditionally include fields.
  • Operation names: query GetUser { ... } — required for persisted queries, telemetry, and debugging.
Mutations in depthMutations return data like queries — it's idiomatic to return the modified object so clients can update their cache without a refetch. Best practice is the Input/Payload pattern: mutation { createPost(input: {title:"hi", body:"..."}) { post { id title } userErrors { field message } } }. The userErrors array lets you return validation errors as data instead of throwing.
How it differs
  • vs REST: REST uses HTTP verbs (GET/POST/PUT/DELETE) and URL paths. GraphQL uses query/mutation keywords in the body and always POSTs to /graphql (or GETs for persisted queries).
  • vs gRPC: gRPC methods are single RPC calls with fixed request/response protobufs. GraphQL lets the client pick fields and compose multiple reads in one request.
  • vs SQL: Surface-level similar (declarative, field-selection) but GraphQL runs resolvers against arbitrary backends, not a single DB.
Common gotchasMutation ordering: The spec guarantees mutations run sequentially, but only at the top level. Nested mutations are not a thing. Error semantics: Throwing in a mutation resolver returns partial data with errors — clients must handle this. Cache invalidation: After a mutation, Apollo/Relay need to know which queries to refetch or update. Unbounded queries: Without depth/complexity limits, a malicious client can send { user { friends { friends { friends { ... } } } } } and melt your DB.
Real-world examplesGitHub's v4 queries: query { viewer { repositories(first:10) { nodes { name stargazerCount } } } }. Shopify Storefront uses mutations like cartCreate and cartLinesAdd with input/payload pattern. Hasura auto-generates CRUD mutations like insert_user, update_user_by_pk, delete_user from your Postgres schema.

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.

What is itA subscription is the third GraphQL operation type (alongside query and mutation). Unlike queries — which return a single response — a subscription is a long-lived stream: the client opens it once, and the server pushes data whenever something changes. Syntactically it looks like a query: subscription { messageAdded(channelId:"1") { id body author { name } } }. Under the hood, the transport is typically WebSocket using the graphql-ws protocol (the modern replacement for the deprecated subscriptions-transport-ws).
Key features
  • Server push: Data flows server→client on events — no polling.
  • Same SDL: type Subscription { messageAdded(channelId: ID!): Message! }.
  • PubSub backend: Servers use an event bus — in-memory for dev, Redis/NATS/Kafka/PostgreSQL LISTEN-NOTIFY for production.
  • AsyncIterator pattern: Resolvers return an async iterator that yields events.
  • Authorization per event: You can filter events per-subscriber based on auth context.
How it differs
  • vs REST + polling: Clients don't have to ask "anything new?" every N seconds. Subscriptions deliver changes the instant they happen, using ~zero bandwidth when idle.
  • vs WebSockets (raw): GraphQL subscriptions ride on WebSocket but add a typed schema, field selection, and integrate with your existing auth/resolvers.
  • vs Server-Sent Events (SSE): SSE is simpler (one-way HTTP stream) but limited to 6 concurrent streams per browser. graphql-sse exists but WebSocket is more common.
  • vs gRPC streaming: gRPC has bidirectional streams but requires HTTP/2 and protobuf.
  • vs MQTT: MQTT is lighter for IoT; subscriptions are richer for app UIs.
Why use itReal-time UIs: chat apps, collaborative editors (like Notion, Figma comments), live dashboards, stock tickers, sports scores, notification centers, multiplayer games, CI/CD build logs. If your UI has a "new message" badge that updates without refresh, subscriptions are a clean fit.
Common gotchasScaling: Each subscriber holds an open WebSocket — 100K concurrent subscribers means 100K sockets. You'll need sticky sessions or a stateful load balancer. PubSub bottleneck: In-memory PubSub doesn't work across multiple server instances; use Redis. Authorization drift: Auth is checked at subscribe time; if the user is revoked mid-session you must disconnect them. Unbounded streams: Filter events server-side so you don't push irrelevant data. CORS + WebSocket: Different headers than HTTP.
Real-world examplesGitHub (live issue updates in web UI), Hasura (live queries over Postgres LISTEN/NOTIFY), HashiCorp Waypoint, Apollo Studio, and most chat products built on Apollo Server. Libraries: graphql-ws, graphql-subscriptions, @graphql-yoga/subscription, Apollo Router with Redis PubSub.

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.

What is itA resolver is a function attached to a specific field in your GraphQL schema that is responsible for producing that field's value at query time. When a request arrives, the GraphQL execution engine walks the query tree and calls one resolver per field, passing four arguments: (parent, args, context, info). The parent is whatever the enclosing field's resolver returned; args are the field's arguments from the query; context is a per-request object usually containing the logged-in user, data loaders, and DB clients; info contains the AST, path, and selection set. Resolvers are where you talk to databases, REST APIs, caches, gRPC services, message queues — anything.
Key features
  • Default trivial resolver: If you don't define one, GraphQL uses (parent) => parent[fieldName], so scalar fields on plain JS objects "just work."
  • Composable chain: Each level of nesting (user { posts { comments } }) fires its own resolver, letting you join across multiple data sources.
  • Async by default: Resolvers can return a Promise, and the engine runs sibling resolvers in parallel.
  • Type-level resolvers: __resolveType and __resolveReference (for unions, interfaces, and federation).
  • Resolver chains via context: Inject DataLoaders, Prisma clients, Redis clients once and reuse them everywhere.
How it differs
  • vs REST controller: A REST controller produces the entire response; a GraphQL resolver produces one field. The engine composes the final shape for you.
  • vs gRPC method: gRPC handlers are 1-to-1 with RPC methods. GraphQL resolvers are 1-to-1 with fields, so a single query fans out to dozens of them.
  • vs ORM hook: Resolvers are not model methods — they live on the schema and can aggregate from anywhere.
The N+1 trapBecause each field gets its own resolver, naive code loads related data one row at a time. Rendering 50 posts with authors fires 1 query for posts and 50 queries for users. The fix is DataLoader (from Facebook) — per-request batching and deduplication. You wrap every DB call: context.userLoader.load(id). DataLoader collects all calls made in a single tick of the event loop, batches them into WHERE id IN (...), and caches by key for the rest of the request.
Why use itResolvers let you treat your GraphQL layer as a graph abstraction over any backend: one field can call Postgres, the next can hit Stripe, the next can read from Redis — and the client sees a single consistent type. They make it easy to stitch legacy systems together without writing a new REST facade.
Common gotchasN+1 queries (use DataLoader), forgetting to pass context from server setup, doing auth checks inline in every resolver instead of via directives/middleware, accidentally returning mismatched shapes (GraphQL throws at runtime), and blocking the event loop with synchronous CPU work.
Real-world examplesGitHub's v4 API has thousands of resolvers mapped over a unified graph. Shopify Storefront API uses resolvers to stitch product catalogs, inventory, and pricing. Airbnb and Netflix have resolver layers that aggregate dozens of microservices per query.

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.

What is itA production Node.js GraphQL backend is typically a small stack: a web server (Express, Fastify, Koa, or just the standalone Apollo server), a GraphQL engine (@apollo/server, graphql-yoga, or mercurius), a schema written in SDL or code-first with Nexus/Pothos/TypeGraphQL, resolvers that call a database client (Prisma, Drizzle, TypeORM, or plain pg), and a context function that parses the JWT and builds per-request DataLoaders. The whole thing mounts at a single endpoint — typically POST /graphql.
Server libraries
  • Apollo Server 4: The most popular; batteries-included; plugin system; Apollo Studio integration; works standalone or with Express.
  • GraphQL Yoga 5: Built on graphql-helix and envelop; smallest footprint; supports SSE, WebSocket, and file uploads out of the box.
  • Mercurius: Fastify-native; benchmarks as the fastest Node.js GraphQL server; built-in JIT compiler.
  • Helix/Envelop: Low-level plugin framework — roll your own.
  • express-graphql: Deprecated — don't start new projects on it.
Schema approaches
  • SDL-first: Write the schema as a .graphql string, wire resolvers by name. Simple, but types and resolvers drift.
  • Code-first (Pothos, Nexus, TypeGraphQL): Define types in TypeScript, generate the schema. Full type safety between schema and resolvers.
  • Generated from DB (PostGraphile, Hasura): Point at Postgres, get a GraphQL API instantly.
How it differs
  • vs Express REST: One endpoint, one middleware — no router file growing to thousands of lines. Validation is automatic from the schema.
  • vs NestJS REST: NestJS also supports GraphQL via @nestjs/graphql (built on Apollo). Decorators replace SDL.
  • vs Go/Python GraphQL: Node's async nature is a natural fit for resolver fan-out. The ecosystem (Apollo, DataLoader, Relay) originated here.
Production checklistEnable query depth limit, query complexity analysis, persisted queries (APQ), disable introspection and /playground in production, set CORS strictly, enable gzip, add request timeouts, wire @apollo/server/plugin/landingPage/prod, and integrate tracing (Apollo Studio, OpenTelemetry, Datadog APM).
Real-world examplesMedium and The New York Times run Apollo Server in Node. Artsy's Metaphysics is one of the oldest open-source Apollo servers. Shopify's Hydrogen storefront uses GraphQL Yoga.

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.

What is itThe React + GraphQL frontend pattern means replacing ad-hoc fetch/axios calls with a GraphQL client that knows how to parse a query, send it to /graphql, read the response, and — crucially — maintain a normalized in-memory cache of all fetched entities keyed by __typename + id. When one component mutates a record, every other component that reads that record from the cache re-renders automatically. You write queries with the gql template tag: const GET_ME = gql\`query Me { me { id name } }\`, then consume them through hooks like useQuery, useMutation, and useSubscription.
Client libraries
  • Apollo Client 3: The default. Large, feature-rich, normalized cache, devtools, good docs, 40kb gzipped.
  • Relay: Facebook's client. Requires a compiler, strict conventions (connections, fragments), and a Relay-compliant server. Best cache in the industry, but steepest learning curve.
  • urql: Formidable Labs. Lightweight (~12kb), exchange-based architecture, document cache by default with optional normalized cache.
  • graphql-request: Tiny fetch wrapper for scripts and SSR — no cache.
  • TanStack Query + gql-request: Popular "bring your own client" pattern.
Key features
  • Normalized cache: Entities stored once, referenced by {__ref:"User:1"}.
  • Optimistic UI: Mutations can write to the cache instantly, then confirm or rollback.
  • Refetch / cache policy: cache-first, network-only, cache-and-network, no-cache.
  • Fragment colocation: Each component declares exactly the fields it needs; the parent composes them.
  • Codegen: @graphql-codegen/cli generates fully typed hooks from your schema + queries.
How it differs
  • vs REST with TanStack Query: You describe the shape you want inline in the query, not by choosing an endpoint. The cache is entity-based, not URL-based.
  • vs SWR: SWR is URL-centric. Apollo/Relay know the entity graph and can update unrelated queries automatically.
  • vs Redux: Your server cache is your global state. You rarely need Redux for server data.
Common gotchasMissing id fields break normalization (cache can't dedupe); forgetting refetchQueries or update after mutations leaves stale UI; heavy cache-and-network policies cause layout thrash; large fragments cause over-fetching; and Apollo's cache is a minefield if you use pagination without the relayStylePagination field policy.
Real-world examplesFacebook (Relay), GitHub.com (Relay), Airbnb (Apollo), Medium (Apollo), Twitch (Apollo), and nearly every Shopify storefront built on Hydrogen.

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.

What is itAuthentication (authN) answers "who are you?" and is typically handled outside GraphQL: HTTP middleware parses a Authorization: Bearer <jwt> header, verifies the signature, loads the user, and injects it into the resolver context. Authorization (authZ) answers "what are you allowed to do?" and lives inside GraphQL: individual resolvers, custom @auth directives, or schema-level middleware (graphql-shield) check the user's role, team, or field-level permissions before returning data. GraphQL has no built-in auth — the community settled on JWTs and context injection as the idiomatic pattern.
Common approaches
  • JWT in Authorization header: Stateless, scales horizontally, works with mobile and third-party clients.
  • HTTP-only cookie sessions: Safer from XSS. Combine with CSRF tokens for browser clients.
  • OAuth/OIDC: Auth0, Clerk, Supabase Auth, Cognito, Firebase Auth — delegate authN, verify JWTs in the context function.
  • API keys: For server-to-server traffic.
Authorization patterns
  • Resolver guards: if (!ctx.user) throw new GraphQLError('Unauthenticated', {extensions:{code:'UNAUTHENTICATED'}}).
  • Schema directives: type Query { admins: [User!]! @auth(role: ADMIN) } — clean, declarative, enforced by middleware.
  • graphql-shield: Compose rules as a tree matching the schema tree. Rules are memoized per request.
  • Field-level ACL: Return null for forbidden fields rather than erroring — common in public APIs.
  • Row-level security (RLS): Push authZ into Postgres with Hasura/PostGraphile — the DB enforces it.
How it differs
  • vs REST: REST usually guards an entire endpoint; GraphQL has to guard every field because one query touches many.
  • vs gRPC: gRPC handles auth via interceptors and mTLS. GraphQL relies on HTTP-layer auth plus resolver logic.
Common gotchasRelying on introspection leaks (turn introspection off in prod), forgetting that mutations need CSRF protection when using cookies, returning 200 + errors that leak user existence ("email not found" vs "wrong password"), and not scoping queries to the current userposts(id:"123") must check ownership server-side. Never trust anything from args for authZ.
Real-world examplesGitHub uses OAuth-issued JWTs with scopes per token. Shopify Admin API uses session tokens + HMAC. Hasura enforces row-level permissions via JWT claims mapped to Postgres RLS policies.

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.

What is itGraphQL errors are first-class citizens in the response body, not HTTP status codes. Every response — success, partial failure, or catastrophe — returns HTTP 200 (unless the request was malformed) and a JSON body shaped { data, errors, extensions }. The errors array contains objects with message, path, locations, and an extensions bag you use for error codes like UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT, INTERNAL_SERVER_ERROR. Partial success is possible: a query for {me, admins} can return me and error on admins simultaneously.
Two schools of thought
  • Top-level errors (classic): Throw GraphQLError from a resolver. Apollo/urql surface it on the client. Good for unexpected errors (500, DB down, auth).
  • Errors as data (modern): Model expected errors as union types: type SignupResult = SignupSuccess | EmailTakenError | WeakPasswordError. Clients pattern-match with inline fragments. Much better UX — errors are typed, documented, and localizable.
Error codes conventionApollo ships a standard list under error.extensions.code: UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT, GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, PERSISTED_QUERY_NOT_FOUND, INTERNAL_SERVER_ERROR. Clients can branch on code rather than parsing error messages.
How it differs
  • vs REST: REST uses HTTP status (400, 401, 404, 500). GraphQL bakes error info into the body so multi-field queries can partially succeed.
  • vs gRPC: gRPC uses rich status codes + google.rpc.Status details. Similar "errors as data" spirit.
  • vs SOAP: SOAP has the infamous <Fault> envelope — verbose and rigid.
Common gotchasLeaking stack traces in production (format errors via formatError), returning different shapes for same error (break clients), treating GraphQL 200 responses as "success" in HTTP-level monitoring (you miss every business error), and forgetting that nullable fields absorb errors silently — if a non-null field errors, the error bubbles to the nearest nullable parent, potentially wiping an entire subtree.
Real-world examplesShopify uses the errors-as-data pattern extensively — every mutation returns a userErrors array. GitHub uses rich error extensions with documentation URLs. Stripe's GraphQL experiments embed idempotency keys and retry hints in extensions.
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.

What is itGraphQL performance is primarily about two things: taming the resolver fan-out so one query doesn't issue hundreds of DB round trips, and caching across layers (CDN, server, client) even though GraphQL uses a single POST /graphql endpoint that's invisible to traditional HTTP caches. The toolkit includes DataLoader for batching, query complexity analysis to reject abusive queries, Automatic Persisted Queries (APQ) to shrink payloads and enable CDN caching, response caching with @cacheControl hints, and execution-level JIT compilers.
The N+1 problemThe canonical GraphQL pitfall. A query like posts { author { name } } fetches N posts and then fires N queries for authors. DataLoader solves this by batching all .load(id) calls made in the same tick of the event loop into a single WHERE id IN (...) query and caching results per-request. Every serious Node.js GraphQL server uses DataLoader; Ruby has graphql-batch, Python has aiodataloader, Go has dataloaden.
Caching layers
  • Client cache: Apollo/Relay normalized in-memory cache.
  • Persisted queries + CDN: Replace the query string with a hash; GET /graphql?id=abc becomes cacheable by Cloudflare/Fastly.
  • Server response cache: @apollo/server-plugin-response-cache with Redis — keyed by query hash + variables + auth scope.
  • Per-field cache hints: type Product @cacheControl(maxAge: 60) { ... }.
  • DataLoader per-request cache: Eliminates duplicate fetches within one query.
Query complexity & depth limitsMalicious or careless queries can blow up the server. graphql-depth-limit rejects queries nested deeper than N levels. graphql-cost-analysis or graphql-query-complexity assigns a cost to each field (especially lists and connections) and rejects queries exceeding a budget — critical for public APIs.
How it differs
  • vs REST: REST benefits from free HTTP caching; GraphQL has to engineer it. But REST suffers from over-fetching which GraphQL solves natively.
  • vs gRPC: gRPC is faster on the wire (binary protobuf, HTTP/2) but lacks GraphQL's field selection.
Real-world examplesGitHub measures query points and rate-limits by cost instead of requests. Shopify returns a cost object in extensions so clients know how much budget they used. Facebook Relay uses query compilation + persisted IDs so clients never send the raw query at runtime.

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.

What is itA production GraphQL deployment looks very different from a tutorial. You have a CDN layer (Cloudflare/Fastly) caching persisted queries, an API gateway handling TLS termination, rate limiting, and auth, a pool of stateless GraphQL servers behind a load balancer, a Redis layer for response cache and pub/sub, your databases (Postgres, Mongo, ElasticSearch) usually accessed through DataLoader, and observability via Apollo Studio, OpenTelemetry, or Datadog. At scale you also add federation so multiple teams can own subgraphs independently.
Key components
  • Persisted query registry: Clients ship a hash; the gateway resolves it against a registry of known queries, blocking arbitrary queries.
  • Schema registry: Apollo Studio, GraphQL Hive, or Grafbase. Tracks schema versions, breaking changes, field usage.
  • Tracing: Apollo Tracing / OpenTelemetry emits per-resolver spans — you see exactly which field is slow.
  • Rate limiting by cost: Instead of "100 req/min", it's "10,000 points/hour" where each field has a cost.
  • Blue/green schema deploys: Via the registry — old and new schemas coexist during rollout.
How it differs
  • vs REST production: REST plays nicely with CDNs by default; GraphQL needs persisted queries. But REST has no equivalent of field-usage analytics, which lets you deprecate dead fields surgically.
  • vs gRPC: gRPC-web needs a proxy (Envoy); GraphQL runs over plain HTTP anywhere.
Deployment patterns
  • Monolith: Single GraphQL service — simplest, best for <100 engineers.
  • Schema stitching (legacy): Merge multiple schemas at the gateway — deprecated.
  • Apollo Federation: Subgraphs own types; router composes at runtime. Standard for large orgs.
  • BFF (Backend-for-Frontend): One GraphQL layer per client type (web, mobile) wraps internal REST/gRPC services.
Real-world examplesGitHub: Rails monolith exposing ~2000 types. Shopify: Ruby + custom federation gateway serving >1 trillion requests/year. Netflix: Java DGS (Domain Graph Service) federation, one of the largest federated graphs on earth. Airbnb: Migrated from REST to a federated GraphQL "Niobe" platform. Expedia, Wayfair, Glassdoor: all on Apollo Federation.

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.

What is itGraphQL's flexibility is also its attack surface. Because a single endpoint accepts arbitrary nested queries, the server has to defend against entirely new categories of abuse that REST never worried about: deeply nested recursive queries that blow the stack, high-cost query explosion (requesting a million connection nodes in one shot), introspection leakage (attackers download the entire schema), batching attacks (1000 login attempts in one HTTP request), field suggestion leakage ("did you mean secretField?"), and IDOR via exposed IDs in args.
Top risks
  • Query depth attacks: user { friends { friends { friends { ... } } } }. Fix: graphql-depth-limit at 7-10.
  • Query complexity / cost attacks: posts(first: 10000) { comments(first: 1000) }. Fix: graphql-query-complexity with per-field costs.
  • Introspection in prod: Leaks your entire schema. Fix: disable introspection: false or require a special header.
  • Batching attacks: Apollo supports query batching; attackers use it to bypass per-request rate limits. Fix: count operations, not HTTP requests.
  • Field suggestions: GraphQL errors reveal field names. Fix: NoSchemaIntrospectionCustomRule + suppress suggestion messages in prod.
  • IDOR: Never trust ID args. Always filter by ownerId = ctx.user.id server-side.
  • SQL injection via args: Use parameterized queries — same rules as REST.
  • CSRF: With cookies, enable SameSite=strict and a custom header requirement (apollo-require-preflight).
  • DoS via large uploads: Cap graphql-upload file sizes.
Defensive middlewaregraphql-shield, graphql-armor (bundles depth/cost/aliases/directives/tokens limits), graphql-depth-limit, graphql-query-complexity, persisted queries only in production, and envelope plugins like @escape.tech/graphql-armor-*.
How it differs
  • vs REST: REST's attack surface is a list of endpoints; GraphQL's is a query tree. You need cost-based rate limiting, not request-count.
  • vs SOAP: SOAP had WS-Security; GraphQL has no built-in security spec — you assemble it.
Common gotchasLeaving introspection on, trusting args.id without authZ, returning full error stack traces, allowing arbitrary query aliases to multiply work (a: me b: me c: me ...), and letting clients send queries longer than your allowed max.
Real-world examplesHackerOne and Shopify run active bug bounties on their GraphQL APIs — many CVEs have been found via introspection leaks and complexity attacks. GitHub documents their exact query cost algorithm publicly. The OWASP GraphQL Cheat Sheet is the canonical reference.

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.

What is itTesting GraphQL spans four distinct levels: unit tests for pure resolver functions (mock the context, call the function directly), integration tests that spin up an in-process Apollo Server and run real queries against an in-memory or test DB, contract tests that diff schemas between consumer and provider to catch breaking changes, and frontend tests that use mocked responses (MockedProvider from Apollo, or MSW with handlers matching operation names).
Tools by layer
  • Unit (backend): Jest / Vitest — resolvers are plain functions.
  • Integration (backend): @apollo/server executeOperation API, or Supertest + real HTTP, with a test DB (Docker Postgres, pg-mem, SQLite).
  • Snapshot / schema: @graphql-inspector/cli diffs old vs new schema, fails CI on breaking changes.
  • Contract: Pact + @pact-foundation/pact GraphQL adapter.
  • Frontend: @apollo/client/testing MockedProvider, msw GraphQL handlers, Storybook interaction tests.
  • E2E: Playwright / Cypress hitting a real server.
  • Load testing: k6 or Artillery with a GraphQL plugin.
Patterns
  • Test-first schema changes: Write the query in a test, then make it pass — catches missing fields immediately.
  • Schema mocking with @graphql-tools/mock: Auto-generate fake resolvers from the schema; frontend works before backend ships.
  • Golden file tests: Store canonical responses in __snapshots__.
  • Operation-name mocks: Match MSW handlers by operationName, not URL.
How it differs
  • vs REST testing: You don't need to maintain a list of routes; the schema is the contract. One failing query can tell you dozens of fields are broken.
  • vs gRPC testing: gRPC has generated stubs; GraphQL has schema-typed hooks via codegen — similar ergonomics.
Common gotchasNot testing the context builder (auth bugs live there), mocking too deeply and missing real resolver bugs, snapshot tests failing on trivial schema additions, forgetting to test error paths (what does the client see when a nested field errors out?), and MockedProvider requiring exact query matches including variables.
Real-world examplesApollo's own test suite is a great reference — they test the server by running queries against it. Shopify uses a custom tool that replays production queries against staging to catch regressions. Relay ships a compiler that verifies every query in the codebase against the schema on every build.

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 itApollo Federation is a specification and toolset for composing a single unified GraphQL API from multiple independent GraphQL services called subgraphs. Each subgraph is a full GraphQL server owned by a different team, and a stateless router (the Apollo Router, written in Rust) ingests each subgraph's schema, composes them into a supergraph, plans query execution across subgraphs, and returns a single response to the client. The client still sees POST /graphql and has no idea microservices exist underneath.
Key primitives
  • @key: type Product @key(fields: "id") declares an entity's primary key so other subgraphs can extend it.
  • @external / @requires / @provides: Describe which fields come from elsewhere and which dependencies a resolver needs.
  • Entity references: { __typename: "Product", id: "123" } — passed between subgraphs.
  • __resolveReference: The resolver that turns an entity reference into a full object on the owning subgraph.
  • Composition: rover supergraph compose merges subgraph schemas into a supergraph SDL.
  • Router: Rust binary that does query planning, fetches from subgraphs in parallel, and stitches results.
How it differs
  • vs Schema Stitching (legacy): Stitching runs merging at the gateway with no spec; federation has an official spec and query planner.
  • vs monolith GraphQL: Federation lets separate teams ship independently; a monolith requires one release train.
  • vs BFF-per-client: BFF duplicates logic per client; federation shares one graph.
  • vs gRPC microservices: gRPC services don't compose into a client-facing unified type system.
When to adoptYou have multiple teams, a shared core entity graph, and want to avoid a single GraphQL monolith becoming a bottleneck. It's overkill for <5 services. Alternatives: Hive, GraphQL Mesh, WunderGraph, Nautilus.
Common gotchasCross-subgraph N+1 is harder to solve than within a single service; entity ownership disputes ("who owns User.email?"); subgraph schema drift breaks composition; requires a schema registry (Apollo Studio / GraphQL Hive); federated subscriptions are still limited; and the router adds one more hop in latency — benchmark before committing.
Real-world examplesNetflix DGS: hundreds of subgraphs in Java/Kotlin, one of the largest federated graphs in existence. Airbnb: Niobe platform, migrated from schema stitching. Expedia, Wayfair, Volvo, Amazon, Walmart all run Apollo Federation in production. The Apollo Router handles billions of operations per day across customers.

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.

What is itGraphQL interviews are not usually about exotic edge cases — they're about whether you can reason about schema design, resolver execution, caching, and operational tradeoffs versus REST. Expect a blend of conceptual questions (N+1, connections, federation), practical design questions ("design a Twitter-like feed in GraphQL"), and debugging ("this query is slow — why?"). Senior interviews drill into federation, persisted queries, and production concerns.
Topics interviewers love
  • Query vs mutation vs subscription: Know the operation types and the transport (HTTP for first two, WebSocket for the third).
  • N+1 and DataLoader: Guaranteed question. Explain batching + per-request cache.
  • Schema design: When to use interfaces, unions, enums, input types; Relay connections vs offset pagination.
  • Caching layers: Client cache, persisted queries + CDN, server response cache, DataLoader — and why GraphQL's single endpoint makes caching harder than REST.
  • Errors: HTTP 200 with errors array; errors-as-data pattern; partial responses.
  • Security: Depth limits, query cost, introspection, IDOR.
  • Federation: Entities, @key, __resolveReference, query planner.
  • Versus REST/gRPC/tRPC: Know when not to use GraphQL.
Design-round favorites
  • "Design a schema for a blog with posts, comments, tags, and authors." Look for: Relay-style pagination, interfaces for Node, input types for mutations.
  • "How would you handle infinite scroll?" edges/pageInfo/cursor (Relay connection spec).
  • "A query fires 500 DB queries — debug it." DataLoader missing.
  • "Should we use GraphQL for a new internal service?" Know the tradeoffs vs tRPC and REST.
Red flags candidates hitSaying "GraphQL is always better than REST" (it isn't), not knowing about N+1, confusing schema stitching with federation, thinking POST /graphql can't be cached (persisted queries fix this), believing introspection is safe in production, and skipping query complexity limits on a public API.
How it differs from REST interviewsREST interviews focus on HTTP verbs, status codes, REST maturity levels, and HATEOAS. GraphQL interviews focus on the graph model, schema evolution, and the operational machinery (DataLoader, persisted queries, federation) that REST doesn't need.
Companies that interview on GraphQLMeta (invented it — expect deep Relay/connection questions), GitHub, Shopify, Airbnb, Netflix, Expedia, Wayfair, PayPal, Coinbase, Atlassian, Intuit, and most modern startups with public APIs.

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

Section 18

CRUD: Mongoose & Prisma

Complete Create, Read, Update, Delete with both ORMs — every line explained.

What is itCRUD (Create, Read, Update, Delete) is the backbone of any API. In GraphQL, Queries handle the R (Read) and Mutations handle C, U, D. The resolvers inside these operations call your database layer — either Mongoose (MongoDB ODM) or Prisma (type-safe ORM for SQL/MongoDB). This section gives you a full, production-style implementation with both, line by line.
Mongoose vs Prisma
  • Mongoose: ODM for MongoDB. Schema-based, flexible documents, no migrations needed, great for unstructured/semi-structured data. You define schemas in JS/TS code.
  • Prisma: Type-safe ORM for PostgreSQL, MySQL, SQLite, MongoDB. Uses a .prisma schema file, generates a client with full autocompletion. Handles migrations automatically.
  • Key difference: Mongoose = NoSQL-first, schema-in-code, manual population. Prisma = SQL-first, schema-in-file, auto-generated client with relations.
When to use which
  • Mongoose: When using MongoDB, need flexible schemas, rapid prototyping, document-style data (logs, IoT, CMS content).
  • Prisma: When you want type safety, relational data, auto-migrations, or need to support multiple databases. Production apps with strict data integrity.
CRUD in GraphQLCreate = Mutation (e.g., createUser), Read = Query (e.g., users, user(id)), Update = Mutation (e.g., updateUser), Delete = Mutation (e.g., deleteUser). Each one maps to a resolver that calls the database.

Part 1: GraphQL CRUD with Mongoose (MongoDB)

Step 1: Project Setup

BashTerminal
# Create project folder and initialize
mkdir graphql-mongoose-crud && cd graphql-mongoose-crud
npm init -y

# Install core dependencies
npm install @apollo/server graphql express cors
# @apollo/server  → GraphQL server engine
# graphql         → GraphQL JS reference implementation
# express         → HTTP server framework
# cors            → Cross-Origin Resource Sharing middleware

# Install Mongoose (MongoDB ODM)
npm install mongoose
# mongoose → Object Document Mapper for MongoDB

# Dev dependencies
npm install -D nodemon
# nodemon → Auto-restarts server on file changes

Step 2: Mongoose Model

This defines how your data is stored in MongoDB. Think of it as your database table blueprint.

JavaScriptmodels/User.js
const mongoose = require('mongoose');
// ↑ Import mongoose — the library that lets us talk to MongoDB
//   using JavaScript objects instead of raw MongoDB commands

const userSchema = new mongoose.Schema({
// ↑ Create a new Schema — this defines the STRUCTURE of every
//   document in the "users" collection. Like a blueprint.

  name: {
    type: String,        // Data type is String
    required: true,      // This field MUST be provided (validation)
    trim: true,          // Removes whitespace from both ends before saving
  },
  // ↑ name field: required string, auto-trimmed

  email: {
    type: String,
    required: true,
    unique: true,        // Creates a unique INDEX in MongoDB — no duplicates allowed
    lowercase: true,     // Converts to lowercase before saving (Bob@Mail.com → bob@mail.com)
  },
  // ↑ email field: required, must be unique across all users, stored lowercase

  age: {
    type: Number,
    min: 0,              // Validation: age cannot be negative
    max: 150,            // Validation: age cannot exceed 150
  },
  // ↑ age field: optional number with min/max validation

  role: {
    type: String,
    enum: ['USER', 'ADMIN', 'MODERATOR'],  // Only these values are allowed
    default: 'USER',                          // If not provided, defaults to 'USER'
  },
  // ↑ role field: restricted to specific values using enum

  posts: [{
    type: mongoose.Schema.Types.ObjectId,  // Stores MongoDB ObjectId references
    ref: 'Post',                           // References the 'Post' model (for .populate())
  }],
  // ↑ posts: array of references to Post documents
  //   This creates a ONE-TO-MANY relationship (one user → many posts)
  //   We use .populate('posts') later to load the actual post data

}, {
  timestamps: true,
  // ↑ Automatically adds createdAt and updatedAt fields
  //   createdAt = when document was first created
  //   updatedAt = when document was last modified
  //   Both are managed by Mongoose automatically
});

module.exports = mongoose.model('User', userSchema);
// ↑ Create and export the Model
//   'User' → name of the model (MongoDB creates a 'users' collection)
//   userSchema → the schema we just defined
//   The model gives us methods like .find(), .findById(), .create(), etc.

Step 3: Post Model

JavaScriptmodels/Post.js
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
    trim: true,
  },
  // ↑ title: required string, whitespace trimmed

  body: {
    type: String,
    required: true,
  },
  // ↑ body: the main content of the post

  published: {
    type: Boolean,
    default: false,
  },
  // ↑ published: defaults to false (draft state)
  //   Only published posts should be shown to other users

  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
  // ↑ author: reference to the User who created this post
  //   This is the "belongs to" side of the relationship
  //   required: true means every post MUST have an author

}, { timestamps: true });

module.exports = mongoose.model('Post', postSchema);
// ↑ Creates 'posts' collection in MongoDB

Step 4: GraphQL Type Definitions (Schema)

This is the contract between frontend and backend — what data can be queried and what mutations are available.

JavaScriptschema/typeDefs.js
const typeDefs = `#graphql

  # ============ TYPES (what the API RETURNS) ============

  type User {
    id: ID!              # Unique identifier (mapped from MongoDB's _id)
    name: String!        # ! means non-nullable — server guarantees a value
    email: String!       # User's email — always present
    age: Int             # No ! → nullable — age might not be provided
    role: String!        # USER, ADMIN, or MODERATOR
    posts: [Post!]!      # Array of posts — the array itself and each post are non-null
    createdAt: String!   # ISO date string from Mongoose timestamps
    updatedAt: String!   # Last modification date
  }
  # ↑ User type: defines the SHAPE of user data returned by queries
  #   This is NOT the database schema — it's the API contract
  #   Some DB fields (like password) are intentionally excluded

  type Post {
    id: ID!
    title: String!
    body: String!
    published: Boolean!
    author: User!        # The author field returns a full User object (not just an ID)
    createdAt: String!
    updatedAt: String!
  }
  # ↑ Post type: notice author returns User! (resolved via .populate())

  # ============ INPUT TYPES (what the client SENDS) ============

  input CreateUserInput {
    name: String!        # Required for creation
    email: String!       # Required for creation
    age: Int             # Optional
    role: String         # Optional — defaults to USER in the model
  }
  # ↑ input types are for ARGUMENTS — separate from output types
  #   This separation lets read shape differ from write shape
  #   e.g., User output has 'id', 'posts', 'createdAt' but input doesn't

  input UpdateUserInput {
    name: String         # All fields optional for updates (partial update)
    email: String        # Only send the fields you want to change
    age: Int
    role: String
  }
  # ↑ Update input: no ! on any field — all optional
  #   This enables PATCH-style updates (only change what you send)

  input CreatePostInput {
    title: String!
    body: String!
    published: Boolean
    authorId: ID!        # ID of the user creating the post
  }

  input UpdatePostInput {
    title: String
    body: String
    published: Boolean
  }

  # ============ QUERIES (READ operations) ============

  type Query {
    users: [User!]!
    # ↑ Returns all users — no arguments needed
    #   [User!]! → non-null array of non-null users
    #   Even if empty, returns [] not null

    user(id: ID!): User
    # ↑ Returns a single user by ID
    #   Returns User (nullable) — returns null if not found
    #   id: ID! → the ID argument is required

    posts: [Post!]!
    # ↑ Returns all posts

    post(id: ID!): Post
    # ↑ Returns a single post by ID
  }

  # ============ MUTATIONS (CREATE, UPDATE, DELETE) ============

  type Mutation {
    createUser(input: CreateUserInput!): User!
    # ↑ Creates a new user
    #   Takes CreateUserInput (required) → returns the created User
    #   The ! after User means it always returns a user (or throws error)

    updateUser(id: ID!, input: UpdateUserInput!): User
    # ↑ Updates an existing user
    #   Takes id (which user) + input (what to change)
    #   Returns User (nullable) — null if user not found

    deleteUser(id: ID!): User
    # ↑ Deletes a user by ID
    #   Returns the deleted user data (so frontend can update cache)
    #   Nullable — null if user didn't exist

    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post
    deletePost(id: ID!): Post
  }
`;

module.exports = typeDefs;
// ↑ Export so our Apollo Server can use this schema

Step 5: Resolvers (The Business Logic)

Resolvers are functions that actually fetch/modify data. Each field in the schema can have a resolver.

JavaScriptschema/resolvers.js
const User = require('../models/User');
const Post = require('../models/Post');
// ↑ Import our Mongoose models — these give us database methods
//   User.find(), User.findById(), User.create(), etc.

const resolvers = {

  // ============================================
  // QUERY RESOLVERS (READ operations)
  // ============================================
  Query: {

    // -------- GET ALL USERS --------
    users: async () => {
    // ↑ async because database calls return Promises
    //   No arguments needed — returns all users

      return await User.find().populate('posts');
      // ↑ User.find() → MongoDB equivalent of SELECT * FROM users
      //   Returns ALL documents in the users collection
      //
      //   .populate('posts') → Mongoose "join"
      //   The 'posts' field in User schema stores ObjectId references
      //   .populate() replaces those IDs with actual Post documents
      //   Without populate: posts: ["507f1f77bcf86cd799439011"]
      //   With populate:    posts: [{ title: "Hello", body: "..." }]
    },

    // -------- GET SINGLE USER BY ID --------
    user: async (_, { id }) => {
    // ↑ Resolver arguments: (parent, args, context, info)
    //   _ → parent (unused here since this is a root Query)
    //   { id } → destructure 'id' from the args object
    //   When client sends: query { user(id: "123") { name } }
    //   args = { id: "123" }

      return await User.findById(id).populate('posts');
      // ↑ User.findById(id) → finds one document by its _id field
      //   MongoDB equivalent: db.users.findOne({ _id: ObjectId(id) })
      //   Returns null if no user found (which is fine — our schema says User is nullable)
    },

    // -------- GET ALL POSTS --------
    posts: async () => {
      return await Post.find().populate('author');
      // ↑ Post.find() → get all posts
      //   .populate('author') → replace the authorId ObjectId
      //   with the full User document
    },

    // -------- GET SINGLE POST BY ID --------
    post: async (_, { id }) => {
      return await Post.findById(id).populate('author');
    },
  },

  // ============================================
  // MUTATION RESOLVERS (CREATE, UPDATE, DELETE)
  // ============================================
  Mutation: {

    // -------- CREATE USER --------
    createUser: async (_, { input }) => {
    // ↑ { input } → destructured from args
    //   input = { name: "John", email: "john@mail.com", age: 25 }
    //   (comes from CreateUserInput in our schema)

      const user = new User(input);
      // ↑ Create a new Mongoose document instance
      //   This does NOT save to the database yet
      //   It applies schema defaults (role: 'USER')
      //   and validations (required fields, enum values)

      await user.save();
      // ↑ Actually persist to MongoDB
      //   This is where validation runs
      //   If validation fails, it throws an error
      //   If email is duplicate, MongoDB throws a duplicate key error
      //   Mongoose also sets createdAt and updatedAt here

      return user;
      // ↑ Return the saved user document
      //   It now has an _id, createdAt, updatedAt
      //   Apollo Server automatically maps _id → id for the ID! field
    },

    // -------- UPDATE USER --------
    updateUser: async (_, { id, input }) => {
    // ↑ id = which user to update
    //   input = fields to change { name: "New Name" }

      const user = await User.findByIdAndUpdate(
        id,           // Find user with this _id
        input,        // Apply these changes (only provided fields)
        { new: true, runValidators: true }
        // ↑ Options:
        //   new: true → return the UPDATED document (default returns the OLD one)
        //   runValidators: true → still run schema validations on update
        //   Without runValidators, you could set age: -5 on update!
      ).populate('posts');
      // ↑ Also populate the posts for the returned user

      return user;
      // ↑ Returns updated user, or null if ID not found
    },

    // -------- DELETE USER --------
    deleteUser: async (_, { id }) => {

      const user = await User.findByIdAndDelete(id);
      // ↑ Find the user by ID and remove from database
      //   Returns the deleted document (so frontend can update cache)
      //   Returns null if user not found

      if (user) {
        await Post.deleteMany({ author: id });
        // ↑ CASCADE DELETE: also delete all posts by this user
        //   Without this, we'd have orphaned posts in the database
        //   MongoDB doesn't have foreign key constraints like SQL
        //   So we handle cascading manually
      }

      return user;
    },

    // -------- CREATE POST --------
    createPost: async (_, { input }) => {
    // ↑ input = { title, body, published, authorId }

      const { authorId, ...postData } = input;
      // ↑ Destructure: pull out authorId, keep the rest as postData
      //   postData = { title, body, published }
      //   authorId = "507f1f77bcf86cd799439011"

      const post = new Post({ ...postData, author: authorId });
      // ↑ Create Post document, set author to the user's ObjectId

      await post.save();
      // ↑ Save to MongoDB

      await User.findByIdAndUpdate(authorId, {
        $push: { posts: post._id }
      });
      // ↑ MAINTAIN THE RELATIONSHIP
      //   $push adds post._id to the user's posts array
      //   This is manual — MongoDB doesn't auto-sync relations
      //   Now user.posts = [...existing, newPostId]

      return await post.populate('author');
      // ↑ Populate the author field before returning
      //   So the response includes full author data, not just an ID
    },

    // -------- UPDATE POST --------
    updatePost: async (_, { id, input }) => {
      return await Post.findByIdAndUpdate(
        id,
        input,
        { new: true, runValidators: true }
      ).populate('author');
    },

    // -------- DELETE POST --------
    deletePost: async (_, { id }) => {
      const post = await Post.findByIdAndDelete(id);
      // ↑ Remove the post from database

      if (post) {
        await User.findByIdAndUpdate(post.author, {
          $pull: { posts: post._id }
        });
        // ↑ $pull removes the post ID from the user's posts array
        //   This keeps the relationship consistent
        //   Without this, user.posts would contain a dead reference
      }

      return post;
    },
  },

  // ============================================
  // FIELD-LEVEL RESOLVERS (resolve relationships)
  // ============================================
  User: {
    id: (parent) => parent._id.toString(),
    // ↑ MongoDB uses _id (with underscore)
    //   GraphQL schema uses id (without underscore)
    //   This resolver maps _id → id
    //   .toString() converts ObjectId to a string
  },

  Post: {
    id: (parent) => parent._id.toString(),
    // ↑ Same mapping for Post documents
  },
};

module.exports = resolvers;

Step 6: Server Setup (Putting It All Together)

JavaScriptindex.js
const { ApolloServer } = require('@apollo/server');
// ↑ Import Apollo Server — the GraphQL engine that parses queries,
//   validates them against our schema, and calls the right resolvers

const { expressMiddleware } = require('@apollo/server/express4');
// ↑ Middleware adapter to plug Apollo Server into Express
//   Apollo v4 doesn't run standalone with Express — it uses middleware

const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');

const typeDefs = require('./schema/typeDefs');
const resolvers = require('./schema/resolvers');
// ↑ Our GraphQL schema and resolver functions

async function startServer() {
// ↑ Wrap in async function because Apollo Server startup is async

  const app = express();
  // ↑ Create Express application instance

  const server = new ApolloServer({
    typeDefs,      // Our GraphQL schema (type definitions)
    resolvers,     // Our resolver functions
  });
  // ↑ Create Apollo Server with our schema + resolvers
  //   It validates that every type/field in typeDefs
  //   has a corresponding resolver (or uses default resolver)

  await server.start();
  // ↑ MUST call start() before applying middleware
  //   This initializes the schema, plugins, and internal caches

  app.use(
    '/graphql',              // Route: all GraphQL requests go to POST /graphql
    cors(),                  // Allow cross-origin requests (needed for frontends on different ports)
    express.json(),          // Parse JSON request bodies (GraphQL sends JSON)
    expressMiddleware(server)  // Mount Apollo as Express middleware
  );
  // ↑ Chain of middleware:
  //   1. CORS headers added
  //   2. JSON body parsed
  //   3. Apollo processes the GraphQL query

  await mongoose.connect('mongodb://localhost:27017/graphql-crud');
  // ↑ Connect to MongoDB
  //   'graphql-crud' is the database name (created automatically)
  //   In production, use an environment variable for the connection string

  console.log('Connected to MongoDB');

  app.listen(4000, () => {
    console.log('Server running at http://localhost:4000/graphql');
  });
  // ↑ Start HTTP server on port 4000
  //   Open http://localhost:4000/graphql in browser for Apollo Sandbox
}

startServer();
// ↑ Call the async startup function

Step 7: Testing Mongoose CRUD (Apollo Sandbox Queries)

Open http://localhost:4000/graphql in your browser. Apollo Sandbox loads automatically. Paste these queries/mutations.

GraphQLCREATE — Add a user
mutation {
  createUser(input: {
    name: "Yatin Dora"       # Required field
    email: "yatin@dev.com"   # Required, must be unique
    age: 25                   # Optional
    role: "ADMIN"             # Optional, defaults to USER
  }) {
    # ↓ Choose which fields you want back in the response
    id          # The generated MongoDB _id
    name        # Confirm the name was saved
    email       # Confirm email was lowercased
    age
    role
    createdAt   # Timestamp when created
  }
}
GraphQLCREATE — Add a post for that user
mutation {
  createPost(input: {
    title: "GraphQL is Amazing"
    body: "Here's why GraphQL changes everything..."
    published: true
    authorId: "PASTE_USER_ID_HERE"  # Use the id from createUser response
  }) {
    id
    title
    published
    author {        # ← This returns the full User object (thanks to .populate)
      name
      email
    }
    createdAt
  }
}
GraphQLREAD — Get all users with their posts
query {
  users {
    id
    name
    email
    age
    role
    posts {         # ← Nested: each user's posts are resolved
      id
      title
      published
    }
  }
}
GraphQLREAD — Get single user
query {
  user(id: "PASTE_USER_ID_HERE") {
    name
    email
    posts {
      title
    }
  }
}
GraphQLUPDATE — Change user's name and age
mutation {
  updateUser(
    id: "PASTE_USER_ID_HERE"
    input: {
      name: "Yatin Updated"   # Only fields you send get changed
      age: 26                    # Email and role stay the same
    }
  ) {
    id
    name          # Should show "Yatin Updated"
    age           # Should show 26
    email         # Unchanged
  }
}
GraphQLDELETE — Remove a post
mutation {
  deletePost(id: "PASTE_POST_ID_HERE") {
    id
    title         # Returns the deleted post's data
  }
}
GraphQLDELETE — Remove a user (cascades: deletes their posts too)
mutation {
  deleteUser(id: "PASTE_USER_ID_HERE") {
    id
    name
    email
  }
}

Part 2: GraphQL CRUD with Prisma (PostgreSQL)

Step 1: Project Setup

BashTerminal
# Create project folder and initialize
mkdir graphql-prisma-crud && cd graphql-prisma-crud
npm init -y

# Install core dependencies
npm install @apollo/server graphql express cors
# Same as Mongoose setup — Apollo is the GraphQL engine

# Install Prisma
npm install prisma @prisma/client
# prisma        → CLI tool for migrations, schema management
# @prisma/client → auto-generated type-safe database client

# Initialize Prisma (creates prisma/ folder with schema.prisma)
npx prisma init
# This creates:
#   prisma/schema.prisma → your database schema file
#   .env                 → DATABASE_URL goes here

# Dev dependencies
npm install -D nodemon

Step 2: Prisma Schema

Unlike Mongoose (schema in JS code), Prisma uses its own schema file. This is your single source of truth for the database structure.

Prismaprisma/schema.prisma
// prisma/schema.prisma
// This file defines your DATABASE structure.
// Prisma reads this and generates:
//   1. SQL migrations (CREATE TABLE statements)
//   2. A type-safe JavaScript/TypeScript client

generator client {
  provider = "prisma-client-js"
}
// ↑ Tells Prisma to generate a JavaScript client
//   After running `npx prisma generate`, you get
//   a client with full autocompletion in your IDE

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
// ↑ Database connection settings
//   provider: which database (postgresql, mysql, sqlite, mongodb)
//   url: connection string from .env file
//   Example: DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"

model User {
  id        String   @id @default(cuid())
  // ↑ id: primary key, auto-generated CUID string
  //   @id → marks this as the primary key (like PRIMARY KEY in SQL)
  //   @default(cuid()) → auto-generates a unique ID
  //   CUID is like UUID but shorter and URL-safe
  //   You could also use @default(uuid()) or @default(autoincrement())

  name      String
  // ↑ name: required string column (NOT NULL by default in Prisma)
  //   In Prisma, fields are required unless you add ? (e.g., String?)
  //   Opposite of GraphQL where fields are nullable by default

  email     String   @unique
  // ↑ email: required + unique constraint
  //   @unique → creates a UNIQUE INDEX in the database
  //   INSERT with duplicate email will throw an error

  age       Int?
  // ↑ age: OPTIONAL integer (? makes it nullable)
  //   SQL: age INTEGER (no NOT NULL constraint)

  role      Role     @default(USER)
  // ↑ role: uses the Role enum defined below
  //   @default(USER) → defaults to USER if not provided

  posts     Post[]
  // ↑ posts: ONE-TO-MANY relationship — one user has many posts
  //   This is a VIRTUAL field — no column is created in the users table
  //   Prisma uses the Post.authorId foreign key to resolve this
  //   Accessed as: user.posts (returns array of Post objects)

  createdAt DateTime @default(now())
  // ↑ createdAt: auto-set to current timestamp when row is created
  //   SQL: created_at TIMESTAMP DEFAULT NOW()

  updatedAt DateTime @updatedAt
  // ↑ updatedAt: automatically updated whenever the row is modified
  //   Prisma handles this — no trigger needed
}

model Post {
  id        String   @id @default(cuid())
  title     String
  body      String
  published Boolean  @default(false)
  // ↑ published: defaults to false (draft state)

  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  // ↑ author: MANY-TO-ONE relationship — each post belongs to one user
  //   @relation → tells Prisma HOW to join the tables:
  //     fields: [authorId] → "use the authorId column in THIS table"
  //     references: [id]   → "to match against the id column in User table"
  //     onDelete: Cascade  → if the user is deleted, delete their posts too
  //   SQL: FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE

  authorId  String
  // ↑ The actual foreign key column stored in the posts table
  //   This IS a real column (unlike the 'author' field above)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([authorId])
  // ↑ Create a database INDEX on authorId for faster lookups
  //   When querying "all posts by user X", this index makes it fast
  //   SQL: CREATE INDEX ON posts(author_id)
}

enum Role {
  USER
  ADMIN
  MODERATOR
}
// ↑ Enum type — only these three values are allowed for the role field
//   Prisma creates a real PostgreSQL ENUM type
//   SQL: CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'MODERATOR')
BashRun migrations after defining schema
# Create the database tables from your schema
npx prisma migrate dev --name init
# ↑ This does THREE things:
#   1. Generates SQL migration files (in prisma/migrations/)
#   2. Runs those migrations against your database
#   3. Regenerates the Prisma Client
# --name init → names this migration "init" (for readability)

# If you only want to regenerate the client (no migration):
npx prisma generate
# ↑ Regenerates node_modules/@prisma/client with latest schema types

Step 3: Prisma Client Setup

JavaScriptprisma/client.js
const { PrismaClient } = require('@prisma/client');
// ↑ Import the auto-generated Prisma Client
//   This client was generated by `npx prisma generate`
//   It has full TypeScript types matching your schema

const prisma = new PrismaClient();
// ↑ Create a single Prisma Client instance
//   IMPORTANT: only create ONE instance for the entire app
//   Each instance holds a connection pool (default: 5 connections)
//   Creating multiple instances wastes database connections

module.exports = prisma;
// ↑ Export the singleton — import this everywhere you need DB access

Step 4: GraphQL Type Definitions

Very similar to Mongoose version — the GraphQL schema is independent of which ORM you use.

JavaScriptschema/typeDefs.js
const typeDefs = `#graphql

  # Same output types as before — GraphQL schema doesn't change
  # whether you use Mongoose or Prisma underneath

  type User {
    id: ID!
    name: String!
    email: String!
    age: Int
    role: String!
    posts: [Post!]!
    createdAt: String!
    updatedAt: String!
  }

  type Post {
    id: ID!
    title: String!
    body: String!
    published: Boolean!
    author: User!
    createdAt: String!
    updatedAt: String!
  }

  # Input types stay the same too
  input CreateUserInput {
    name: String!
    email: String!
    age: Int
    role: String
  }

  input UpdateUserInput {
    name: String
    email: String
    age: Int
    role: String
  }

  input CreatePostInput {
    title: String!
    body: String!
    published: Boolean
    authorId: ID!
  }

  input UpdatePostInput {
    title: String
    body: String
    published: Boolean
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts: [Post!]!
    post(id: ID!): Post
  }

  type Mutation {
    createUser(input: CreateUserInput!): User!
    updateUser(id: ID!, input: UpdateUserInput!): User
    deleteUser(id: ID!): User
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post
    deletePost(id: ID!): Post
  }
`;

module.exports = typeDefs;
// ↑ Identical schema to Mongoose version
//   The ONLY thing that changes is the resolvers (database calls)
//   This is one of GraphQL's strengths: the API contract is ORM-agnostic

Step 5: Prisma Resolvers (The Key Difference)

This is where Prisma shines — notice how much cleaner the database calls are compared to Mongoose. No .populate(), no $push/$pull, no manual cascade deletes.

JavaScriptschema/resolvers.js
const prisma = require('../prisma/client');
// ↑ Import our singleton Prisma Client
//   prisma.user → access the User table
//   prisma.post → access the Post table
//   Every method returns a Promise (so we use async/await)

const resolvers = {

  // ============================================
  // QUERY RESOLVERS (READ operations)
  // ============================================
  Query: {

    // -------- GET ALL USERS --------
    users: async () => {
      return await prisma.user.findMany({
      // ↑ prisma.user.findMany() → SELECT * FROM users
      //   Returns an array of all users
      //   Like Mongoose's User.find() but with a type-safe API

        include: { posts: true },
        // ↑ include → Prisma's version of Mongoose's .populate()
        //   { posts: true } → also load related Post records
        //   Prisma generates a JOIN query (not N+1 separate queries)
        //   SQL: SELECT * FROM users LEFT JOIN posts ON posts.author_id = users.id
        //
        //   You can also do nested includes:
        //   include: { posts: { include: { comments: true } } }
      });
    },

    // -------- GET SINGLE USER BY ID --------
    user: async (_, { id }) => {
      return await prisma.user.findUnique({
      // ↑ findUnique → find ONE record by a unique field
      //   SQL: SELECT * FROM users WHERE id = $1 LIMIT 1
      //   Returns null if not found (perfect for our nullable return type)
      //
      //   findUnique vs findFirst:
      //   findUnique → only works on @unique or @id fields
      //   findFirst → works on any field (but slower, no index guarantee)

        where: { id },
        // ↑ where clause: { id: id } (shorthand: { id })
        //   Prisma generates: WHERE id = 'clx123...'

        include: { posts: true },
        // ↑ Include related posts in the result
      });
    },

    // -------- GET ALL POSTS --------
    posts: async () => {
      return await prisma.post.findMany({
        include: { author: true },
        // ↑ Include the author (User) for each post
        //   Prisma knows how to join because of the @relation in schema
      });
    },

    // -------- GET SINGLE POST BY ID --------
    post: async (_, { id }) => {
      return await prisma.post.findUnique({
        where: { id },
        include: { author: true },
      });
    },
  },

  // ============================================
  // MUTATION RESOLVERS (CREATE, UPDATE, DELETE)
  // ============================================
  Mutation: {

    // -------- CREATE USER --------
    createUser: async (_, { input }) => {
      return await prisma.user.create({
      // ↑ prisma.user.create() → INSERT INTO users (...) VALUES (...)
      //   Prisma validates against the schema automatically
      //   If email is duplicate, throws: Unique constraint failed on `email`

        data: input,
        // ↑ data: the values to insert
        //   input = { name: "John", email: "john@dev.com", age: 25 }
        //   Prisma auto-handles:
        //     id → generated by @default(cuid())
        //     role → defaults to USER via @default(USER)
        //     createdAt → set by @default(now())
        //     updatedAt → set by @updatedAt

        include: { posts: true },
        // ↑ Include posts in the response (will be empty [] for new user)
      });
    },

    // -------- UPDATE USER --------
    updateUser: async (_, { id, input }) => {
      try {
      // ↑ Wrap in try-catch because Prisma throws if record not found
      //   (unlike Mongoose which returns null)

        return await prisma.user.update({
        // ↑ prisma.user.update() → UPDATE users SET ... WHERE id = $1
        //   IMPORTANT: Prisma THROWS an error if the record doesn't exist
        //   This is different from Mongoose's findByIdAndUpdate (returns null)

          where: { id },
          // ↑ Which record to update

          data: input,
          // ↑ What to change — only provided fields are updated
          //   If input = { name: "New Name" }, only name changes
          //   Prisma automatically updates the updatedAt timestamp

          include: { posts: true },
          // ↑ Return the updated user with their posts
        });
      } catch (error) {
        if (error.code === 'P2025') return null;
        // ↑ P2025 = "Record to update not found"
        //   Return null instead of throwing (matches our nullable schema)
        //   Prisma error codes: P2025 (not found), P2002 (unique violation), etc.

        throw error;
        // ↑ Re-throw any other errors (real database errors)
      }
    },

    // -------- DELETE USER --------
    deleteUser: async (_, { id }) => {
      try {
        return await prisma.user.delete({
        // ↑ prisma.user.delete() → DELETE FROM users WHERE id = $1
        //   Returns the deleted record data
        //   Throws P2025 if record doesn't exist
        //
        //   CASCADE: Because we set onDelete: Cascade in the schema,
        //   Prisma/PostgreSQL automatically deletes all related posts!
        //   No manual cascade needed (unlike Mongoose)

          where: { id },
        });
      } catch (error) {
        if (error.code === 'P2025') return null;
        throw error;
      }
    },

    // -------- CREATE POST --------
    createPost: async (_, { input }) => {
      const { authorId, ...postData } = input;
      // ↑ Same destructuring as Mongoose version

      return await prisma.post.create({
        data: {
          ...postData,
          // ↑ Spread: title, body, published

          author: {
            connect: { id: authorId },
          },
          // ↑ connect → Prisma's way of setting a relationship
          //   Instead of: author: authorId (like Mongoose)
          //   Prisma uses: author: { connect: { id: authorId } }
          //   This sets the authorId foreign key in the posts table
          //   SQL: INSERT INTO posts (title, body, author_id) VALUES ($1, $2, $3)
          //
          //   No need to manually update the User's posts array!
          //   Prisma handles the relationship automatically
          //   (Unlike Mongoose where we had to $push to user.posts)
        },
        include: { author: true },
        // ↑ Return the post with full author data
      });
    },

    // -------- UPDATE POST --------
    updatePost: async (_, { id, input }) => {
      try {
        return await prisma.post.update({
          where: { id },
          data: input,
          include: { author: true },
        });
      } catch (error) {
        if (error.code === 'P2025') return null;
        throw error;
      }
    },

    // -------- DELETE POST --------
    deletePost: async (_, { id }) => {
      try {
        return await prisma.post.delete({
          where: { id },
          include: { author: true },
          // ↑ Include author in the returned deleted post
          //   No need to manually $pull from user.posts!
          //   Prisma manages the relationship automatically
        });
      } catch (error) {
        if (error.code === 'P2025') return null;
        throw error;
      }
    },
  },
};

module.exports = resolvers;

Step 6: Server Setup (Prisma Version)

JavaScriptindex.js
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const express = require('express');
const cors = require('cors');

const typeDefs = require('./schema/typeDefs');
const resolvers = require('./schema/resolvers');

async function startServer() {
  const app = express();

  const server = new ApolloServer({ typeDefs, resolvers });
  await server.start();

  app.use(
    '/graphql',
    cors(),
    express.json(),
    expressMiddleware(server)
  );

  // ↑ Notice: NO database connection code here!
  //   Prisma connects lazily — the first query triggers a connection
  //   Mongoose required explicit mongoose.connect() before starting
  //   Prisma reads DATABASE_URL from .env automatically

  app.listen(4000, () => {
    console.log('Server running at http://localhost:4000/graphql');
  });
}

startServer();

Step 7: Testing Prisma CRUD (Same Queries Work!)

The GraphQL queries are identical to the Mongoose version — that's the beauty of GraphQL. The ORM is an implementation detail hidden behind the resolvers.

GraphQLAll CRUD operations — same as Mongoose
# CREATE USER — exactly the same query
mutation {
  createUser(input: {
    name: "Yatin Dora"
    email: "yatin@dev.com"
    age: 25
    role: "ADMIN"
  }) {
    id
    name
    email
    role
  }
}

# READ ALL USERS — exactly the same query
query {
  users {
    id
    name
    posts {
      title
    }
  }
}

# UPDATE — exactly the same query
mutation {
  updateUser(
    id: "PASTE_ID"
    input: { name: "Updated Name" }
  ) {
    name
  }
}

# DELETE — exactly the same query
mutation {
  deleteUser(id: "PASTE_ID") {
    name
  }
}

Mongoose vs Prisma — Side by Side

Operation Comparison

ComparisonEvery CRUD operation side by side
// ==========================================
// FIND ALL
// ==========================================

// Mongoose:
await User.find().populate('posts');
// → Separate queries: 1 for users + 1 for posts (2 queries)
// → .populate() does a second query behind the scenes

// Prisma:
await prisma.user.findMany({ include: { posts: true } });
// → Single JOIN query (1 query, more efficient)
// → include replaces populate with better SQL generation

// ==========================================
// FIND BY ID
// ==========================================

// Mongoose:
await User.findById(id).populate('posts');
// → Works with MongoDB ObjectId strings

// Prisma:
await prisma.user.findUnique({ where: { id } });
// → findUnique only works on @id or @unique fields (optimized)

// ==========================================
// CREATE
// ==========================================

// Mongoose:
const user = new User(data);    // Create instance
await user.save();              // Save to DB (2 steps)
// OR: await User.create(data);  // 1 step shorthand

// Prisma:
await prisma.user.create({ data });
// → Always 1 step, returns the created record

// ==========================================
// UPDATE
// ==========================================

// Mongoose:
await User.findByIdAndUpdate(id, data, { new: true, runValidators: true });
// → Need { new: true } to get updated doc (quirky default)
// → Need { runValidators: true } to validate on update

// Prisma:
await prisma.user.update({ where: { id }, data });
// → Always returns updated record
// → Always validates (schema is enforced by the database)
// → THROWS if record not found (must catch P2025)

// ==========================================
// DELETE
// ==========================================

// Mongoose:
await User.findByIdAndDelete(id);
await Post.deleteMany({ author: id });  // Manual cascade!

// Prisma:
await prisma.user.delete({ where: { id } });
// → Cascade is automatic (onDelete: Cascade in schema)
// → No manual cleanup needed

// ==========================================
// RELATIONSHIPS
// ==========================================

// Mongoose — Creating a post requires 2 operations:
const post = await Post.create({ ...data, author: userId });
await User.findByIdAndUpdate(userId, { $push: { posts: post._id } });
// → Must manually sync BOTH sides of the relationship

// Prisma — Creating a post is 1 operation:
await prisma.post.create({
  data: { ...data, author: { connect: { id: userId } } }
});
// → Prisma handles the foreign key — no manual syncing

When to Choose Which

Decision Guide
// CHOOSE MONGOOSE WHEN:
// ✓ Using MongoDB (document database)
// ✓ Data is unstructured or changes shape often
// ✓ Rapid prototyping — no migrations needed
// ✓ Storing nested documents (arrays of objects inside a document)
// ✓ Team already knows MongoDB
// ✓ Building CMS, IoT, logging, or analytics systems

// CHOOSE PRISMA WHEN:
// ✓ Using PostgreSQL, MySQL, or SQLite
// ✓ Need strong data integrity (foreign keys, constraints)
// ✓ Want type-safe queries with autocompletion
// ✓ Need database migrations (version-controlled schema changes)
// ✓ Complex relationships (many-to-many, self-referential)
// ✓ Building production apps with strict data requirements
// ✓ Want the cleaner API (include vs populate, connect vs $push)

// BOTH WORK GREAT WITH GRAPHQL
// The GraphQL schema + queries stay IDENTICAL
// Only the resolvers (database layer) change