Search Tech Journey

Find topics, journeys and posts

6-month learning plan57 / 130
back to blog
backend apisintermediate 15m read

S057 · GraphQL — Schema, Resolvers, N+1, When to Pick It

REST's flexible cousin.

Module M06: Backend & APIs · Session 57 of 130 · Track: SYS ◈

What you'll be able to do after this session

  • Explain GraphQL to a friend in one minute, out loud, no notes.
  • Recognise it when you see it in real code / systems / papers.
  • Complete the hands-on exercise at the end without looking up help.

Prerequisites

If you can't answer these in 30 seconds each, redo the linked session first:


Pre-read (skim before the session)


(a) Intuition · 5 min

The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.

In one sentence: REST's flexible cousin.

Imagine ordering food at a restaurant. REST is a fixed-menu prix-fixe: you say "I'll have order #47" and get exactly what the kitchen decided item 47 contains — 4 courses, whether you wanted them all or not. GraphQL is the à la carte menu: you write down exactly what you want ("Caesar salad, hold the anchovies, extra dressing on the side, chicken not shrimp") and the kitchen delivers precisely that. One endpoint, one request, exactly the fields you asked for.

The problem GraphQL was invented to solve (at Facebook, 2012, for the mobile News Feed): the mobile team was making 10+ REST calls per screen and getting back way more data than they needed. Bandwidth on 3G was expensive, latency was terrible, and every screen redesign meant coordinating with the backend team. GraphQL flips it: the client declares its needs, the server executes against a strongly-typed schema, and no more overfetching or underfetching.

The three pillars: (1) Schema — a strongly-typed contract (type User { id: ID!, name: String!, posts: [Post!]! }) describing what's queryable. (2) Queries — a client-written selection like { user(id: 42) { name posts { title } } } returning exactly those fields as JSON. (3) Resolvers — server functions the runtime calls to fetch each field. Plus Mutations (writes) and Subscriptions (live updates via websockets).

Forever gotcha: GraphQL solves overfetching but creates the N+1 query problem. A query for { posts { author { name } } } naïvely calls getAuthor(id) once per post — 100 posts = 101 DB queries. You MUST use DataLoader (a batching+caching layer) or your DB will melt.


(b) Visual walkthrough · 15 min

Diagrams, worked examples, step-by-step visuals.

REST vs GraphQL — same use case, side by side:

A worked example — the schema:

type Query {
  user(id: ID!): User
  posts(limit: Int = 20): [Post!]!
}
 
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
 
type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments: [Comment!]!
}
 
type Comment {
  id: ID!
  text: String!
  author: User!
}

Client sends:

query MobileHome {
  user(id: 42) {
    name
    posts {
      title
      comments { text author { name } }
    }
  }
}

Server returns exactly that shape as JSON — no extra fields, no missing fields.

The N+1 problem, visualised:

Without DataLoader, resolving posts { author { name } } for 100 posts:

Query #SQLComment
1SELECT * FROM posts LIMIT 100The top-level query
2SELECT * FROM users WHERE id = 1Post 1's author
3SELECT * FROM users WHERE id = 2Post 2's author
.........
101SELECT * FROM users WHERE id = 100Post 100's author

With DataLoader batching:

Query #SQL
1SELECT * FROM posts LIMIT 100
2SELECT * FROM users WHERE id IN (1,2,3,...,100)

Two queries instead of 101. DataLoader coalesces all getUser(id) calls within one tick of the event loop into a single batch call.

Query cost management — because clients can request arbitrarily deep queries, servers must guard against:

  • Depth limiting (graphql-depth-limit): reject queries deeper than N levels.
  • Complexity scoring: assign a "cost" per field (list fields cost more than scalars); reject if total > budget.
  • Persisted queries: production clients pre-register their query documents; server rejects any query not in the whitelist. Massive perf win + security win.

Mutations and subscriptions:

mutation { createPost(input: { title: "Hi", body: "..." }) { id } }
subscription { newComment(postId: 1) { id text author { name } } }

Subscriptions typically ride on websockets or SSE.


(c) Hands-on · 20 min

Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.

Build a real GraphQL server with N+1 protection in ~70 lines of Python + Strawberry.

python -m venv .venv && source .venv/bin/activate
pip install strawberry-graphql[debug-server]==0.239.0
# gql.py
import strawberry, time
from typing import List, Optional
from strawberry.dataloader import DataLoader
 
# ---- fake "database" ----
USERS = {i: {"id": i, "name": f"User{i}", "email": f"u{i}@x.com"} for i in range(1, 6)}
POSTS = [{"id": i, "title": f"Post {i}", "author_id": (i % 5) + 1, "body": "hi"}
         for i in range(1, 21)]
 
db_calls = 0  # counter to prove batching works
 
async def batch_load_users(user_ids: List[int]) -> List[dict]:
    global db_calls
    db_calls += 1
    print(f"  [DB] batch fetch users: {user_ids}")
    time.sleep(0.02)  # simulate network
    return [USERS.get(uid) for uid in user_ids]
 
user_loader = DataLoader(load_fn=batch_load_users)
 
# ---- schema types ----
@strawberry.type
class User:
    id: int
    name: str
    email: str
 
@strawberry.type
class Post:
    id: int
    title: str
    body: str
    author_id: strawberry.Private[int]
 
    @strawberry.field
    async def author(self) -> User:
        u = await user_loader.load(self.author_id)
        return User(**u)
 
@strawberry.type
class Query:
    @strawberry.field
    def posts(self, limit: int = 20) -> List[Post]:
        return [Post(id=p["id"], title=p["title"], body=p["body"], author_id=p["author_id"])
                for p in POSTS[:limit]]
 
    @strawberry.field
    async def user(self, id: int) -> Optional[User]:
        u = await user_loader.load(id)
        return User(**u) if u else None
 
@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_post(self, title: str, body: str, author_id: int) -> Post:
        new_id = max(p["id"] for p in POSTS) + 1
        p = {"id": new_id, "title": title, "body": body, "author_id": author_id}
        POSTS.append(p)
        return Post(**p)
 
schema = strawberry.Schema(query=Query, mutation=Mutation)

Run: strawberry server gql:schema — opens GraphiQL at http://127.0.0.1:8000/graphql

Try these queries in GraphiQL:

# 1. Simple query
query { user(id: 1) { name email } }
 
# 2. Nested (this WOULD be N+1 without DataLoader)
query {
  posts(limit: 20) {
    title
    author { name }
  }
}
 
# 3. Mutation
mutation {
  createPost(title: "GraphQL rocks", body: "yes", authorId: 1) { id title }
}

What to observe:

  1. Look at the server console during query #2: you see ONE [DB] batch fetch users: [1,2,3,4,5] — not 20 separate fetches. That's DataLoader batching.
  2. GraphiQL auto-completes fields from the schema — no manual doc-hunting.
  3. Ask for a non-existent field like posts { unicorn } — you get a compile-time error before any DB call.
  4. mutation uses the same endpoint as query — GraphQL has ONE HTTP endpoint total.
  5. Change limit: 20limit: 200 and note the server response is still exactly what you asked for.

Try this modification: add a depth limiter. Install strawberry-graphql[opentelemetry] and use the QueryDepthLimiter extension (or write one — it's a resolver middleware that walks the AST). Then try a malicious query like { posts { author { posts { author { posts { author { name } } } } } } } and watch it get rejected. This is essential DoS protection for a public GraphQL endpoint.


(d) Production reality · 10 min

How this shows up in real systems, gotchas, war stories, common bugs.

GraphQL is the default choice at Meta (where it was born), GitHub (their public v4 API is GraphQL), Shopify, Airbnb, and Netflix (with federation). It's not the right default for internal server-to-server APIs or purely public data — REST/gRPC often win there. Rule of thumb: use GraphQL when you have a rich, nested, evolving domain and heterogeneous clients (web + iOS + Android + partners) that each want different slices.

War stories:

  • The DoS query. Public GraphQL endpoint with no depth or complexity limits. Attacker sent { users { posts { author { posts { author { ... } } } } } } 100 levels deep. Server melted trying to hydrate an exponential explosion. Fix: query depth limit (7-10 is typical), field-cost analysis, persisted queries for known clients.
  • The N+1 that hid. Team added a new nested field, forgot to add DataLoader for it. DB queries per request grew from 3 → 300. P99 latency went from 80ms → 4s. Fix: automated tests that assert query-count per resolver; tools like graphql-benchmark catch this in CI.
  • The cache-less world. REST responses cache trivially on CDN by URL. GraphQL POSTs to one URL — CDN can't cache. Solutions: (a) persisted queries with GET (/graphql?hash=abc123), (b) response cache keyed on query+variables, (c) Apollo's automatic-persisted-queries protocol.
  • The federation nightmare. Netflix and many others split GraphQL across teams via federation (Apollo Federation, Wundergraph, Grafbase). One gateway stitches sub-schemas from 20+ services. Powerful but complex — ownership of shared types (User) becomes a governance problem.
  • The subscription scaling wall. GraphQL subscriptions over websockets sound great until you have 500k concurrent connections and no plan for authentication drift, reconnect storms, or backpressure. Most teams end up using SSE or a dedicated real-time layer (Pusher, Ably, Supabase Realtime) instead.
  • The "one-size-fits-all" trap. Team ripped out REST, moved everything to GraphQL "for consistency." A file-upload endpoint became a nightmare (GraphQL has no first-class binary support — you use multipart-request spec or shim). Some things (health checks, webhooks) never should have moved. Fix: use GraphQL where it earns its keep; keep REST for the rest.

How top teams handle it: persisted queries in production (only pre-registered queries allowed), DataLoader in every resolver that touches related data, query-cost analysis in the middleware chain, tracing per resolver (Apollo Studio / OpenTelemetry), and a schema linter in CI (graphql-schema-linter) to enforce naming and deprecation conventions.

Gotcha to memorise: there is no "GraphQL versioning." The convention is to never remove fields — mark them @deprecated(reason: "use xFoo instead") and let old clients keep using them. This means your schema grows monotonically forever unless you have serious client-tracking to know when it's safe to remove.


(e) Quiz + exercise · 10 min

  1. In one sentence, what problem does GraphQL solve that REST doesn't?
  2. What is the N+1 problem, and what pattern solves it?
  3. Why can't a CDN cache GraphQL POST responses the same way it caches REST GETs?
  4. Give two DoS-prevention techniques for a public GraphQL endpoint.
  5. Why does GraphQL not have "versioning" in the REST sense, and how do teams evolve schemas instead?

Stretch (connects to S056 — REST): Compare the same "get orders + line items + product info" operation implemented as (a) three REST calls, (b) one GraphQL query. Count round trips, bytes transferred (order-of-magnitude), and code complexity on both client and server. When does GraphQL clearly win, and when does REST clearly win?


Explain-out-loud test

If you can't teach these 3 things to a friend without notes, redo the session:

  1. What is GraphQL? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.

More from M06 · Backend & APIs

all modules →
  1. 055HTTP Fundamentals — Verbs, Status Codes, Headers, Caching
  2. 056REST API Design — Resources, Versioning, Idempotency
  3. 058gRPC & Protobuf — When RPC Wins
  4. 059AuthN & AuthZ — OAuth 2.0, OIDC, JWT