Module 04: GraphQL Advanced Patterns
Why this matters
Module 03 got you a working, N+1-safe GraphQL API — enough to replace a pile of
REST aggregation endpoints for a single app. But four things stand between "works
in GraphiQL" and "runs as the front door for a real product," and each is a place
teams get burned. You need subscriptions when the client shouldn't have to
re-ask — live scores, a notification badge, a collaborative view. You need
pagination the moment any list can grow, because books: [Book!]! returning
everything is a latent outage. You need authorization inside resolvers,
because GraphQL's single endpoint and field-by-field execution mean auth can't
live at "the route" the way it did in REST — a query can reach any field in the
graph, so every sensitive field needs its own guard. And once more than one team
owns parts of the graph, you need a strategy for composing schemas —
stitching or federation — instead of one monolith everyone fights over.
These are the patterns that also make GraphQL viable specifically as a Backend-for-Frontend (the role module 06 formalizes and the capstone builds). A BFF fronting several services has to page through large result sets, enforce who-can-see-what per field, push live updates, and potentially compose types owned by different backends. That's this entire module. Pagination and authz in particular are not optional niceties — shipping a GraphQL API without bounded lists and per-field authorization is how you get a data-exfiltration incident or a database melted by one unbounded query.
You'll keep building in Strawberry on FastAPI. Everything here layers onto the module-03 schema/resolver/DataLoader model — subscriptions are a new root type, connections are a new field shape, authz is logic inside resolvers, and federation is a way of splitting the schema you already know how to write.
Concepts
Subscriptions: server-pushed updates
A subscription is GraphQL's third root type (alongside Query and
Mutation) and its answer to "notify me when something changes." Instead of
returning a value once, a subscription resolver is an async generator that
yields values over time; the transport is typically a WebSocket, so updates
flow to the client as they happen — no polling.
import asyncio
import strawberry
@strawberry.type
class Subscription:
@strawberry.subscription
async def price_updates(self, symbol: str) -> AsyncGenerator[Price, None]:
async for tick in price_feed(symbol): # some async source
yield Price(symbol=symbol, value=tick)
schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)
The client "subscribes" and receives a stream:
subscription { priceUpdates(symbol: "ACME") { value } }
Two things to keep straight. First, this overlaps conceptually with gRPC server-streaming (module 02) and with WebSockets/SSE (track 06) — same "server pushes over a long-lived connection" idea, different ecosystem. GraphQL subscriptions are the right tool when your clients already speak GraphQL and want live updates in the same typed schema. Second, subscriptions inherit every long-lived-connection concern from track 06: connection lifecycle, cleanup on disconnect, and the fact that they don't scale across processes without a shared pub/sub backend (Redis) behind them. Don't reach for subscriptions when a simpler poll or a webhook would do — module 05 is the decision framework.
Pagination and the connection pattern
books: [Book!]! that returns the whole table is a bug waiting for the table to
grow. GraphQL's community-standard answer is cursor-based pagination, and its
formalization is the Relay Connection specification — a consistent shape for
paginated fields that most tooling understands:
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
}
type BookEdge {
node: Book! # the actual item
cursor: String! # opaque pointer to THIS item's position
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String # feed this back as `after` to get the next page
}
type Query {
books(first: Int!, after: String): BookConnection!
}
books(first: 2) books(first: 2, after: "c2")
┌───────────────────────┐ ┌───────────────────────┐
│ edges: │ │ edges: │
│ node Book A cursor c1 │ node Book C cursor c3
│ node Book B cursor c2 ──┐ │ node Book D cursor c4
│ pageInfo: │ │ │ pageInfo: │
│ hasNextPage: true │ └──►│ endCursor: "c4" ... │
│ endCursor: "c2" ─────┼───────► feed back as `after` │
└───────────────────────┘ └───────────────────────┘
cursor points AFTER an item -> inserts elsewhere don't shift the window
A client fetches books(first: 20), reads pageInfo.endCursor, then requests
books(first: 20, after: "<endCursor>") for the next page. This is the same
cursor vs. offset distinction from track 02's CRUD module: a cursor
points after a specific item (stable under inserts/deletes — no skipped or
duplicated rows when the list changes between pages), whereas offset/skip
shifts when rows are inserted. The connection pattern is just the standardized,
graph-friendly packaging of cursor pagination, with edges/node/cursor/
pageInfo as the agreed field names. Always paginate any list that can grow —
and always cap first (module-07/track-02 discipline: an unbounded page size
is a denial-of-service vector).
Authorization in resolvers
In REST you could put auth on the route (a router-level dependency, track 02).
GraphQL has one route, and a single query can traverse from a public field
down into a sensitive one, so authorization has to live at the field/resolver
level. The current user arrives via the GraphQL context (populated in
context_getter, exactly where the per-request DataLoader lives), and each
protected resolver checks it:
@strawberry.type
class User:
id: strawberry.ID
name: str # public
@strawberry.field
def email(self, info: strawberry.Info) -> str | None:
current = info.context["user"]
# only the user themselves (or an admin) can read the email field
if current and (current.is_admin or current.id == self.id):
return self._email
return None # or raise a GraphQLError for a hard denial
Key principles:
- Authorize per field, not per query. A query can reach any field in the
graph; a public
user(id)lookup must not expose that user'semailorphoneto everyone. Guard the sensitive fields. - Context carries identity, resolved once per request (from a JWT/session in
context_getter), just like the request-context dependency injection from track 02. - Decide deny semantics deliberately: return
nullfor "you may see the object but not this field," or raise aGraphQLErrorfor "you may not access this at all." Strawberry offers field permission extensions to make this declarative and reusable rather than hand-rolled in every resolver. - Watch introspection and error messages — don't leak existence or internal detail through them (the enumeration concern from track 02, and track 03's security-deep-dive spirit).
Query cost: depth and complexity limits
GraphQL's flexibility is also an attack surface: because the client composes the
query, a malicious or careless one can nest deeply (author { books { author { books { ... } } } }) or select enormous fan-outs, turning one request into a
crushing amount of work. Production GraphQL therefore adds query-cost
controls: a max depth limit, a complexity/cost analysis (assign each
field a cost, reject queries over a budget), and pagination caps so no single
field returns unbounded data. Strawberry ships extensions for query depth
limiting; Apollo-style complexity plugins exist too. This is the GraphQL analog
of rate limiting and payload caps from track 02's middleware module — the
threat is just shaped like an expensive query instead of a flood of requests.
Schema composition: stitching and federation
One schema owned by one team is simple. But at scale, users, orders, and
products are owned by different teams/services, and you don't want one giant
schema everyone edits (merge conflicts, coupling, a single deploy bottleneck).
Two approaches compose a unified graph from parts:
- Schema stitching — a gateway takes multiple independent schemas and merges them into one, wiring up links between types at the gateway. Older approach; the gateway holds the integration logic and can get brittle.
- Federation (Apollo Federation is the dominant standard) — each service
owns a subgraph and declares how its types connect to others using
directives (e.g. an
Ordersubgraph says itsOrder.userfield references aUserentity owned by the users subgraph, keyed byid). A gateway/router composes the subgraphs into one supergraph and resolves cross-subgraph references by calling the owning subgraph. Federation pushes ownership to the teams and is the modern default for multi-team GraphQL at scale.
The reference model: a federated User type can be extended by the orders
subgraph so an Order can expose order.user.name even though users live in a
different service — the router fetches the User fields from the users subgraph
behind the scenes. You don't need to implement federation to work here; you need
to recognize when it applies (multiple teams owning distinct parts of one
graph) versus when a single BFF schema (module 06) is simpler and sufficient.
Don't federate a graph one team owns — that's premature, per the module-00
discipline.
Command reference
| Item | Purpose | Example |
|---|---|---|
@strawberry.subscription |
Server-pushed stream (async generator) | async def price_updates(...) |
AsyncGenerator[T, None] |
Subscription return type | yield values over time |
Subscription root type |
Third root alongside Query/Mutation | Schema(..., subscription=...) |
Connection / edges/node/cursor |
Relay-style cursor pagination | BookConnection |
first / after args |
Page size + cursor position | books(first: 20, after: $c) |
pageInfo { hasNextPage endCursor } |
How to fetch the next page | in every connection |
info.context["user"] |
Current identity in a resolver | authz check |
strawberry.GraphQLError |
Hard authorization/error denial | raise GraphQLError("forbidden") |
| Field permission extension | Declarative per-field authz | strawberry.field(permission_classes=[...]) |
| Query depth / complexity extension | Reject expensive queries | schema extensions |
Federation directives (@key, @external) |
Declare cross-subgraph entities | @key(fields: "id") |
| Gateway / router | Compose subgraphs into a supergraph | Apollo Router |
Capping and paging a connection resolver (note the enforced first cap):
MAX_PAGE = 100
@strawberry.field
def books(self, first: int = 20, after: str | None = None) -> BookConnection:
first = min(first, MAX_PAGE) # cap page size (never trust the client)
rows = fetch_books_after(cursor=after, limit=first + 1) # fetch one extra to peek
has_next = len(rows) > first
rows = rows[:first]
edges = [BookEdge(node=b, cursor=encode_cursor(b.id)) for b in rows]
end = edges[-1].cursor if edges else None
return BookConnection(edges=edges,
page_info=PageInfo(has_next_page=has_next, end_cursor=end))
A declarative field-permission class (reusable authz, not hand-rolled per field):
from strawberry.permission import BasePermission
class IsSelfOrAdmin(BasePermission):
message = "Not authorized to read this field"
def has_permission(self, source, info, **kwargs) -> bool:
current = info.context["user"]
return bool(current and (current.is_admin or current.id == source.id))
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str = strawberry.field(permission_classes=[IsSelfOrAdmin])
Hands-on exercises
Continue the graphql-books/blog project from module 03 (Strawberry + FastAPI).
Keep your per-request DataLoader wiring — it's assumed here.
1. Add a subscription
Add a Subscription with count_up() -> AsyncGenerator[int, None] that yields
1..10, one per second. Wire subscription= into the schema. Subscribe from
GraphiQL. Expected: values arrive live, one per second, over a WebSocket — not
all at once.
2. A domain subscription
Replace the counter with comment_added(post_id) that yields each new Comment
as it's created (drive it from your addComment mutation via an in-process
async queue/broker). Expected: a subscriber to a post sees new comments appear
in real time as another client mutates.
3. Convert a list field to a connection
Turn books (or posts) into a Relay-style BookConnection with
edges/node/cursor and pageInfo. Support first and after. Expected:
books(first: 2) returns 2 edges plus pageInfo.endCursor; feeding that cursor
as after returns the next 2.
4. Cap the page size
Enforce first = min(first, MAX_PAGE) in the connection resolver. Request
books(first: 100000). Expected: you get at most MAX_PAGE items, not the whole
table — an unbounded page size is a DoS vector, exactly like the uncapped
limit from track 02's CRUD module.
5. Prove cursor stability
Fetch page 1 (first: 2), then insert a new book that sorts before the current
window, then fetch page 2 with the saved after cursor. Expected: no row is
skipped or duplicated across the page boundary — the cursor points after a
specific item, so an insert elsewhere doesn't shift your window (contrast with
offset pagination).
6. Per-field authorization
Add a private email to User, exposed only to the user themselves or an
admin. Populate context["user"] in context_getter (fake a user id via a
header for now). Query user(id) { name email } as the same user, a different
user, and an admin. Expected: name always returns; email returns the value
only for self/admin and null (or an error) otherwise.
7. Make authz declarative
Refactor exercise 6's inline check into a reusable IsSelfOrAdmin
BasePermission and attach it via permission_classes. Expected: identical
behavior, but the rule is defined once and reused — and adding it to another
sensitive field (phone) is one line.
8. Add a depth limit
Install/enable a query depth-limiting extension and set a low max depth. Send a
deeply nested query (post { author { posts { author { posts { ... } } } } })
that exceeds it. Expected: the query is rejected before execution with a
depth-limit error — the query-cost defense in action.
9. Diagnose and fix
This "my orders" GraphQL API leaks data and can be trivially DoS'd. Find every problem.
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str # (A) always returned
def _orders(self): return db.orders_for(self.id)
@strawberry.type
class Query:
@strawberry.field
def users(self) -> list[User]: # (B) every user, unbounded
return db.all_users()
@strawberry.field
def user(self, id: strawberry.ID) -> User: # (C) no authz at all
return db.get_user(id)
Solution
- (A)
emailis a plain field, always returned. Anyone who can resolve aUser— including via the publicuser(id)lookup — reads everyone's email. Sensitive fields need per-field authorization: gateemailwith a permission class (IsSelfOrAdmin) that readsinfo.context["user"], returningnull/error otherwise. GraphQL has one endpoint, so auth lives on the field, not the route. - (B)
usersreturns every user with no pagination or cap. Unbounded list = DoS + mass data exposure. Convert to a capped connection (first/after,first = min(first, MAX_PAGE)), and it should almost certainly require admin authorization to list users at all. - (C)
user(id)has no authorization and returns the full object. Even if fields are individually gated, decide whether arbitrary user lookup should be allowed; at minimum the sensitive fields must be guarded, and object-level existence shouldn't leak (enumeration). - Missing query-cost controls (implicit). With relationships in the graph,
add a depth/complexity limit so a nested
users { orders { ... } }can't be weaponized.
class IsSelfOrAdmin(BasePermission):
message = "forbidden"
def has_permission(self, source, info, **kwargs):
u = info.context["user"]
return bool(u and (u.is_admin or u.id == source.id))
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str = strawberry.field(permission_classes=[IsSelfOrAdmin])
@strawberry.type
class Query:
@strawberry.field(permission_classes=[IsAdmin])
def users(self, first: int = 20, after: str | None = None) -> UserConnection:
first = min(first, MAX_PAGE)
...
Lesson: on a single-endpoint, client-composed API, authorization is per field and unbounded lists are a vulnerability — both must be designed in, not bolted on.
Independent challenge
No code given. Take the blog GraphQL BFF you started in module 03 and make it
production-shaped. Add: (1) cursor-based posts and per-post comments
connections, both with capped page sizes; (2) a commentAdded(postId)
subscription driven by your addComment mutation; (3) per-field
authorization so a Post's author.email and a private draft body are
visible only to the author or an admin, using a reusable permission class; and
(4) a query depth limit. Then write a short note on whether this blog graph
should be federated or stay a single schema, justified against the module-00
"don't add complexity before the pain" rule.
Hint
Reuse the per-request context you already built for DataLoaders (module 03) —
it's the same place the current user and your loaders live, so authz checks and
batched loads share one context object. For the federation note, the honest
answer is almost certainly stay single-schema: one team owns the whole blog
domain, so there are no distinct subgraph owners to decouple — federation would
add a router, subgraph directives, and composition overhead to solve a
multi-team problem you don't have (the exact premature-abstraction trap from
module 00). Reach for federation only when users/posts/billing are owned by
different teams that need to deploy independently.
Common mistakes & troubleshooting
- Unbounded list fields.
things: [Thing!]!returning everything is a latent outage and data-exposure risk. Paginate anything that can grow, and cap the page size server-side. - Route-level auth thinking. GraphQL has one endpoint; you can't guard "the route." Authorize per field/resolver using the current user from context.
- Leaking sensitive fields by default. A field is exposed to anyone who can
resolve its parent type. Gate
email/phone/draft/internal fields explicitly; prefer a reusable permission class over copy-pasted checks. - No query-cost controls. A client-composed query can nest deeply or fan out hugely. Add depth and complexity limits — the GraphQL analog of rate limiting.
- Offset pagination in a connection. Defeats the point; use real cursors so pages are stable under concurrent inserts/deletes.
- Subscriptions that don't scale or clean up. They're long-lived connections: they leak without disconnect cleanup and don't fan out across processes without a shared pub/sub backend (track 06). Don't use them where a poll/webhook is simpler (module 05).
- Federating prematurely. Federation solves multi-team schema ownership. One team, one schema → a single BFF is simpler; adding a router/subgraphs first is premature complexity (module 00).
- Leaking existence via errors/introspection. Don't let error messages or schema introspection confirm records or expose internals (enumeration/OWASP concerns).
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.
- What is a subscription, how is its resolver written in Python, and which two other technologies in this curriculum does it overlap with conceptually?
- Describe the Relay connection shape (
edges/node/cursor/pageInfo) and how a client uses it to fetch the next page. - Why does GraphQL authorization have to live at the field/resolver level rather than at the route, and where does the current user come from?
- Give the two sensible "deny" semantics for a protected field and when you'd use each.
- What query-cost controls does production GraphQL need, and which track-02 defense are they analogous to?
- Contrast schema stitching and federation, and state the signal that tells you you actually need either (vs a single BFF schema).
- Why must you cap
firston a connection, and which earlier module's principle is that?
Answers
- A subscription is GraphQL's third root type for server-pushed updates over a
long-lived connection (usually a WebSocket); the resolver is an async
generator that
yields values over time. It overlaps conceptually with gRPC server-streaming (module 02) and WebSockets/SSE (track 06) — same push-over-persistent-connection idea, different ecosystem. - A connection returns
edges(each an object with anode— the item — and acursor— an opaque pointer to that item's position) pluspageInfo(hasNextPage,endCursor). The client requestsfirst: N, readspageInfo.endCursor, then requestsfirst: N, after: <endCursor>to get the next page. - GraphQL exposes a single endpoint, and one query can traverse from a
public field into a sensitive one, so you can't guard "the route" — each
sensitive field needs its own check. The current user comes from the
per-request context (
info.context["user"]), resolved once from a JWT/ session incontext_getter. - Return
nullwhen the user may see the object but not that particular field (partial visibility); raise aGraphQLErrorwhen the user may not access the thing at all (hard denial). Choose based on whether partial results make sense. - A max depth limit, query complexity/cost analysis (budget per query), and pagination caps. They're the analog of rate limiting and payload caps from track 02's middleware/CRUD modules — same goal (bound the work one request can cause), threat shaped as an expensive query.
- Stitching merges independent schemas at a gateway that holds the linking logic (older, can be brittle). Federation has each team own a subgraph that declares how its types connect via directives, with a router composing them into a supergraph (modern default). You need either only when multiple teams own distinct parts of one graph and must deploy independently — one team/one schema should stay a single BFF.
- Because an uncapped page size lets a client request unbounded data — a denial-of-service and mass-exposure vector. That's the same "cap the page size / never trust client-supplied limits" principle from track 02's CRUD deep dive.
Further reading & sources
- GraphQL — Subscriptions (spec/learn) - background on GraphQL's server-push model and how subscriptions differ from queries.
- Strawberry — Subscriptions guide - the async-generator resolver pattern and WebSocket transport used in the exercises.
- Relay — GraphQL Cursor Connections specification - the authoritative
edges/node/cursor/pageInfoshape behind cursor-based pagination. - Strawberry — Permissions / field authorization - the
BasePermission/permission_classesmechanism for declarative per-field authz. - Apollo — Introduction to Apollo Federation - the modern standard for composing team-owned subgraphs into one supergraph.
- OWASP — GraphQL Cheat Sheet - practical guidance on query depth/complexity limits and avoiding data exposure through a GraphQL endpoint.
Next
05-graphql-schema-evolution-and-deprecation — you now know gRPC and GraphQL in depth. The next module is deliberately short: a decision framework for choosing between gRPC, GraphQL, REST, and the event/real-time options (webhooks, WebSockets, polling) for a given consumer — cross-referencing track 06 rather than re-teaching the real-time mechanics.