What is GraphQL?
Understanding GraphQL from its core — what it is, why it exists, and how it thinks differently from REST.
/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.- 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 /graphqlhandles 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).
- 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.
graphql-codegen) giving type-safe clients automatically.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.The Definition
GraphQL is a query language for APIs and a runtime for executing those queries against your data. It was developed internally at Facebook in 2012 and open-sourced in 2015.
Think of it this way: REST gives you fixed endpoints that return fixed data structures. GraphQL gives you a single endpoint where the client says exactly what data it needs, and the server returns exactly that — nothing more, nothing less.
GraphQL is NOT a database query language (like SQL). It sits between your client and your data sources (databases, REST APIs, microservices, etc.) as an API layer.
React, Mobile, etc.
Single /graphql endpoint
Fetch data logic
DB, REST, gRPC, etc.
Why Was GraphQL Created?
Facebook created GraphQL to solve real problems they hit with REST APIs in their mobile app:
- Over-fetching: REST endpoints return all fields even when the client only needs 2-3. This wastes bandwidth, which is critical on mobile.
- Under-fetching: To build one screen, the client often needs to hit 3-5 different REST endpoints. Each round trip adds latency.
- Rapid iteration: Every new feature required backend changes to create or modify endpoints. GraphQL lets the frontend evolve independently.
- Type safety: REST has no built-in contract. GraphQL has a strongly-typed schema that acts as a contract between frontend and backend.
How GraphQL Works — The Mental Model
GraphQL works on a simple principle: the client describes the shape of the data it needs, and the server returns data in exactly that shape.
Client Sends This Query
// "Hey server, give me user 1's name
// and their posts' titles"
{
user(id: 1) {
name
posts {
title
}
}
}
Server Returns Exactly This
{
"data": {
"user": {
"name": "Yatin Dora",
"posts": [
{ "title": "Learning GraphQL" },
{ "title": "React + GraphQL" }
]
}
}
}
Notice: the response mirrors the query structure exactly. The client asked for name and posts.title, and that's exactly what it got. No extra fields. No missing fields.
REST vs GraphQL
A deep comparison to understand when to use which and why.
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.- 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,/v2URL 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 returns200 OKwith anerrorsarray in the body.
query { repository(owner:"facebook", name:"react") { issues(first:10) { nodes { title author { login } } } } } in one request.| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints: /users, /posts, /comments | Single endpoint: /graphql |
| Data Fetching | Server decides what data to return | Client decides what data it needs |
| Over-fetching | Common — you get all fields | Eliminated — you get only what you request |
| Under-fetching | Common — need multiple calls | Eliminated — one query gets everything |
| Versioning | Often needs v1, v2, v3 | Evolves via field deprecation, no versioning needed |
| Caching | Easy — HTTP caching on URLs | Harder — needs client-side normalized cache |
| Error Handling | HTTP status codes (404, 500, etc.) | Always returns 200, errors in response body |
| File Upload | Native multipart support | Needs workarounds (multipart spec or presigned URLs) |
| Type System | None built-in (OpenAPI/Swagger is add-on) | Strong, built-in schema type system |
| Real-time | WebSockets (separate protocol) | Subscriptions (part of the spec) |
| Learning Curve | Low — most devs know HTTP methods | Medium — new query language, schema concepts |
The Over-fetching / Under-fetching Problem Visualized
Scenario: Build a user profile page that shows user name, avatar, and their 5 latest posts with comment counts.
REST Approach (3 round trips)
// Call 1: Get user (over-fetches email, address,
// phone, settings, etc.)
GET /api/users/1
// Response: 50+ fields, you need 2
// Call 2: Get posts (over-fetches post body,
// metadata, etc.)
GET /api/users/1/posts?limit=5
// Response: full post objects, you need titles
// Call 3: Get comment counts for each post
GET /api/posts/101/comments/count
GET /api/posts/102/comments/count
GET /api/posts/103/comments/count
// ... 5 more requests!
Total: 3-8 network round trips
GraphQL Approach (1 round trip)
# One query, exactly what we need
query UserProfile {
user(id: 1) {
name
avatarUrl
posts(limit: 5) {
title
commentCount
}
}
}
# Returns EXACTLY this shape
# No extra fields
# No extra round trips
Total: 1 network round trip
Use REST when: Simple CRUD apps, public APIs for third-party consumers, heavy caching requirements, team unfamiliar with GraphQL, file-upload-heavy services.
Use GraphQL when: Multiple client types (web, mobile, TV), complex data relationships, rapid frontend iteration, microservices aggregation layer, real-time features needed, you want a strong API contract.
Use both: Many production systems use GraphQL as a BFF (Backend For Frontend) that internally calls REST microservices. This is extremely common at scale.
Core Concepts
The fundamental building blocks that everything in GraphQL is built upon.
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.- Scalar types: Built-in
String,Int,Float,Boolean,ID. Custom scalars likeDateTime,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.
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.- 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.
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.Node, Actor), unions (IssueOrPullRequest), and the Relay-style pagination (Connection/Edge). Shopify's Admin API and Stripe's GraphQL (beta) are other excellent references.Schema — The Heart of GraphQL
The schema defines every piece of data your API can return. It's a contract between frontend and backend. It uses SDL (Schema Definition Language).
# ===== SCALAR TYPES (built-in primitives) =====
# Int - Signed 32-bit integer
# Float - Signed double-precision floating-point
# String - UTF-8 character sequence
# Boolean - true or false
# ID - Unique identifier (serialized as String)
# ===== CUSTOM SCALAR (for types not built-in) =====
scalar DateTime # You define how this is serialized/parsed
scalar JSON
# ===== ENUM (fixed set of values) =====
enum Role {
USER
ADMIN
MODERATOR
}
# ===== ENUM for status =====
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# ===== OBJECT TYPE (the main building block) =====
type User {
id: ID! # ! means non-nullable (required)
name: String!
email: String!
role: Role!
avatar: String # No ! means nullable (optional)
posts: [Post!]! # Non-null list of non-null Posts
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
status: PostStatus!
author: User! # Relationship back to User
comments: [Comment!]!
tags: [String!]!
createdAt: DateTime!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}
# ===== INPUT TYPE (for mutations) =====
# Input types are used for arguments. You can't use
# regular types as arguments — this is by design
# to separate "read shape" from "write shape"
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
input UpdatePostInput {
title: String # All optional for partial updates
body: String
tags: [String!]
}
# ===== INTERFACE (shared fields) =====
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
# ===== UNION TYPE (either this or that) =====
union SearchResult = User | Post | Comment
# ===== THE ROOT TYPES =====
type Query {
# These are the "read" entry points
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(status: PostStatus): [Post!]!
search(query: String!): [SearchResult!]!
}
type Mutation {
# These are the "write" entry points
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
register(email: String!, password: String!, name: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
}
type Subscription {
# These are the "real-time" entry points
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
type AuthPayload {
token: String!
user: User!
}
Understanding the ! (Non-Null) Modifier
This is one of the most confusing parts for beginners. Let's break it down:
| Declaration | Meaning | Example Value |
|---|---|---|
| String | Nullable string — can be null |
"hello" or null |
| String! | Non-null string — guaranteed to have a value | "hello" (never null) |
| [String] | Nullable list of nullable strings | null, [], ["a", null] |
| [String!] | Nullable list of non-null strings | null, [], ["a", "b"] |
| [String!]! | Non-null list of non-null strings | [], ["a", "b"] (never null) |
| [String]! | Non-null list of nullable strings | [], ["a", null] |
For return types: prefer nullable fields (no !) — this gives you graceful degradation if a resolver fails. A non-null field that fails will null-bubble up to the parent.
For arguments/inputs: use ! for required fields to enforce validation at the schema level.
The Three Root Operation Types
Query
Read operations. Analogous to GET in REST. Queries run in parallel by default.
Mutation
Write operations (create, update, delete). Analogous to POST/PUT/DELETE. Mutations run sequentially (in order).
Subscription
Real-time operations via WebSocket. Server pushes data to clients when events occur.
Schema Design Patterns
How senior engineers and architects design schemas that scale.
- 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!]!, useposts(first:10, after:"cursor"): PostConnectionwithedges { cursor node { ... } }andpageInfo. - 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.
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?".... 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").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.
# The Connection pattern
type Query {
posts(
first: Int # Number of items to fetch
after: String # Cursor: "fetch items AFTER this cursor"
last: Int # For backward pagination
before: String # Cursor: "fetch items BEFORE this cursor"
): PostConnection!
}
# The Connection type wraps the data + pagination info
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int! # Optional but useful
}
# Each Edge wraps a node + its cursor
type PostEdge {
node: Post! # The actual data
cursor: String! # Opaque cursor for this item
}
# Pagination metadata
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Client usage: get first 10 posts
query {
posts(first: 10) {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page: use the endCursor from previous response
query {
posts(first: 10, after: "Y3Vyc29yXzEw") {
edges {
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Offset pagination (LIMIT 10 OFFSET 20) breaks when items are added/removed during pagination — you skip or duplicate items.
Cursor pagination uses a stable pointer (usually base64-encoded ID + sort key) so it always picks up exactly where it left off, regardless of inserts/deletes.
Mutation Response Pattern
Always return enough data from mutations so the client can update its cache without making another query.
# BAD: Returns just a boolean
type Mutation {
createPost(input: CreatePostInput!): Boolean!
# Client has no data to update cache with!
}
# GOOD: Returns the created entity + possible errors
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
type CreatePostPayload {
post: Post # The created post (null on error)
errors: [UserError!]! # Business logic errors
}
type UserError {
field: [String!]! # Path to the field: ["input", "title"]
message: String! # Human-readable error
code: ErrorCode! # Machine-readable error code
}
enum ErrorCode {
VALIDATION_ERROR
NOT_FOUND
UNAUTHORIZED
DUPLICATE
}
Directives
Directives modify the behavior of fields or fragments at runtime. They start with @.
# Built-in directives
query UserProfile($withPosts: Boolean!, $skipAvatar: Boolean!) {
user(id: 1) {
name
avatar @skip(if: $skipAvatar) # Skip this field if true
posts @include(if: $withPosts) { # Include only if true
title
}
}
}
# Schema directives (custom)
directive @auth(requires: Role!) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION
type User {
email: String! @auth(requires: ADMIN) # Only admins see email
username: String! @deprecated(reason: "Use name instead")
}
Queries & Mutations Deep Dive
Everything about reading and writing data through GraphQL.
query 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 } }.- 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.
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.- vs REST: REST uses HTTP verbs (GET/POST/PUT/DELETE) and URL paths. GraphQL uses
query/mutationkeywords 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.
{ user { friends { friends { friends { ... } } } } } and melt your DB.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
# === 1. Basic Query ===
query {
users {
id
name
}
}
# === 2. Named Query with Variables ===
# Naming queries is a best practice for:
# - Debugging (shows in network tab & server logs)
# - Caching (Apollo uses operation name)
# - APQ (Automatic Persisted Queries)
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
email
}
}
# Variables (sent as separate JSON):
# { "userId": "123" }
# === 3. Aliases (query same field multiple times) ===
query TwoUsers {
firstUser: user(id: "1") {
name
}
secondUser: user(id: "2") {
name
}
}
# Returns: { firstUser: { name: "..." }, secondUser: { name: "..." } }
# === 4. Fragments (reusable field sets) ===
fragment UserFields on User {
id
name
email
avatar
}
query Dashboard {
me {
...UserFields # Spread the fragment
posts {
title
author {
...UserFields # Reuse same fragment
}
}
}
}
# === 5. Inline Fragments (for unions/interfaces) ===
query Search($q: String!) {
search(query: $q) {
# SearchResult = User | Post | Comment
... on User {
name
avatar
}
... on Post {
title
body
}
... on Comment {
text
}
}
}
Mutations Anatomy
# === Create ===
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
body
author {
name
}
}
errors {
field
message
}
}
}
# Variables:
# {
# "input": {
# "title": "My First Post",
# "body": "Hello World!",
# "tags": ["graphql", "tutorial"]
# }
# }
# === Update ===
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
post {
id
title # Returns updated data for cache update
}
errors {
field
message
}
}
}
# === Delete ===
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
errors {
message
}
}
}
# === Multiple mutations in one request ===
# They execute SEQUENTIALLY (in order), unlike queries
mutation BatchOps {
first: createPost(input: { title: "A", body: "..." }) {
post { id }
}
second: createPost(input: { title: "B", body: "..." }) {
post { id }
}
}
Real-Time with Subscriptions
Push-based real-time data delivery over WebSocket.
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).- 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.
- 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-sseexists 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.
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.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).
# Client subscribes to new comments on a post
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
text
author {
name
avatar
}
createdAt
}
}
# Server pushes this shape every time
# a new comment is added to that post
Subscriptions at scale require careful infrastructure — WebSocket connections are stateful and long-lived, which means you need sticky sessions or a pub/sub system (Redis, Kafka) to broadcast events across server instances. Many teams at scale use polling or server-sent events instead.
Resolvers Explained
Resolvers are functions that actually fetch the data. They're the bridge between your schema and your data sources.
(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.- 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:
__resolveTypeand__resolveReference(for unions, interfaces, and federation). - Resolver chains via context: Inject DataLoaders, Prisma clients, Redis clients once and reuse them everywhere.
- 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.
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.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.Resolver Function Signature
Every resolver receives 4 arguments:
// Every resolver function has this signature:
const resolver = (
parent, // Result from the PARENT resolver
// (also called "root" or "obj")
// For top-level Query/Mutation: undefined
// For nested fields: the parent object
args, // Arguments passed to this field
// e.g., user(id: "1") => args = { id: "1" }
context, // Shared across ALL resolvers in a request
// Common: { db, user, loaders, req }
// Set once per request in server setup
info // AST of the query + schema info
// Advanced: used for optimizations like
// checking which fields were requested
) => {
// Return data (or a Promise)
};
Resolver Chain (How Nested Resolution Works)
This is crucial to understand. Resolvers form a chain:
// Given this query:
// query {
// user(id: "1") { ← Query.user resolver
// name ← User.name resolver (often default)
// posts { ← User.posts resolver
// title ← Post.title resolver (often default)
// author { ← Post.author resolver
// name ← User.name resolver (often default)
// }
// }
// }
// }
const resolvers = {
// Step 1: Top-level query resolver
Query: {
user: async (parent, args, context) => {
// parent is undefined for root queries
// args = { id: "1" }
return await context.db.user.findUnique({
where: { id: args.id }
});
// Returns: { id: "1", name: "Yatin", email: "..." }
// This return value becomes "parent" for child resolvers
},
},
// Step 2: User type resolvers
User: {
// "name" doesn't need a resolver!
// GraphQL has a DEFAULT resolver that does:
// (parent) => parent["name"]
// So if parent.name exists, it just works.
// "posts" DOES need a resolver because it's a relationship
posts: async (parent, args, context) => {
// parent = the user object from step 1
// parent = { id: "1", name: "Yatin", email: "..." }
return await context.db.post.findMany({
where: { authorId: parent.id }
});
// Returns: [{ id: "101", title: "...", authorId: "1" }, ...]
},
},
// Step 3: Post type resolvers
Post: {
// "title" uses default resolver: parent.title
author: async (parent, args, context) => {
// parent = a single post from step 2
// parent = { id: "101", title: "...", authorId: "1" }
return await context.db.user.findUnique({
where: { id: parent.authorId }
});
},
},
};
In the above example, if you query 10 users with their posts, the posts resolver runs 10 times — one for each user. That's 1 query for users + 10 queries for posts = 11 total queries. This is the N+1 problem. The solution is DataLoader (covered in Performance section).
Node.js Implementation
Complete backend setup with Apollo Server, Express, Prisma, and JWT authentication.
@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.- Apollo Server 4: The most popular; batteries-included; plugin system; Apollo Studio integration; works standalone or with Express.
- GraphQL Yoga 5: Built on
graphql-helixandenvelop; 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.
- SDL-first: Write the schema as a
.graphqlstring, 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.
- 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.
/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).Project Setup
# Initialize project
mkdir graphql-api && cd graphql-api
npm init -y
# Core dependencies
npm install @apollo/server graphql express cors
# Database (Prisma ORM)
npm install prisma @prisma/client
npx prisma init
# Authentication
npm install jsonwebtoken bcryptjs
# Performance
npm install dataloader
# Subscriptions (real-time)
npm install graphql-ws ws graphql-subscriptions
# Dev dependencies
npm install -D nodemon typescript @types/node ts-node
Project Structure
graphql-api/
├── prisma/
│ └── schema.prisma # Database schema
├── src/
│ ├── index.js # Server entry point
│ ├── schema/
│ │ ├── typeDefs.js # GraphQL schema definitions
│ │ └── resolvers/
│ │ ├── index.js # Merge all resolvers
│ │ ├── userResolver.js
│ │ ├── postResolver.js
│ │ └── commentResolver.js
│ ├── middleware/
│ │ └── auth.js # JWT authentication
│ ├── utils/
│ │ ├── dataLoaders.js # DataLoader setup
│ │ └── errors.js # Custom error classes
│ └── context.js # Context builder
├── .env
└── package.json
Step 1: Database Schema (Prisma)
// prisma/schema.prisma
// This defines your DATABASE tables.
// Prisma generates a type-safe client from this.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "mysql", "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String // Hashed, never exposed in GraphQL
name String
role Role @default(USER)
avatar String? // ? means nullable
posts Post[] // One-to-many relation
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
body String
status PostStatus @default(DRAFT)
tags String[]
author User @relation(fields: [authorId], references: [id])
authorId String
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId]) // Index for faster lookups
}
model Comment {
id String @id @default(cuid())
text String
author User @relation(fields: [authorId], references: [id])
authorId String
post Post @relation(fields: [postId], references: [id])
postId String
createdAt DateTime @default(now())
@@index([postId])
@@index([authorId])
}
enum Role {
USER
ADMIN
MODERATOR
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Step 2: GraphQL Type Definitions
// src/schema/typeDefs.js
// This is where we define our GraphQL schema using SDL.
// The `gql` tag parses the string into an AST that Apollo understands.
const typeDefs = `#graphql
# ===== Custom Scalars =====
scalar DateTime
# ===== Enums =====
enum Role {
USER
ADMIN
MODERATOR
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum SortOrder {
ASC
DESC
}
# ===== Types =====
type User {
id: ID!
name: String!
email: String!
role: Role!
avatar: String
posts(limit: Int, offset: Int): [Post!]!
postCount: Int!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
status: PostStatus!
tags: [String!]!
author: User!
comments: [Comment!]!
commentCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}
# ===== Pagination Types =====
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
node: Post!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# ===== Input Types =====
input CreatePostInput {
title: String!
body: String!
tags: [String!]
status: PostStatus
}
input UpdatePostInput {
title: String
body: String
tags: [String!]
status: PostStatus
}
input RegisterInput {
name: String!
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
input PostFilters {
status: PostStatus
authorId: ID
tag: String
search: String
}
# ===== Payload Types (Mutation Responses) =====
type AuthPayload {
token: String!
user: User!
}
type PostPayload {
post: Post
errors: [UserError!]!
}
type DeletePayload {
success: Boolean!
errors: [UserError!]!
}
type UserError {
field: [String!]!
message: String!
}
# ===== Queries (Read Operations) =====
type Query {
# User queries
me: User # Current logged-in user
user(id: ID!): User # Get user by ID
users(limit: Int, offset: Int): [User!]!
# Post queries
post(id: ID!): Post
posts(
filters: PostFilters
first: Int
after: String
orderBy: SortOrder
): PostConnection!
}
# ===== Mutations (Write Operations) =====
type Mutation {
# Auth
register(input: RegisterInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
# Posts (require authentication)
createPost(input: CreatePostInput!): PostPayload!
updatePost(id: ID!, input: UpdatePostInput!): PostPayload!
deletePost(id: ID!): DeletePayload!
# Comments
addComment(postId: ID!, text: String!): Comment!
}
# ===== Subscriptions (Real-time) =====
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
`;
module.exports = { typeDefs };
Step 3: Authentication Middleware
// src/middleware/auth.js
// Extracts and verifies the JWT token from the request header.
// This runs on EVERY request to build the context object.
const jwt = require('jsonwebtoken');
// This function extracts the user from the JWT token.
// It does NOT throw if there's no token — some queries are public.
// Authorization (who can do what) happens in the resolvers.
function getUser(token) {
if (!token) return null;
try {
// Remove "Bearer " prefix if present
const cleanToken = token.startsWith('Bearer ')
? token.slice(7)
: token;
// jwt.verify() does two things:
// 1. Checks the signature (was this token created by us?)
// 2. Checks expiration (is the token still valid?)
const decoded = jwt.verify(cleanToken, process.env.JWT_SECRET);
return decoded; // { userId: "...", role: "..." }
} catch (err) {
return null; // Invalid/expired token = no user
}
}
// Helper to generate tokens
function generateToken(user) {
return jwt.sign(
{
userId: user.id,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: '7d' } // Token expires in 7 days
);
}
// Helper: throw if not authenticated
function requireAuth(context) {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.user;
}
module.exports = { getUser, generateToken, requireAuth };
Step 4: DataLoader (Solving N+1)
// src/utils/dataLoaders.js
// DataLoader batches and caches database queries within a single request.
// Instead of N individual queries, it batches them into 1 query.
//
// WITHOUT DataLoader (N+1 problem):
// SELECT * FROM users WHERE id = '1'
// SELECT * FROM users WHERE id = '2'
// SELECT * FROM users WHERE id = '3' ... N queries!
//
// WITH DataLoader (batched):
// SELECT * FROM users WHERE id IN ('1', '2', '3') ... 1 query!
const DataLoader = require('dataloader');
// IMPORTANT: Create new DataLoader instances PER REQUEST
// because the cache should not leak between requests.
function createLoaders(prisma) {
return {
// User loader: batch-fetch users by their IDs
user: new DataLoader(async (userIds) => {
// userIds = ['1', '2', '3'] (collected from all resolvers)
// One query fetches all users
const users = await prisma.user.findMany({
where: { id: { in: [...userIds] } },
});
// CRITICAL: DataLoader requires results in the SAME ORDER
// as the input IDs. If a user is not found, return null.
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
}),
// Posts by author loader
postsByAuthor: new DataLoader(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
// Group posts by authorId
const postsByAuthor = {};
posts.forEach(post => {
if (!postsByAuthor[post.authorId]) {
postsByAuthor[post.authorId] = [];
}
postsByAuthor[post.authorId].push(post);
});
return authorIds.map(id => postsByAuthor[id] || []);
}),
// Comment count by post loader
commentCountByPost: new DataLoader(async (postIds) => {
const counts = await prisma.comment.groupBy({
by: ['postId'],
where: { postId: { in: [...postIds] } },
_count: { id: true },
});
const countMap = new Map(
counts.map(c => [c.postId, c._count.id])
);
return postIds.map(id => countMap.get(id) || 0);
}),
};
}
module.exports = { createLoaders };
Step 5: Resolvers (Full Implementation)
// src/schema/resolvers/index.js
// Complete resolver implementation with explanations.
const { GraphQLError } = require('graphql');
const bcrypt = require('bcryptjs');
const { generateToken, requireAuth } = require('../../middleware/auth');
const { PubSub } = require('graphql-subscriptions');
// PubSub for subscriptions (use Redis-based in production!)
const pubsub = new PubSub();
const resolvers = {
// ==========================================
// CUSTOM SCALAR: DateTime
// ==========================================
DateTime: {
// How to serialize (server -> client)
serialize(value) {
return value.toISOString();
},
// How to parse from variable (client -> server)
parseValue(value) {
return new Date(value);
},
},
// ==========================================
// QUERIES (Read Operations)
// ==========================================
Query: {
// Get the currently logged-in user
me: async (_parent, _args, context) => {
// context.user was set by our auth middleware
if (!context.user) return null;
return context.prisma.user.findUnique({
where: { id: context.user.userId },
});
},
// Get user by ID
user: async (_parent, { id }, { prisma }) => {
// Destructuring: { id } from args, { prisma } from context
return prisma.user.findUnique({ where: { id } });
},
// List users with basic pagination
users: async (_parent, { limit = 10, offset = 0 }, { prisma }) => {
return prisma.user.findMany({
take: Math.min(limit, 50), // Cap at 50 to prevent abuse
skip: offset,
orderBy: { createdAt: 'desc' },
});
},
// Get single post
post: async (_parent, { id }, { prisma }) => {
return prisma.post.findUnique({ where: { id } });
},
// Get posts with cursor-based pagination + filters
posts: async (_parent, args, { prisma }) => {
const {
filters = {},
first = 10,
after,
orderBy = 'DESC',
} = args;
// Build WHERE clause from filters
const where = {};
if (filters.status) where.status = filters.status;
if (filters.authorId) where.authorId = filters.authorId;
if (filters.tag) where.tags = { has: filters.tag };
if (filters.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ body: { contains: filters.search, mode: 'insensitive' } },
];
}
// Cursor-based pagination with Prisma
const queryArgs = {
where,
take: Math.min(first, 50) + 1, // +1 to check hasNextPage
orderBy: { createdAt: orderBy.toLowerCase() },
};
if (after) {
// Decode cursor (base64-encoded ID)
const decodedCursor = Buffer.from(after, 'base64').toString();
queryArgs.cursor = { id: decodedCursor };
queryArgs.skip = 1; // Skip the cursor item itself
}
const [posts, totalCount] = await Promise.all([
prisma.post.findMany(queryArgs),
prisma.post.count({ where }),
]);
// Check if we got more than requested (means there's a next page)
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
// Cursor = base64-encoded ID (opaque to client)
cursor: Buffer.from(post.id).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
totalCount,
};
},
},
// ==========================================
// MUTATIONS (Write Operations)
// ==========================================
Mutation: {
// Register a new user
register: async (_parent, { input }, { prisma }) => {
const { name, email, password } = input;
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
throw new GraphQLError('Email already in use', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
// Hash the password (NEVER store plain text!)
// bcrypt adds a random salt automatically
const hashedPassword = await bcrypt.hash(password, 12);
// Create the user
const user = await prisma.user.create({
data: { name, email, password: hashedPassword },
});
// Generate JWT and return
const token = generateToken(user);
return { token, user };
},
// Login
login: async (_parent, { input }, { prisma }) => {
const { email, password } = input;
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Compare provided password with stored hash
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = generateToken(user);
return { token, user };
},
// Create a new post (requires auth)
createPost: async (_parent, { input }, context) => {
const authUser = requireAuth(context);
try {
const post = await context.prisma.post.create({
data: {
title: input.title,
body: input.body,
tags: input.tags || [],
status: input.status || 'DRAFT',
authorId: authUser.userId,
},
});
// Publish event for subscriptions
pubsub.publish('POST_CREATED', { postCreated: post });
return { post, errors: [] };
} catch (error) {
return {
post: null,
errors: [{
field: ['input'],
message: error.message,
}],
};
}
},
// Update a post (only author or admin)
updatePost: async (_parent, { id, input }, context) => {
const authUser = requireAuth(context);
// Check ownership
const post = await context.prisma.post.findUnique({
where: { id },
});
if (!post) {
return {
post: null,
errors: [{ field: ['id'], message: 'Post not found' }],
};
}
// Authorization: only author or admin can update
if (post.authorId !== authUser.userId && authUser.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
// Build update data (only include provided fields)
const data = {};
if (input.title !== undefined) data.title = input.title;
if (input.body !== undefined) data.body = input.body;
if (input.tags !== undefined) data.tags = input.tags;
if (input.status !== undefined) data.status = input.status;
const updated = await context.prisma.post.update({
where: { id },
data,
});
return { post: updated, errors: [] };
},
// Delete a post
deletePost: async (_parent, { id }, context) => {
const authUser = requireAuth(context);
const post = await context.prisma.post.findUnique({
where: { id },
});
if (!post) {
return {
success: false,
errors: [{ field: ['id'], message: 'Post not found' }],
};
}
if (post.authorId !== authUser.userId && authUser.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
// Delete comments first (cascade), then post
await context.prisma.comment.deleteMany({ where: { postId: id } });
await context.prisma.post.delete({ where: { id } });
return { success: true, errors: [] };
},
// Add a comment
addComment: async (_parent, { postId, text }, context) => {
const authUser = requireAuth(context);
const comment = await context.prisma.comment.create({
data: {
text,
postId,
authorId: authUser.userId,
},
});
// Publish for real-time subscriptions
pubsub.publish(`COMMENT_ADDED_${postId}`, {
commentAdded: comment,
});
return comment;
},
},
// ==========================================
// SUBSCRIPTIONS (Real-time)
// ==========================================
Subscription: {
postCreated: {
// subscribe returns an AsyncIterator
subscribe: () => pubsub.asyncIterableIterator(['POST_CREATED']),
},
commentAdded: {
subscribe: (_parent, { postId }) => {
return pubsub.asyncIterableIterator([`COMMENT_ADDED_${postId}`]);
},
},
},
// ==========================================
// TYPE RESOLVERS (Nested Field Resolution)
// ==========================================
// These resolve fields that don't directly exist on the
// database model (relationships, computed fields).
User: {
// Resolve the "posts" field on a User
posts: (parent, { limit = 10, offset = 0 }, { loaders }) => {
// Use DataLoader to batch this!
return loaders.postsByAuthor.load(parent.id);
},
// Computed field: count of user's posts
postCount: async (parent, _args, { prisma }) => {
return prisma.post.count({
where: { authorId: parent.id },
});
},
},
Post: {
// Resolve the "author" field on a Post
author: (parent, _args, { loaders }) => {
// parent.authorId exists on the Post record
// DataLoader batches all author lookups into one query
return loaders.user.load(parent.authorId);
},
// Resolve the "comments" field on a Post
comments: (parent, _args, { prisma }) => {
return prisma.comment.findMany({
where: { postId: parent.id },
orderBy: { createdAt: 'desc' },
});
},
// Computed field: comment count (uses DataLoader)
commentCount: (parent, _args, { loaders }) => {
return loaders.commentCountByPost.load(parent.id);
},
},
Comment: {
author: (parent, _args, { loaders }) => {
return loaders.user.load(parent.authorId);
},
post: (parent, _args, { prisma }) => {
return prisma.post.findUnique({
where: { id: parent.postId },
});
},
},
};
module.exports = { resolvers, pubsub };
Step 6: Server Entry Point (Putting It All Together)
// src/index.js
// This is the main server file that ties everything together.
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const {
ApolloServerPluginDrainHttpServer,
} = require('@apollo/server/plugin/drainHttpServer');
const express = require('express');
const http = require('http');
const cors = require('cors');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PrismaClient } = require('@prisma/client');
const { typeDefs } = require('./schema/typeDefs');
const { resolvers } = require('./schema/resolvers');
const { getUser } = require('./middleware/auth');
const { createLoaders } = require('./utils/dataLoaders');
// Initialize Prisma Client (database connection)
const prisma = new PrismaClient({
log: ['query'], // Log SQL queries in development
});
async function startServer() {
// 1. Create Express app and HTTP server
const app = express();
const httpServer = http.createServer(app);
// 2. Create executable schema (combines typeDefs + resolvers)
const schema = makeExecutableSchema({ typeDefs, resolvers });
// 3. Set up WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql', // Same path as HTTP endpoint
});
const serverCleanup = useServer(
{
schema,
// Context for subscription connections
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
const user = getUser(token);
return { user, prisma, loaders: createLoaders(prisma) };
},
},
wsServer
);
// 4. Create Apollo Server
const server = new ApolloServer({
schema,
plugins: [
// Graceful shutdown for HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
// Graceful shutdown for WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
// Format errors for the client
formatError(formattedError, error) {
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error' };
}
}
return formattedError;
},
});
// 5. Start Apollo Server
await server.start();
// 6. Mount middleware
app.use(
'/graphql',
cors({
origin: ['http://localhost:3000', 'https://yourdomain.com'],
credentials: true,
}),
express.json({ limit: '10mb' }),
expressMiddleware(server, {
// CONTEXT FUNCTION: runs on EVERY request
// This is where we set up everything resolvers need
context: async ({ req }) => {
// Extract JWT from Authorization header
const token = req.headers.authorization || '';
const user = getUser(token);
return {
// Current authenticated user (or null)
user,
// Prisma client for database queries
prisma,
// DataLoaders (fresh per request!)
loaders: createLoaders(prisma),
// Raw request (for IP, headers, etc.)
req,
};
},
})
);
// 7. Health check endpoint (for load balancers)
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
// 8. Start listening
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`Server ready at http://localhost:${PORT}/graphql`);
console.log(`Subscriptions at ws://localhost:${PORT}/graphql`);
});
}
// Start the server
startServer().catch(console.error);
Step 7: Environment Variables
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/graphql_db"
# Auth
JWT_SECRET="your-super-secret-key-change-in-production"
# Server
PORT=4000
NODE_ENV=development
React Frontend
Complete frontend setup with Apollo Client, hooks, caching, and optimistic UI.
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.- 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.
- 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/cligenerates fully typed hooks from your schema + queries.
- 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.
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.Project Setup
# Create React app
npx create-react-app graphql-client
# OR with Vite (recommended)
npm create vite@latest graphql-client -- --template react
# Install Apollo Client
npm install @apollo/client graphql
Apollo Client Setup
// src/lib/apolloClient.js
// Apollo Client is the brain of GraphQL on the frontend.
// It handles: sending queries, caching, real-time, and state management.
import {
ApolloClient,
InMemoryCache,
createHttpLink,
split,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
// ===== HTTP Link (for queries & mutations) =====
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
// ===== Auth Link (adds JWT to every request) =====
const authLink = setContext((_, { headers }) => {
// Read token from localStorage
const token = localStorage.getItem('token');
return {
headers: {
...headers,
// If token exists, add it to the Authorization header
authorization: token ? `Bearer ${token}` : '',
},
};
});
// ===== WebSocket Link (for subscriptions) =====
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
// Send auth token through WebSocket connection
authorization: localStorage.getItem('token') || '',
},
})
);
// ===== Split Link (routes to HTTP or WS based on operation) =====
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink, // Subscriptions go through WebSocket
authLink.concat(httpLink) // Everything else goes through HTTP
);
// ===== Apollo Client Instance =====
const client = new ApolloClient({
link: splitLink,
// InMemoryCache is a normalized cache.
// It stores data by __typename + id, so if you fetch
// the same user in two different queries, it's stored once.
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Custom merge for paginated queries
posts: {
keyArgs: ['filters'], // Cache separately per filter
merge(existing, incoming, { args }) {
if (!args?.after) return incoming; // Fresh query
// Merge edges for infinite scroll
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
},
}),
// Default options for all queries
defaultOptions: {
watchQuery: {
// fetchPolicy determines where data comes from:
// 'cache-first' - check cache, then network (default)
// 'network-only' - always hit the network
// 'cache-and-network' - return cache, then update from network
// 'cache-only' - only use cache
fetchPolicy: 'cache-and-network',
},
},
});
export default client;
GraphQL Operations (Queries & Mutations)
// src/graphql/operations.js
// Define all GraphQL operations in one place.
// The `gql` tag parses these strings into document ASTs.
import { gql } from '@apollo/client';
// ===== FRAGMENTS (reusable field sets) =====
export const USER_FIELDS = gql`
fragment UserFields on User {
id
name
email
avatar
role
}
`;
export const POST_FIELDS = gql`
fragment PostFields on Post {
id
title
body
status
tags
commentCount
createdAt
author {
...UserFields
}
}
${USER_FIELDS}
`;
// ===== QUERIES =====
export const GET_ME = gql`
query GetMe {
me {
...UserFields
}
}
${USER_FIELDS}
`;
export const GET_POSTS = gql`
query GetPosts($first: Int, $after: String, $filters: PostFilters) {
posts(first: $first, after: $after, filters: $filters) {
edges {
node {
...PostFields
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
${POST_FIELDS}
`;
export const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
...PostFields
comments {
id
text
createdAt
author {
...UserFields
}
}
}
}
${POST_FIELDS}
`;
// ===== MUTATIONS =====
export const LOGIN = gql`
mutation Login($input: LoginInput!) {
login(input: $input) {
token
user {
...UserFields
}
}
}
${USER_FIELDS}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
...PostFields
}
errors {
field
message
}
}
}
${POST_FIELDS}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
errors {
field
message
}
}
}
`;
// ===== SUBSCRIPTIONS =====
export const COMMENT_ADDED = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
text
createdAt
author {
...UserFields
}
}
}
${USER_FIELDS}
`;
React Components (Using Apollo Hooks)
// src/App.jsx
import { ApolloProvider } from '@apollo/client';
import client from './lib/apolloClient';
import PostList from './components/PostList';
// ApolloProvider makes the client available to all components
// via React Context. Similar to Redux's Provider.
function App() {
return (
<ApolloProvider client={client}>
<div className="app">
<h1>My Blog</h1>
<PostList />
</div>
</ApolloProvider>
);
}
// src/components/PostList.jsx
// Demonstrates: useQuery, loading/error states, pagination
import { useQuery } from '@apollo/client';
import { GET_POSTS } from '../graphql/operations';
function PostList() {
// useQuery automatically:
// 1. Sends the query when component mounts
// 2. Returns loading/error/data states
// 3. Re-renders when data changes
// 4. Caches the result
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: {
first: 10,
filters: { status: 'PUBLISHED' },
},
// notifyOnNetworkStatusChange: true // for loading on fetchMore
});
// Loading state (first load)
if (loading && !data) return <p>Loading...</p>;
// Error state
if (error) return <p>Error: {error.message}</p>;
const { edges, pageInfo, totalCount } = data.posts;
// Load More handler (cursor-based pagination)
const handleLoadMore = () => {
fetchMore({
variables: {
after: pageInfo.endCursor, // "Give me items after this cursor"
},
// The merge function in cache config handles combining results
});
};
return (
<div>
<h2>Posts ({totalCount})</h2>
{edges.map(({ node: post }) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>By {post.author.name}</p>
<p>{post.commentCount} comments</p>
<div>{post.tags.map(t => <span key={t}>#{t}</span>)}</div>
</article>
))}
{pageInfo.hasNextPage && (
<button onClick={handleLoadMore}>
Load More
</button>
)}
</div>
);
}
// src/components/CreatePost.jsx
// Demonstrates: useMutation, optimistic UI, cache update
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_POST, GET_POSTS } from '../graphql/operations';
function CreatePost() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
// useMutation returns: [mutationFunction, { data, loading, error }]
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
// OPTION 1: refetchQueries - simple but slower
// Refetches the posts list after mutation completes
// refetchQueries: [{ query: GET_POSTS }],
// OPTION 2: update - manual cache update (faster, more control)
update(cache, { data: { createPost: result } }) {
if (!result.post) return; // Don't update cache on error
// Read existing posts from cache
const existing = cache.readQuery({
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
});
if (existing) {
// Write updated data to cache
cache.writeQuery({
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
data: {
posts: {
...existing.posts,
edges: [
{ node: result.post, cursor: btoa(result.post.id) },
...existing.posts.edges,
],
totalCount: existing.posts.totalCount + 1,
},
},
});
}
},
// OPTION 3: Optimistic Response (instant UI update)
// Shows the new post immediately, before server responds.
// If the mutation fails, Apollo reverts the optimistic update.
optimisticResponse: {
createPost: {
__typename: 'PostPayload',
post: {
__typename: 'Post',
id: 'temp-id-' + Date.now(), // Temporary ID
title,
body,
status: 'DRAFT',
tags: [],
commentCount: 0,
createdAt: new Date().toISOString(),
author: {
__typename: 'User',
id: 'current-user-id',
name: 'You',
email: '',
avatar: null,
role: 'USER',
},
},
errors: [],
},
},
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
await createPost({
variables: {
input: { title, body },
},
});
setTitle('');
setBody('');
} catch (err) {
console.error('Failed to create post:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Post title"
required
/>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="Post body"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p className="error">{error.message}</p>}
</form>
);
}
// src/components/PostComments.jsx
// Demonstrates: useSubscription for real-time updates
import { useQuery, useSubscription } from '@apollo/client';
import { GET_POST, COMMENT_ADDED } from '../graphql/operations';
function PostComments({ postId }) {
// Fetch initial comments
const { data } = useQuery(GET_POST, {
variables: { id: postId },
});
// Subscribe to new comments in real-time
useSubscription(COMMENT_ADDED, {
variables: { postId },
// onData fires every time the server pushes a new comment
onData({ client, data: subData }) {
const newComment = subData.data.commentAdded;
// Update the cache with the new comment
const existing = client.cache.readQuery({
query: GET_POST,
variables: { id: postId },
});
if (existing) {
client.cache.writeQuery({
query: GET_POST,
variables: { id: postId },
data: {
post: {
...existing.post,
comments: [newComment, ...existing.post.comments],
},
},
});
}
},
});
return (
<div>
<h3>Comments</h3>
{data?.post?.comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author.name}</strong>
<p>{comment.text}</p>
</div>
))}
</div>
);
}
Authentication & Authorization
How to secure your GraphQL API properly.
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.- 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.
- 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
nullfor forbidden fields rather than erroring — common in public APIs. - Row-level security (RLS): Push authZ into Postgres with Hasura/PostGraphile — the DB enforces it.
- 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.
posts(id:"123") must check ownership server-side. Never trust anything from args for authZ.Authentication vs Authorization
Authentication (AuthN)
"Who are you?"
Verifying identity. Handled in the context function — extract JWT from headers, verify it, attach user to context.
Authorization (AuthZ)
"What can you do?"
Checking permissions. Handled in resolvers or middleware — check if user has the right role/permissions for this operation.
// PATTERN 1: Direct in resolver (simple)
const resolvers = {
Mutation: {
deleteUser: (_, { id }, context) => {
if (!context.user) throw new AuthenticationError('Login required');
if (context.user.role !== 'ADMIN') throw new ForbiddenError('Admin only');
// ... delete user
},
},
};
// PATTERN 2: Schema directive (scalable, used in production)
// In schema: type Mutation { deleteUser(id: ID!): User @auth(requires: ADMIN) }
// A directive transformer checks the role before the resolver runs.
// PATTERN 3: Middleware layer (using graphql-shield)
const { shield, rule, allow, deny } = require('graphql-shield');
const isAuthenticated = rule()((_, __, ctx) => ctx.user !== null);
const isAdmin = rule()((_, __, ctx) => ctx.user?.role === 'ADMIN');
const isOwner = rule()((_, { id }, ctx) => ctx.user?.userId === id);
const permissions = shield({
Query: {
me: isAuthenticated,
users: isAdmin,
},
Mutation: {
createPost: isAuthenticated,
deletePost: isOwner,
},
});
Error Handling
GraphQL handles errors differently from REST. Understanding this is critical.
{ 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.- Top-level errors (classic): Throw
GraphQLErrorfrom 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.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.- 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.Statusdetails. Similar "errors as data" spirit. - vs SOAP: SOAP has the infamous
<Fault>envelope — verbose and rigid.
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.userErrors array. GitHub uses rich error extensions with documentation URLs. Stripe's GraphQL experiments embed idempotency keys and retry hints in extensions.GraphQL always returns HTTP 200, even on errors. Errors are in the response body under an "errors" key. The "data" key may be partially populated (partial success is possible!).
{
// Data can be PARTIALLY present even with errors!
"data": {
"user": {
"name": "Yatin",
"email": null // This field errored
}
},
"errors": [
{
"message": "Not authorized to view email",
"locations": [{ "line": 4, "column": 5 }],
"path": ["user", "email"], // Which field errored
"extensions": {
"code": "FORBIDDEN", // Machine-readable error code
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
const { GraphQLError } = require('graphql');
// Standard error codes that Apollo/clients understand:
// GRAPHQL_PARSE_FAILED - Query syntax error
// GRAPHQL_VALIDATION_FAILED - Query doesn't match schema
// BAD_USER_INPUT - Invalid argument values
// UNAUTHENTICATED - Not logged in
// FORBIDDEN - Logged in but not authorized
// PERSISTED_QUERY_NOT_FOUND
// INTERNAL_SERVER_ERROR - Catch-all
throw new GraphQLError('Post not found', {
extensions: {
code: 'NOT_FOUND',
argumentName: 'id',
// Add any extra context you want
},
});
Performance Optimization
From N+1 to caching — making GraphQL fast at scale.
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.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.- Client cache: Apollo/Relay normalized in-memory cache.
- Persisted queries + CDN: Replace the query string with a hash;
GET /graphql?id=abcbecomes cacheable by Cloudflare/Fastly. - Server response cache:
@apollo/server-plugin-response-cachewith 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.
- 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.
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.
// A malicious query could look like this:
// query {
// users {
// posts {
// author {
// posts {
// author {
// posts { ... 50 levels deep ... }
// }
// }
// }
// }
// }
// }
// Solution 1: Depth limiting
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
validationRules: [depthLimit(7)], // Max 7 levels deep
});
// Solution 2: Query complexity analysis
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000), // Max complexity score of 1000
],
});
Automatic Persisted Queries (APQ)
Instead of sending the full query string every time, send a hash. The server has a lookup table of hash → query.
// Without APQ (every request):
// POST /graphql
// Body: { query: "query GetUser($id: ID!) { user(id: $id) { name email ... } }" }
// ^^ This string can be HUGE for complex queries
// With APQ (first request sends hash + query, subsequent just hash):
// POST /graphql
// Body: { extensions: { persistedQuery: { sha256Hash: "abc123..." } } }
// ^^ Much smaller payload
// Client setup:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 }).concat(httpLink);
Production Architecture
How companies like GitHub, Shopify, and Netflix run GraphQL in production.
- 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.
- 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.
- 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.
Production Architecture Diagram
┌─────────────┐
│ Clients │
│ Web/Mobile │
└──────┬──────┘
│
┌──────▼──────┐
│ CDN │ ← APQ cache, static assets
│ CloudFront │
└──────┬──────┘
│
┌──────▼──────┐
│ Load │ ← Rate limiting, WAF
│ Balancer │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌────▼─────┐ ┌────▼─────┐
│ GraphQL │ │ GraphQL │ │ GraphQL │ ← Multiple instances
│ Server 1 │ │ Server 2 │ │ Server 3 │ (horizontal scaling)
└─────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────┼────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌──────▼─────┐
│ Postgres │ │ Redis │ │ Upstream │
│ DB │ │ Cache │ │ REST APIs │
└──────────┘ └──────────┘ └────────────┘
Production Checklist
Disable Introspection
In production, disable schema introspection to prevent attackers from discovering your full API surface.
new ApolloServer({
introspection: process.env.NODE_ENV !== 'production',
})
Rate Limiting
Apply per-IP and per-user rate limits. Consider query complexity in your rate limit budget.
// Simple rate limit: 100 queries/minute/IP
// Advanced: cost-based rate limiting
// where complex queries cost more
Query Allow-listing
Only allow pre-approved queries in production. Reject any unknown query string.
Monitoring & Observability
Track resolver execution time, error rates, and query patterns. Use Apollo Studio or custom tracing.
Error Masking
Never expose stack traces or internal errors to clients. Log the full error server-side, send generic message to client.
Connection Pooling
Use a connection pool for your database. Prisma does this automatically. For REST data sources, use keep-alive.
Caching Strategies in Production
// ===== 1. HTTP Caching (CDN-level) =====
// For public queries, you can use GET requests + HTTP cache headers
// Apollo Server supports this for queries (not mutations)
// In schema, add cache hints:
// type User @cacheControl(maxAge: 300) {
// name: String! # Inherits 300s
// email: String! @cacheControl(maxAge: 0) # Never cache
// }
// ===== 2. Server-side Redis Cache =====
const Redis = require('ioredis');
const redis = new Redis();
// In resolver:
async function getUser(id) {
// Check Redis first
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// Not in cache, fetch from DB
const user = await prisma.user.findUnique({ where: { id } });
// Store in Redis with TTL (Time To Live)
await redis.setex(`user:${id}`, 300, JSON.stringify(user)); // 5 min
return user;
}
// ===== 3. Client-side (Apollo Cache) =====
// Already covered in the React section.
// Apollo's InMemoryCache is a normalized cache that
// deduplicates entities by __typename + id.
Security
GraphQL-specific security concerns and how to address them.
- Query depth attacks:
user { friends { friends { friends { ... } } } }. Fix:graphql-depth-limitat 7-10. - Query complexity / cost attacks:
posts(first: 10000) { comments(first: 1000) }. Fix:graphql-query-complexitywith per-field costs. - Introspection in prod: Leaks your entire schema. Fix: disable
introspection: falseor 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.idserver-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-uploadfile sizes.
graphql-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-*.- 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.
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.1. Query Depth Attack
Risk: Deeply nested queries can crash the server (DoS).
Fix: graphql-depth-limit middleware, max depth of 7-10.
2. Query Complexity Attack
Risk: Requesting expensive fields (like connections with 1000 items).
Fix: Query complexity analysis. Assign cost to fields, reject if total exceeds budget.
3. Introspection Abuse
Risk: Attackers discover your entire schema structure.
Fix: Disable introspection in production.
4. Batch Attack
Risk: Sending 1000 mutations in one request.
Fix: Limit batch size, query cost analysis.
5. Injection
Risk: SQL/NoSQL injection through arguments.
Fix: Always use parameterized queries (Prisma does this by default). Validate inputs.
6. Information Disclosure
Risk: Error messages exposing stack traces or internal details.
Fix: Custom formatError that strips sensitive info in production.
Testing GraphQL
Unit testing resolvers, integration testing the full API, and mocking on the frontend.
MockedProvider from Apollo, or MSW with handlers matching operation names).- Unit (backend): Jest / Vitest — resolvers are plain functions.
- Integration (backend):
@apollo/serverexecuteOperationAPI, or Supertest + real HTTP, with a test DB (Docker Postgres,pg-mem, SQLite). - Snapshot / schema:
@graphql-inspector/clidiffs old vs new schema, fails CI on breaking changes. - Contract: Pact +
@pact-foundation/pactGraphQL adapter. - Frontend:
@apollo/client/testingMockedProvider,mswGraphQL handlers, Storybook interaction tests. - E2E: Playwright / Cypress hitting a real server.
- Load testing:
k6or Artillery with a GraphQL plugin.
- 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.
- 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.
MockedProvider requiring exact query matches including variables.Backend: Integration Testing
// __tests__/posts.test.js
const { ApolloServer } = require('@apollo/server');
const { typeDefs } = require('../src/schema/typeDefs');
const { resolvers } = require('../src/schema/resolvers');
// Create a test server
const testServer = new ApolloServer({ typeDefs, resolvers });
describe('Posts API', () => {
it('should fetch published posts', async () => {
const result = await testServer.executeOperation(
{
query: `
query GetPosts($first: Int) {
posts(first: $first) {
edges {
node { id title }
}
totalCount
}
}
`,
variables: { first: 5 },
},
{
// Provide mock context
contextValue: {
prisma: mockPrisma,
user: { userId: '1', role: 'USER' },
loaders: mockLoaders,
},
}
);
// Assert
expect(result.body.singleResult.errors).toBeUndefined();
expect(result.body.singleResult.data.posts.edges.length)
.toBeLessThanOrEqual(5);
});
it('should require auth for createPost', async () => {
const result = await testServer.executeOperation(
{
query: `
mutation { createPost(input: { title: "Test", body: "..." }) {
post { id } errors { message }
}}
`,
},
{
contextValue: {
prisma: mockPrisma,
user: null, // No auth!
loaders: mockLoaders,
},
}
);
// Should get UNAUTHENTICATED error
expect(result.body.singleResult.errors[0].extensions.code)
.toBe('UNAUTHENTICATED');
});
});
Frontend: MockedProvider
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import PostList from './PostList';
import { GET_POSTS } from '../graphql/operations';
const mocks = [
{
request: {
query: GET_POSTS,
variables: { first: 10, filters: { status: 'PUBLISHED' } },
},
result: {
data: {
posts: {
edges: [
{
node: {
id: '1',
title: 'Test Post',
// ... all required fields
},
cursor: 'abc',
},
],
pageInfo: { hasNextPage: false, endCursor: 'abc' },
totalCount: 1,
},
},
},
},
];
test('renders posts', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<PostList />
</MockedProvider>
);
// Wait for data to load
const post = await screen.findByText('Test Post');
expect(post).toBeInTheDocument();
});
Apollo Federation
How large teams split GraphQL across microservices. The architecture pattern used by Netflix, Airbnb, Expedia.
POST /graphql and has no idea microservices exist underneath.- @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 composemerges subgraph schemas into a supergraph SDL. - Router: Rust binary that does query planning, fetches from subgraphs in parallel, and stitches results.
- 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.
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.What is Federation?
Federation lets you compose a single unified GraphQL API from multiple GraphQL services (subgraphs). Each team owns their slice of the schema.
┌─────────────┐
│ Client │ Sees ONE unified schema
└──────┬──────┘
│
┌──────▼──────┐
│ Apollo │ The "router" that composes
│ Gateway │ subgraphs into one API
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌──────▼─────┐
│ Users │ │ Posts │ │ Comments │
│ Subgraph │ │ Subgraph │ │ Subgraph │
│ (Team A) │ │ (Team B) │ │ (Team C) │
└──────────┘ └──────────┘ └────────────┘
# Users subgraph (owned by Team A)
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
# Posts subgraph (owned by Team B)
# Can EXTEND the User type from another subgraph!
type User @key(fields: "id") {
id: ID!
posts: [Post!]! # Team B adds posts to User
}
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
Don't federate until you have: multiple teams, clear domain boundaries, and the monolithic schema is becoming a bottleneck. Premature federation adds significant complexity with little benefit.
Do federate when: teams need independent deploy cycles, the schema is too large for one team to own, or you're composing existing microservice APIs.
Interview Questions & Answers
40+ questions organized by topic. Click to reveal answers.
- 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 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.
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.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).
CRUD: Mongoose & Prisma
Complete Create, Read, Update, Delete with both ORMs — every line explained.
- 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
.prismaschema 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.
- 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.
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
# 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.
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
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.
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.
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)
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.
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
}
}
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
}
}
query {
users {
id
name
email
age
role
posts { # ← Nested: each user's posts are resolved
id
title
published
}
}
}
query {
user(id: "PASTE_USER_ID_HERE") {
name
email
posts {
title
}
}
}
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
}
}
mutation {
deletePost(id: "PASTE_POST_ID_HERE") {
id
title # Returns the deleted post's data
}
}
mutation {
deleteUser(id: "PASTE_USER_ID_HERE") {
id
name
email
}
}
Part 2: GraphQL CRUD with Prisma (PostgreSQL)
Step 1: Project Setup
# 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.
// 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')
# 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
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.
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.
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)
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.
# 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
// ==========================================
// 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
// 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