Module 03: GraphQL Fundamentals
Why this matters
Modules 01–02 covered the RPC-style edge — fast, typed, internal. This module
turns to the other major alternative to REST, and the one that most directly
targets REST's over-/under-fetching pain from module 00: GraphQL. Where REST
exposes many endpoints each returning a fixed shape, GraphQL exposes one
endpoint and a typed schema, and lets the client ask for exactly the
fields and relationships it wants — no more, no less — in a single request. The
mobile home screen that needed four REST round trips becomes one query. The
/screens/* aggregation-endpoint sprawl from module 00's diagnose-and-fix
collapses into resolvers over a shared schema.
The trade you're making is worth naming up front. GraphQL moves power to the
client (great for frontend teams with varied, evolving needs) and gives you a
strongly typed, introspectable, self-documenting schema. In exchange, you give
up a lot of what made REST operationally cheap: HTTP caching mostly stops
working (everything is a POST to one URL), and you inherit a brand-new failure
mode — the N+1 query problem — that will quietly melt your database if you
don't defend against it with a DataLoader. Understanding N+1 and DataLoader
isn't advanced trivia; it's the price of admission for running GraphQL without
an outage.
You'll build this in Python with Strawberry, a modern, type-hint-based GraphQL library that pairs naturally with FastAPI (Ariadne is a fine schema-first alternative; the concepts transfer). This is fundamentals: schema, types, queries, mutations, resolvers, and the N+1/DataLoader defense. Subscriptions, pagination, authorization, and federation are module 04. This module also closes with the track's first cumulative review across modules 00–03.
Concepts
The schema and the type system
GraphQL is schema-first in spirit: the schema is a typed contract describing every type, field, and operation the API offers. It's written in the Schema Definition Language (SDL) — a compact, language-neutral notation:
type Author {
id: ID!
name: String!
books: [Book!]! # a non-null list of non-null Books
}
type Book {
id: ID!
title: String!
author: Author! # the graph: Book points back to Author
}
type Query { # the read entry points
book(id: ID!): Book
books: [Book!]!
}
Key pieces of the type system:
- Object types (
Author,Book) — the nouns, each with typed fields. - Scalars —
Int,Float,String,Boolean,ID(plus custom scalars likeDateTime). - Non-null (
!) and lists ([]).String!is required;[Book!]!is a required list of required Books. Nullability is part of the contract — unlike REST/JSON where everything is implicitly nullable. Query,Mutation,Subscription— three special "root" types that are the entry points for reads, writes, and streams respectively.
Because the schema is fully typed and introspectable, tools (GraphiQL, Apollo Studio) give you auto-complete, in-browser docs, and validation for free — the GraphQL analog of the OpenAPI payoff from track 02, module 09.
Queries: the client picks the shape
A query is the client declaring exactly what it wants. The response mirrors the query's shape, field for field:
query {
book(id: "1") {
title
author {
name # follow the graph edge in the SAME request
}
}
}
{ "data": { "book": { "title": "Dune", "author": { "name": "Herbert" } } } }
This is the whole value proposition: the client got the book's title and its
author's name — and nothing else — in one round trip, without the server
defining a bespoke /books/1?include=author endpoint. Ask for three fields, get
three fields; traverse a relationship, get the related object inline. No
over-fetch, no under-fetch. Queries can also take arguments (book(id: "1")) and use variables (book(id: $id)) so clients don't string-build
queries.
Mutations: writes
Reads go through Query; every write — create, update, delete — goes through
Mutation. A mutation looks like a query with arguments, and, importantly,
it too selects what to return, so the client can fetch the updated object's
new state in the same call:
mutation {
addBook(title: "Dune", authorId: "1") {
id
title
author { name } # get back exactly the fields you need post-write
}
}
The convention: Query fields are side-effect-free reads; Mutation fields
change state. (There's no HTTP-verb enforcement — GraphQL is one POST — so the
Query/Mutation split is your read/write discipline. Putting a write behind a
Query field is a real and common design bug.)
Resolvers: where fields get their data
A resolver is a function that produces the value for a single field. This is
the core execution idea and it trips people up coming from REST: GraphQL doesn't
resolve a whole response in one handler — it resolves the query field by
field, calling the resolver for each requested field, walking down the tree.
book resolves to a Book; then for that Book the engine calls the author
resolver to get its Author; then the name resolver on that Author.
In Strawberry, types are Python classes with type hints, and resolvers are methods/functions:
import strawberry
@strawberry.type
class Author:
id: strawberry.ID
name: str
@strawberry.type
class Book:
id: strawberry.ID
title: str
author_id: strawberry.Private[str] # not exposed in the schema
@strawberry.field
def author(self) -> Author: # a resolver for the 'author' field
return get_author(self.author_id) # called only if the client asks for it
@strawberry.type
class Query:
@strawberry.field
def book(self, id: strawberry.ID) -> Book | None:
return get_book(id)
@strawberry.field
def books(self) -> list[Book]:
return get_all_books()
schema = strawberry.Schema(query=Query)
The crucial mechanical fact: the author resolver runs once per Book that the
query actually reaches. For a single book, fine. For a list of 100 books each
selecting author, that's 100 separate author resolver calls — which is
exactly how N+1 is born.
The N+1 problem
Consider this innocent-looking query:
query { books { title author { name } } }
The books resolver runs 1 query to fetch 100 books. Then, because the
client selected author on each, the author resolver fires N = 100 more
times — one DB lookup per book. That's 1 + N = 101 queries for one request,
most of them fetching authors you'll fetch again and again (many books share an
author). This is the N+1 query problem, and it's GraphQL's signature
performance trap: the client-driven, field-by-field execution model makes it
absurdly easy to trigger, and it scales with result-set size, so it passes tests
with 3 rows and falls over in production with 3,000.
REST rarely hits this because a /books?include=author endpoint is written by
a human who naturally writes one JOIN. GraphQL's resolver-per-field model has
no such human in the loop — the framework calls author 100 times unless you
intervene.
DataLoader: the fix
A DataLoader solves N+1 by batching and caching resolver calls within a
single request. Instead of each author resolver hitting the DB immediately, it
registers "I need author X" with a loader; the loader collects all the requested
keys during that tick, then issues one batched query (SELECT * FROM authors WHERE id IN (...)) and hands each resolver its result. It also caches per key
within the request, so asking for author 5 twice hits the DB once.
from strawberry.dataloader import DataLoader
async def load_authors(keys: list[str]) -> list[Author]:
# ONE query for all requested author ids, returned in key order
rows = await db.fetch_authors_by_ids(keys)
by_id = {r.id: r for r in rows}
return [by_id.get(k) for k in keys] # must return one item per key, in order
author_loader = DataLoader(load_fn=load_authors)
@strawberry.type
class Book:
id: strawberry.ID
title: str
author_id: strawberry.Private[str]
@strawberry.field
async def author(self) -> Author:
return await author_loader.load(self.author_id) # batched, not immediate
{ books { title author { name } } } over 100 books
WITHOUT DataLoader (N+1) WITH DataLoader (batched)
books ─► 1 query -> 100 books books ─► 1 query -> 100 books
author(b1) ─► SELECT author 1 author(b1) ─► loader.load(1) ┐
author(b2) ─► SELECT author 2 author(b2) ─► loader.load(2) ├─ collected
... 100 separate lookups ... ... loader.load(k) ┘ this tick
author(b100) ─► SELECT author 100 one batch ─► WHERE id IN (1,2,..)
= 1 + 100 = 101 queries = 1 + 1 = 2 queries
Now the 100-book query runs 2 queries — one for books, one batched query for
all their authors — instead of 101. Two rules that matter: the load_fn must
return exactly one result per input key, in the same order (or None), and
the loader must be scoped to one request (a fresh loader per request, held in
the GraphQL context) so its cache doesn't leak stale data between users. Getting
those wrong is the subject of this module's diagnose-and-fix.
Serving GraphQL from FastAPI
Strawberry mounts as a single route on your existing FastAPI app — one endpoint, all operations:
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
async def get_context():
# fresh per-request loaders live here (see module 04 for auth in context)
return {"author_loader": DataLoader(load_fn=load_authors)}
graphql_app = GraphQLRouter(schema, context_getter=get_context)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")
Visit /graphql for the in-browser GraphiQL explorer (schema docs +
auto-complete, the OpenAPI-/docs analog). Note the shape: GraphQL doesn't
replace FastAPI here — it's one route on it. That's exactly the BFF pattern
module 06 formalizes.
Command reference
| Item | Purpose | Example |
|---|---|---|
SDL type X { field: T } |
Define an object type | type Book { title: String! } |
! / [T!]! |
Non-null / list-of-non-null | books: [Book!]! |
type Query |
Read entry points | book(id: ID!): Book |
type Mutation |
Write entry points | addBook(...): Book |
@strawberry.type |
Python class → GraphQL type | on a class |
@strawberry.field |
Mark a resolver | method/function |
strawberry.ID / strawberry.Private[T] |
ID scalar / hidden field | not in schema |
strawberry.Schema(query=, mutation=) |
Build the schema | root wiring |
DataLoader(load_fn=...) |
Batch+cache resolver loads | fixes N+1 |
.load(key) |
Enqueue a key for batched load | await loader.load(id) |
GraphQLRouter(schema, context_getter=) |
Mount on FastAPI | one /graphql route |
GraphiQL (/graphql in browser) |
Interactive explorer + docs | /docs analog |
A mutation with variables (how clients really send writes — never string-built):
mutation AddBook($title: String!, $authorId: ID!) {
addBook(title: $title, authorId: $authorId) { id title }
}
# variables: { "title": "Dune", "authorId": "1" }
The Strawberry mutation resolver:
@strawberry.type
class Mutation:
@strawberry.mutation
def add_book(self, title: str, author_id: strawberry.ID) -> Book:
return create_book(title=title, author_id=author_id)
schema = strawberry.Schema(query=Query, mutation=Mutation)
Hands-on exercises
Create a graphql-books/ project. pip install "strawberry-graphql[fastapi]" uvicorn. Back it with an in-memory dict of authors and books to start (you can
swap in a real DB later). Run with uvicorn app:app --reload and open
/graphql.
1. Define the schema
Model Author (id, name) and Book (id, title, author), plus a
Query with book(id) and books. Run the app and open /graphql. Expected:
the GraphiQL explorer shows your types and the two query fields, with docs and
auto-complete — no hand-written documentation.
2. Your first client-shaped query
In GraphiQL, run { books { title } }, then { book(id: "1") { title author { name } } }. Expected: the first returns only titles; the second returns a
title and the nested author name — the response shape mirrors your query
exactly.
3. Prove over-/under-fetching is gone
Write one query that returns, for book(id: "1"): just the title. Then one that
returns title + author name. Note that you changed the response shape with zero
backend changes. Expected: two different response shapes from the same schema
and endpoint — the module-00 pain, solved.
4. Add a mutation
Add a Mutation with add_book(title, author_id) -> Book. Call it from
GraphiQL using variables, selecting id and title back. Expected: the book is
created and the mutation returns exactly the fields you selected; { books }
now includes it.
5. Trigger N+1 and see it
Make the author resolver log a line (print("author query", self.author_id))
every time it hits your data source. Seed 10 books across 2 authors. Run { books { title author { name } } }. Count the log lines. Expected: 10 author
lookups for 10 books (plus the 1 books query) — 11 operations, N+1 in the flesh,
even though there are only 2 distinct authors.
6. Fix it with a DataLoader
Add a DataLoader whose load_fn fetches all requested authors in one batched
call, wire it into the GraphQL context (context_getter), and have the author
resolver await loader.load(self.author_id). Re-run the query and re-count.
Expected: exactly 1 batched author load regardless of book count — 2
operations total. The load_fn returns one author per key, in key order.
7. Per-request loader scoping
Confirm your loader is created fresh in get_context per request (not a module
global). Then reason about what would break if it were a global: run a query,
mutate an author's name, run again. Expected (with correct scoping): the second
query reflects the change; you can articulate that a global loader's cache would
serve stale data across requests/users.
8. Read/write discipline check
Deliberately add a field to Query that creates a book (a write behind a read).
Explain in a comment why this is a design bug even though it "works," then move
it to Mutation. Expected: you can state that Query must be side-effect-free
and that GraphQL's one-POST model makes the Query/Mutation split your only
read/write signal.
9. Diagnose and fix
This DataLoader was added to fix N+1 but makes the results wrong and still leaks across requests. Find every bug.
# module-level, created once at import time
author_loader = DataLoader(load_fn=load_authors) # (A)
async def load_authors(keys: list[str]) -> list[Author]:
rows = await db.fetch_authors_by_ids(keys) # returns only FOUND authors
return [Author(id=r.id, name=r.name) for r in rows] # (B)
async def get_context():
return {} # (C) loader not in context
Solution
- (B) wrong length / wrong order. A DataLoader's
load_fnmust return exactly one item per input key, in the same order (usingNonefor misses). Returning "only the rows the DB found" means the output list is shorter and misaligned — resolver for keyk1may receive keyk3's author, or an index error. Build a{id: author}map and return[by_id.get(k) for k in keys]. - (A)/(C) module-global loader, not per request. A loader created once at
import time shares its per-request cache across all requests and users
forever — so an author's name updated by one request is served stale to
everyone, and unrelated users' data can bleed together. Create a fresh
loader per request inside
get_contextand read it frominfo.context["author_loader"]in the resolver.
async def load_authors(keys: list[str]) -> list[Author]:
rows = await db.fetch_authors_by_ids(keys)
by_id = {r.id: Author(id=r.id, name=r.name) for r in rows}
return [by_id.get(k) for k in keys] # one per key, in order, None on miss
async def get_context():
return {"author_loader": DataLoader(load_fn=load_authors)} # fresh each request
Lesson: DataLoader's two invariants — one result per key in order and scoped to a single request — are exactly what make it correct; violating either turns an optimization into a correctness bug.
Independent challenge
No code given. In module 00 you diagnosed a team drowning in /screens/*
REST aggregation endpoints and prescribed GraphQL. Build the core of that fix:
model a small blog domain — User, Post, Comment (a Post has an author
User and a list of Comments; each Comment has an author User) — as a
Strawberry schema over an in-memory store. Expose a Query with
post(id)/posts, and a Mutation addComment(postId, authorId, body).
Then satisfy a "post detail screen" with one query returning the post, its
author's name, and each comment with its author's name — and make sure that
query triggers no N+1: the users referenced by the post and all its comments
must load in a single batched call. Verify by logging your data-source hits.
Hint
There are two edges that both point at User (a post's author, and each
comment's author), so both the Post.author resolver and the Comment.author
resolver should go through the same per-request user_loader — that shared
loader is what collapses "post author + all comment authors" into one batched
WHERE id IN (...). Put the loader in get_context (fresh per request, per this
module's scoping rule) and read it from the resolver's info.context. Keep
addComment on Mutation, and remember the load_fn must return one user per
key, in order.
Common mistakes & troubleshooting
- Ignoring N+1 until production. It passes tests with a handful of rows and collapses at scale. Assume any resolver that fetches a related object per parent is N+1 and needs a DataLoader.
- A DataLoader
load_fnthat doesn't return one-result-per-key-in-order. This misaligns results silently. Build a key→value map and index it by the input keys, usingNonefor misses. - A module-global (not per-request) DataLoader. Its cache leaks stale and
cross-user data. Create it fresh in
context_getterfor every request. - Writes behind
Queryfields.Querymust be side-effect-free; put every create/update/delete underMutation. GraphQL won't stop you — the discipline is yours. - Expecting HTTP caching to work. GraphQL is one
POSTto one URL, so URL/verb-based HTTP caching and CDNs mostly don't apply — a real cost versus REST you should weigh (and mitigate with persisted queries / response caching). - Over-nesting / unbounded queries. A client can request deeply nested, expensive graphs. Fundamentals here; module 04 covers depth/complexity limits and pagination as the real defense.
- Leaking internal fields into the schema. Only fields you declare on the
type are exposed; use
strawberry.Private[...]for internal-only data (like a foreign key) so it never appears in the schema — the GraphQL analog of*Outredaction from track 02. - Forgetting nullability is part of the contract.
StringvsString!is a real API decision; sloppy nullability makes clients defensive. Be deliberate.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- In GraphQL, who decides the response shape, and how is that different from REST?
- What are the three root types, and what does each govern?
- What is a resolver, and why does "resolvers run field by field, once per parent object reached" set up the N+1 problem?
- Walk through exactly how the query
{ books { author { name } } }produces 1 + N queries against 100 books. - How does a DataLoader eliminate N+1, and what are its two hard invariants?
- Why must a DataLoader be created fresh per request rather than once at import?
- Name one major operational thing you lose moving from REST to GraphQL, and why.
Answers
- The client decides — it sends a query naming exactly the fields and nested relations it wants, and the response mirrors that shape. In REST the server decides each endpoint's fixed representation, so clients over- or under-fetch.
Query(side-effect-free reads / entry points),Mutation(writes: create/ update/delete), andSubscription(server-pushed streams of updates — module 04). Since GraphQL is onePOST, the Query/Mutation split is the read/write contract.- A resolver is a function that produces the value for a single field. Because
the engine resolves the query tree field by field, a resolver for a related
field (e.g.
author) runs once for every parent object reached (once per book). Fetching the related object inside that resolver therefore fires one query per parent — the setup for N+1. - The
booksresolver runs 1 query returning 100 books. The client selectedauthoron each, so theauthorresolver fires 100 times, each doing its own author lookup → 1 + 100 = 101 queries, many redundant since authors repeat. - It batches all the keys requested during a tick into one query (
WHERE id IN (...)) and caches per key within the request, so the 100-book query does 2 queries instead of 101. Invariants: theload_fnmust return one result per input key in the same order (None for misses), and the loader must be scoped to a single request. - Its per-request cache is meant to dedupe within one request only. A global loader would keep that cache across all requests and users, serving stale data after a mutation and bleeding one user's cached data into another's request.
- HTTP caching / CDN cacheability (and simple
curl-style debugging). Everything is aPOSTto one URL, so URL+verb-based caching no longer applies; you must add response caching / persisted queries to recover any of it. (Also acceptable: you take on the N+1 failure mode.)
Cumulative review
Closed-book. Don't reopen modules 00–03 while attempting these — the point is to find out what actually stuck across "when to leave REST," gRPC, and GraphQL.
- A new requirement lands: an internal
fraud-servicewill be called synchronously bypayments-service~8,000×/sec, and a customer-facing web + mobile app needs a "my account" screen aggregating profile, recent transactions, and saved cards. Assign a paradigm to each edge and justify each against the module-00 decision framework. - Explain why renumbering a Protobuf field (module 01) and changing a resolver
to a write-behind-
Query(module 03) are both silent contract bugs — the thing that makes each dangerous is the same category of problem. Name it. - Give the gRPC status code and the reason for each: unknown order id; a non-positive quantity; the server is mid-restart. Then say which of the three is safe to retry and why (modules 01–02).
- Compare how REST, gRPC unary, and a GraphQL query each handle "get a book and its author's name." Which needs the most round trips in the naive case, which is the most typed on the wire, and which is most prone to N+1?
- You have a server-streaming gRPC
WatchStockand a GraphQL query over 500 products each selectingsupplier. Name the specific failure each is prone to (streaming vs N+1) and the specific defense for each (modules 02–03). - Interceptors (module 02) and DataLoaders (module 03) both "wrap" resolver/ handler execution but solve different problems. State what each is for, and why a DataLoader must be per-request while a logging interceptor can be a singleton.
- A team exposes a public partner API in gRPC and a GraphQL endpoint for third-party integrators, and puts their two internal services behind REST. Critique each of the three choices against module 00's guidance and propose the corrected assignment.
- Trace what happens end to end when a GraphQL client sends
{ books { title author { name } } }against a Strawberry server with a correctly scopedauthorDataLoader: which resolvers run how many times, how many data-source round trips occur, and why (modules 03).
Answers
payments → fraud: gRPC — internal, extreme throughput, low latency, typed stubs (framework Q1 internal + Q4 performance). The "my account" screen: a GraphQL BFF — own frontend(s), rich/variable relational data, collapses multi-source aggregation into one query and serves web + mobile with different shapes (Q1 own frontend + Q3 variable data shape). No public third party here, so no REST edge is forced.- Both are changes that keep the code compiling and running while silently
breaking the contract. Renumbering a Protobuf field changes the wire tag,
so peers misread data with no error; moving/allowing a write behind
Queryviolates the read-only contract clients rely on (caching, safety) with no enforcement. The category: an unenforced-contract change that fails silently rather than loudly. - Unknown order id →
NOT_FOUND(the resource doesn't exist). Non-positive quantity →INVALID_ARGUMENT(caller sent bad input). Server mid-restart →UNAVAILABLE(transient). OnlyUNAVAILABLEis safe to retry — it's transient and the same request may succeed shortly; the other two are permanent (the request itself is wrong / the thing isn't there), so retrying changes nothing. - Naive round trips: REST needs the most (book, then a second call for the
author, unless you built an
?include). gRPC unary is the most typed on the wire (binary Protobuf with a compiled schema; a wrong-typed field can't be sent). GraphQL does it in one round trip but is the most prone to N+1 (theauthorresolver fires per book unless batched). WatchStockrisks a leaked stream — the server keeps producing after the client disconnects; defense: checkcontext.is_active()and bound/exit the loop. The 500-product query risks N+1 — 500supplierlookups; defense: a per-request DataLoader batching all supplier ids into one query.- An interceptor handles cross-cutting concerns (auth, logging, metrics) around every RPC — it's stateless with respect to a given request's data, so one shared instance is fine. A DataLoader batches/caches data loads within one request; its cache is request-scoped by design, so a singleton would leak stale/cross-user data — it must be created per request.
- All three are backwards. Public partners should get REST (universal, cacheable, low integration cost), not gRPC (steep external toolchain) and not an open GraphQL endpoint to untrusted integrators (complexity/abuse surface). The two internal services should talk gRPC (typed, fast), not REST. Corrected: partner-facing → REST; internal service-to-service → gRPC; (and if a rich first-party frontend exists, a GraphQL BFF for it, not for arbitrary third parties).
booksresolver runs once (1 data-source call → 100 books). Theauthorfield resolver runs once per book (100 times) — but each call only.load(author_id)s into the DataLoader rather than hitting the source; the loader batches all requested author ids into one query and caches duplicates. Total data-source round trips: 2 (books + one batched authors), because the DataLoader collapses the 100 loads into a single keyed fetch.
Further reading & sources
- GraphQL — Learn (official docs) - the canonical introduction to the schema, type system, queries, and mutations this module builds on.
- Strawberry GraphQL — documentation - the reference for the type-hint-based schema, resolvers, and FastAPI integration used in the exercises.
- Strawberry — DataLoaders guide - shows the exact per-request DataLoader wiring that defends against N+1.
- graphql/dataloader (GitHub) - the original DataLoader project and README that defines the batch-and-cache pattern and its one-result-per-key invariant.
- Apollo — Understanding the N+1 problem - a clear writeup of why resolver-per-field execution causes N+1 and how batching fixes it.
- GraphQL — Best practices: caching - explains why URL-based HTTP caching stops working and what to do instead, the operational cost this module flags.
Free PDF notes
Download these notes as a printable PDF, with every diagram and interview questions: SD-C-002: GraphQL for System Design. Each section in one file: Phase 2, APIs (PDF).
Next
04-graphql-advanced-patterns — with the schema/resolver/N+1 fundamentals solid, next you'll handle what production GraphQL actually needs: subscriptions for live updates, cursor-based (Relay-style) pagination, authorization inside resolvers, and the concept of schema stitching and federation for composing schemas across teams.