GraphQL — Schema, Resolvers, N+1, When to Pick It
Trace typed GraphQL execution, nullability and mutation ordering; Compare exact naive and batched lookup results with scoped authorisation; Bound queries and distinguish persisted documents from allowlists.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Trace typed GraphQL execution, nullability and mutation ordering
- Compare exact naive and batched lookup results with scoped authorisation
- Bound queries and distinguish persisted documents from allowlists
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 29 sections
Review boundary — 8 October 2026: GraphQL semantics use the October 2021 specification. The exact JavaScript program runs the installed GraphQL engine locally without HTTP. Strawberry DataLoader/depth APIs are checked against captured official documentation, but the Python service reference is unexecuted. Counters measure fixture lookups, not database latency or production performance.
Learning goal: Read a typed schema, trace resolver execution, reproduce N+1 and batching, enforce bounded query work, and choose GraphQL, REST or gRPC by their contracts.
Why this session exists
GraphQL lets clients select fields from a typed graph. That can reduce unwanted response data and combine related fetches, but it does not remove backend work, authorise every reachable object or optimise arbitrary database queries. A flexible public API needs limits and operational visibility just as a fixed-endpoint API does. Avoid both “REST but better” and “never use GraphQL publicly.”
You will be able to explain SDL/nullability/queries/mutations/resolvers; understand a Strawberry service backed by SQLite; compare naive and batched lookup counts; identify three suitable/unsuitable workload patterns; and distinguish persisted documents, allowlisting, depth and cost limits.
Prerequisites
Understand HTTP and resource-oriented APIs, SQL lookup/join costs and asynchronous functions. GraphQL's core specification is transport-independent; HTTP POST is common, and supported HTTP GET conventions can carry queries. Mutation operations must not be executed through GET under the GraphQL-over-HTTP draft contract.
Optional preparation resources
Choose a resource for the question you want to investigate; there is no required viewing list or fixed pre-read duration.
- GraphQL Learn: use queries, types and execution as preparation for the schema and resolver sections below.
- GraphQL: A Data Query Language: a historical engineering link, not evidence that mobile over-fetching is the whole motivation or that a particular performance gain applies here.
- Apollo's GraphQL/REST comparison: optional vendor perspective; compare its recommendations with your workload rather than treating it as an independent verdict.
- Optional video searches: Fireship + GraphQL, Lee Byron + GraphQL conference, and Strawberry + Python tutorial. These are search destinations, not verified selections or endorsements of a specific speaker, title, duration or walkthrough.
(a) Intuition · 5 min
A fixed menu versus a build-your-own plate illustrates server-shaped versus client-selected data. REST can also offer field selection or a screen-specific endpoint, and GraphQL only selects from fields the schema exposes. Selecting three fields reduces returned representation fields; it does not prove the resolver fetched only three database columns or that bandwidth fell by a fixed factor.
What GraphQL actually gives you (and doesn't)
A schema defines types and legal selections. Validation rejects structurally invalid documents before execution. One operation can traverse related data, while resolvers still perform the underlying work. Introspection supports tooling where enabled; client generation and build-time checks require configured tools. Errors/nullability/directives mean a result is not always a simple full mirror of requested fields. GraphQL includes subscriptions as well as queries/mutations; transport and event delivery are additional contracts.
The (short) history
The useful design history is the move from fixed mobile response shapes to a typed selection/execution language and an ecosystem of clients, gateways and schema tooling. The October 2021 specification is the stable semantic reference here; federation is an ecosystem composition pattern, not a requirement of that specification. The lesson does not infer universal company adoption or a “20/50 services” crossover from unverified timelines.
Request lifecycle
Figure text equivalent — labels and relationships
- Participant “Client”.
- Participant “GraphQL execution”.
- Participant “Posts source”.
- Participant “Users source”.
- 1. solid: “Client” to “GraphQL execution” — Document, operation name and variables.
- 2. solid: “GraphQL execution” to “GraphQL execution” — Parse, validate, authorise and apply limits.
- 3. solid: “GraphQL execution” to “Posts source” — Bounded posts lookup.
- 4. dotted: “Posts source” to “GraphQL execution” — Parent rows.
- 5. solid: “GraphQL execution” to “Users source” — Per-author lookups or request-scoped batch.
- 6. dotted: “Users source” to “GraphQL execution” — Author rows in requested key order.
- 7. dotted: “GraphQL execution” to “Client” — data and possibly errors.
Execution resolves selected fields for parent objects and completes values according to their types. Sibling query fields may execute concurrently; the specification does not mandate a universal breadth-first traversal. Top-level mutation fields within one operation execute serially. That ordering neither rolls back an earlier successful mutation when a later one fails nor serialises all concurrent requests.
The schema is the contract
This is a coherent illustrative SDL including all original User/Post/Comment relationships and creation fields:
schema { query: Query mutation: Mutation }
type User { id: ID!, name: String!, email: String, posts(limit: Int = 10): [Post!]! }
type Post { id: ID!, title: String!, body: String!, author: User!, comments(limit: Int = 10): [Comment!]! }
type Comment { id: ID!, body: String!, author: User! }
type Query { user(id: ID!): User, posts(limit: Int = 10): [Post!]! }
type Mutation { createPost(authorId: ID!, title: String!, body: String!): Post! }! means non-null; [Post!]! is a non-null list whose elements must be non-null. An empty list remains valid. A nullable user can be absent. A forbidden nullable email can resolve null with a documented policy; exposing it as non-null means an error can propagate to a nullable ancestor. Input IDs are not authorisation: createPost(authorId:...) must verify the caller may publish as that author. Integer defaults do not bound client-supplied arguments; validate list limits in resolvers/policy.
N+1: the mistake everyone makes once
One posts query yields N parents; a naive author resolver queries once per parent, for N+1 source calls. A batch loader collects requested keys during a scheduling window, fetches them together and returns a result for each input key in the same order, including missing/error positions. Database IN results do not guarantee this order. Request-local memoisation can deduplicate repeated keys; chunking, distinct permission scopes or scheduling boundaries can produce more than one batch. “Exactly two queries for every operation” is not guaranteed.
Repeated authors in five posts mean five naive user lookups versus one batch for three distinct IDs in the fixture. The root lookup must be counted too: six versus two total fixture source calls. The original program did not count a real database root query and therefore could not claim an actual measured N+1 database total.
GraphQL vs REST: when each wins
GraphQL can suit independently evolving web/mobile clients, nested UI graphs and a deliberate aggregation facade with query controls. REST often suits simple CRUD, familiar third-party scripts and independently cacheable resources. gRPC often suits generated service contracts, polyglot internal RPC and streaming. These are workload tendencies, not restrictions: public GraphQL APIs exist, REST can have schemas/expansions, and gRPC is not automatically the lowest-latency option.
The production controls you must add
Parse/validate documents and variables; enforce object/field authorisation in the shared data-access layer; bound page sizes, aliases, fragments, request bytes, response bytes and resolver resources; apply depth/complexity/time limits; isolate loaders/caches by request and identity; observe per-operation/field work and errors. A directive named @auth has no built-in magic: implement and test enforcement on every route to sensitive data.
A persisted document stores query text behind an ID/hash. Automatic persisted-query negotiation can accept a new arbitrary document after a cache miss; that is not an allowlist. A deployment that accepts only approved registered operations reduces the query surface, but variables/page sizes/aliases in approved documents still require bounds. GET caching needs a complete key (document, variables, operation, relevant request fields) and safe personalised cache policy. Persisted queries are not the only possible GET cache path and do not automatically make a response public/cacheable.
Deriving a query-cost budget
A recursive schema permits arbitrarily deep finite documents unless limited, and nested lists can multiply work. Even an acyclic shallow query can be huge through page sizes or aliases. Fixed REST endpoints can also accept unbounded filters, bodies or expensive expansions; they do not supply safety for free.
For a teaching model, charge root posts 1 + p × (1 + c × 1) where p is bounded posts and c bounded comments per post. At p=10,c=20, cost is 211; at p=100,c=100, 10,101. These are conservative example points, not measured SQL or Shopify's exact formula. Validate variables before costing; expand fragments, account for aliases and repeated selections, and cap actual execution/resources too. A static estimate can be wrong about cardinality or expensive scalar resolvers.
Pagination is a consistency and authorization contract
A limit bounds one response; it does not provide a continuation position. For a feed sorted by (created_at, id), a forward cursor should represent that entire unique ordering tuple and its filter/tenant context. The next query applies the same authorization and filters, selects tuples strictly after the cursor, orders identically, and fetches at most first + 1 rows to determine whether another page exists. Offset pagination can skip or duplicate rows when inserts/deletes shift positions. Keyset pagination avoids that particular shift but does not promise a frozen snapshot when ordering values or permissions change. Specify whether this is a live feed or a snapshot-bound traversal.
A Relay-style connection convention returns edges { node cursor } and pageInfo { endCursor hasNextPage }; it is not required by the core GraphQL specification. Cursors are opaque to clients by contract, not automatically secret or tamper-proof because they use base64. Validate their shape and bind them to the relevant scope; never trust a cursor as proof of permission. Reject invalid/expired or wrong-scope cursors under a documented policy. Count authorized results, not a global page subsequently filtered in memory, if you promise a full page or accurate next-page flag.
Here is a bounded integer-ID keyset model, deliberately not a cursor codec, SQL query or GraphQL connection implementation. IDs are unique immutable increasing keys; the fixture is a fixed snapshot. The membership predicate stands in for trusted data-access policy, applied before slicing. Production should push that predicate, keyset comparison, ordering and limit into an indexed data source rather than scan the whole collection.
# exact-program: authorized_page
def page(rows, viewer, first, after=None):
if type(first) is not int or not 1 <= first <= 100:
raise ValueError('first 1..100')
if after is not None and type(after) is not int:
raise ValueError('integer cursor in this model')
eligible = sorted((r for r in rows if viewer in r['readers']
and (after is None or r['id'] > after)),
key=lambda r: r['id'])
window = eligible[:first + 1]
nodes = window[:first]
return ([r['id'] for r in nodes],
nodes[-1]['id'] if nodes else None, len(window) > first)
if __name__ == '__main__':
rows = [{'id': i, 'readers': {'alice'} if i % 2 else {'bob'}}
for i in range(1, 7)]
assert page(rows, 'alice', 2) == ([1, 3], 3, True)
assert page(rows, 'alice', 2, 3) == ([5], 5, False)
assert page(rows, 'nobody', 2) == ([], None, False)
print('authorized keyset page: PASS')Failure exercise: fetching global IDs [1,2] and then removing Bob's row gives Alice a one-item page even though IDs 3 and 5 remain. The model yields [1,3] because authorization is inside selection. A cursor from Bob does not grant access to Bob's rows. In a real API a signed cursor can preserve its scope/filters, but authorization must still run against current policy on every page and every alternate resolver path.
Cost exercise: two aliases selecting the same list with different arguments can double the declared list work while depth stays constant. Identical fields with the same response key may be merged by GraphQL execution; do not mechanically equate every syntactic repetition with another resolver call. Admission estimation must follow selected operations, argument values, field collection and fragment semantics, or deliberately overestimate with a documented conservative rule. The small cost() fixture below models one declared shape only. Runtime row/byte/time caps remain necessary even when that estimate passes.
A typed graph the client walks
The graph defines possibilities, not permissions or physical plans. DataLoader is one batching solution; joins, prefetching and query planners can also avoid N+1. Request-scoped caches must include the effective data-access scope, and mutation code may need to clear/prime cached values. Deprecating fields with @deprecated helps evolution but removal still needs client usage evidence and a policy. Additive enum/nullability/input changes can affect clients; generated types help only when consumers regenerate/check them.
GraphQL, expansions, or a backend-for-frontend
GraphQL shifts some screen-shape choices to clients but requires schema/resolver operations. REST expansions preserve a familiar route surface while adding bounded query combinations. BFFs tailor APIs per client but can duplicate business policy. Choose from coordination costs and measured screen/backend work, not a numeric service threshold. Federation adds subgraph ownership and gateway query planning, with new identity propagation, failure and compatibility boundaries; a single company-wide graph is not mandatory.
(c) Hands-on · 25 min
Exact local GraphQL execution and batching
This executable uses the installed graphql package and a small educational micro-batcher, not the production DataLoader package. Its user source is an in-memory fixture. It implements a smaller runnable schema than the full SDL above and explicitly covers query, mutation, email policy and null propagation. It performs no socket or real DB query.
// exact-program: graphql_execution
const {buildSchema, graphql, parse, validate, execute: executeDocument} = require('graphql');
const assert = require('node:assert/strict');
const users = new Map([
['1', {id:'1', name:'alice', email:'a@example.test'}],
['2', {id:'2', name:'bob', email:'b@example.test'}],
['3', {id:'3', name:'carol', email:'c@example.test'}],
]);
const posts = [1,2,1,3,2].map((id,i)=>({id:String(i+10),title:'post '+i,authorId:String(id)}));
const schema = buildSchema(`
type User { id: ID!, name: String!, email: String }
type Post { id: ID!, title: String!, author: User! }
type Query { posts(limit: Int = 10): [Post!]!, user(id: ID!): User }
type Mutation { step(value: Int!): Int }
`);
function loader(batch) {
const cache = new Map(); let pending = []; let scheduled = false;
return key => {
if (cache.has(key)) return cache.get(key);
const promise = new Promise((resolve,reject)=>pending.push({key,resolve,reject}));
cache.set(key,promise);
if (!scheduled) {
scheduled=true;
queueMicrotask(async()=>{
const work=pending; pending=[]; scheduled=false;
try {
const values=await batch(work.map(x=>x.key));
if(values.length!==work.length) throw Error('batch length mismatch');
work.forEach((x,i)=>x.resolve(values[i]));
} catch(error) { work.forEach(x=>x.reject(error)); }
});
}
return promise;
};
}
function context(batched, viewer='1') {
const ctx={counts:{posts:0,users:0},viewer,effects:[],batched};
ctx.load=loader(async keys=>{
ctx.counts.users++;
// The result order comes from keys, never unspecified source iteration order.
return keys.map(key=>users.get(key) ?? null);
});
return ctx;
}
function userValue(row,ctx) {
if(!row) return null;
return {...row,email:()=>row.id===ctx.viewer ? row.email : null};
}
const root = {
posts: ({limit},ctx)=>{
if(!Number.isInteger(limit)||limit<1||limit>100) throw Error('limit 1..100');
ctx.counts.posts++;
return posts.slice(0,limit).map(p=>({...p,author:async()=>{
let row;
if(ctx.batched) row=await ctx.load(p.authorId);
else {ctx.counts.users++; row=users.get(p.authorId);}
return userValue(row,ctx);
}}));
},
user: ({id},ctx)=>{ctx.counts.users++; return userValue(users.get(id),ctx);},
step: ({value},ctx)=>{ if(value<0) throw Error('deliberate second-field failure');
ctx.effects.push(value); return value; },
};
async function execute(source,ctx,variableValues) {
return graphql({schema,source,rootValue:root,contextValue:ctx,variableValues});
}
async function executeWithDepth(source, ctx, maxDepth) {
const document=parse(source); const errors=validate(schema,document);
if(errors.length) return {errors};
const fragments=new Map(document.definitions.filter(x=>x.kind==='FragmentDefinition').map(x=>[x.name.value,x]));
function depth(selectionSet, current) {
if(!selectionSet) return current;
return Math.max(current,...selectionSet.selections.map(node=>
node.kind==='Field' ? depth(node.selectionSet,current+1) :
node.kind==='InlineFragment' ? depth(node.selectionSet,current) :
depth(fragments.get(node.name.value).selectionSet,current)));
}
// validate() has already rejected unknown/cyclic fragments.
const operations=document.definitions.filter(x=>x.kind==='OperationDefinition');
if(operations.length!==1) return {errors:[new Error('one operation required in this fixture')]};
if(depth(operations[0].selectionSet,0)>maxDepth) return {errors:[new Error('depth exceeded')]};
return executeDocument({schema,document,rootValue:root,contextValue:ctx});
}
async function main() {
const query='{ posts { title author { id name email } } }';
const a=context(false), b=context(true);
const naive=await execute(query,a), batched=await execute(query,b);
assert.equal(naive.errors,undefined); assert.equal(batched.errors,undefined);
assert.deepEqual(JSON.parse(JSON.stringify(naive.data)),JSON.parse(JSON.stringify(batched.data)));
assert.deepEqual(a.counts,{posts:1,users:5}); assert.deepEqual(b.counts,{posts:1,users:1});
assert.equal(batched.data.posts[1].author.email,null);
const invalid=context(true);
assert.ok((await execute('{ posts { absentField } }',invalid)).errors);
assert.deepEqual(invalid.counts,{posts:0,users:0});
const mutation=context(true);
const result=await execute('mutation { a:step(value:1) b:step(value:-1) c:step(value:2) }',mutation);
assert.deepEqual(mutation.effects,[1,2]); assert.equal(result.data.b,null);
assert.equal(result.errors.length,1); // serial execution is not rollback
console.log(JSON.stringify({naive:a.counts,batched:b.counts,mutation:result.data,effects:mutation.effects}));
}
if(require.main===module) main().catch(e=>{console.error(e);process.exitCode=1;});
module.exports={loader,context,execute,executeWithDepth,users,posts,schema};The independent tests compare exact result objects and backend counters, request-local privacy, duplicate/missing key positions, invalid selection before resolver work, argument bounds, nullable field errors and non-null propagation. One batch here follows this engine's scheduling and these resolvers; it is not a universal GraphQL scheduling promise.
Strawberry/Python reference with an actual SQLite source
This unexecuted service-reference program uses the documented async DataLoader API and a fresh in-memory SQLite fixture. It intentionally exposes a subset of the full SDL; add Comment/User.posts/createPost only with bounded queries and authorisation. It can execute through schema.execute without an HTTP server in an authorised compatible Strawberry environment. No dependencies were installed here.
# service-reference: Strawberry DataLoader API, captured 2026-10-08; not run
import asyncio
import sqlite3
import strawberry
from strawberry.dataloader import DataLoader
from strawberry.types import Info
from strawberry.extensions import QueryDepthLimiter
@strawberry.type
class User:
id: strawberry.ID
name: str
@strawberry.type
class Post:
title: str
author_id: strawberry.Private[int]
@strawberry.field
async def author(self, info: Info) -> User | None:
if info.context['batched']:
return await info.context['loader'].load(self.author_id)
info.context['counts']['users'] += 1
row = info.context['db'].execute('select id,name from users where id=?',
(self.author_id,)).fetchone()
return User(id=strawberry.ID(str(row[0])), name=row[1]) if row else None
@strawberry.type
class Query:
@strawberry.field
def posts(self, info: Info, limit: int = 10) -> list[Post]:
if not 1 <= limit <= 100:
raise ValueError('limit 1..100')
info.context['counts']['posts'] += 1
return [Post(title=title, author_id=uid) for title, uid in
info.context['db'].execute('select title,author_id from posts order by id limit ?', (limit,))]
schema = strawberry.Schema(query=Query, extensions=[QueryDepthLimiter(max_depth=3)])
async def run(batched):
with sqlite3.connect(':memory:') as db:
db.executescript("""
create table users(id integer primary key,name text);
create table posts(id integer primary key,title text,author_id integer);
insert into users values(1,'alice'),(2,'bob'),(3,'carol');
insert into posts values(10,'hello',1),(11,'world',2),(12,'graphql',1),
(13,'resolvers',3),(14,'batching',2);
""")
counts = {'posts': 0, 'users': 0}
async def load_users(keys):
counts['users'] += 1
marks = ','.join('?' for _ in keys)
found = dict(db.execute(f'select id,name from users where id in ({marks})', keys))
return [User(id=strawberry.ID(str(k)), name=found[k]) if k in found else None for k in keys]
context = {'db': db, 'counts': counts, 'batched': batched,
'loader': DataLoader(load_fn=load_users)}
result = await schema.execute('{ posts { title author { name } } }', context_value=context)
assert not result.errors
print(result.data, counts)
db.close()
if __name__ == '__main__':
asyncio.run(run(False)); asyncio.run(run(True))The SQL batch uses bound placeholders for IDs and reconstructs key order. A real asynchronous server should avoid blocking its event loop with unbounded synchronous DB operations and use a request-safe connection/pool and trusted identity scope. Expected counts on this five-post fixture are posts=1/users=5 versus posts=1/users=1; those are reference expectations, not claimed Python runtime observations.
To expose a compatible Strawberry schema through FastAPI, the documented pattern is GraphQLRouter(schema, context_getter=...) attached at /graphql. The context factory must create request-scoped loaders and manage DB cleanup. GraphiQL is a development interface, not a production security boundary. No uvicorn server or IDE was launched.
Anatomy of the script
Types define exposed fields; Private stores internal author identity without exposing it as a schema field. Info.context supplies data access, identity and loaders. Resolver invocations are not synonymous with database calls: scalar properties may need no query. Counters belong to each request, not an accumulated global dictionary shared between users. DataLoader's per-key cache and batching are distinct; clearing stale values after mutations and returning missing/error positions are correctness concerns.
Depth-limit exercise that actually rejects a valid query
The original “imagine posts.author.posts...” example was invalid against its own runnable schema, so rejection would not prove a depth limiter. With the shown valid selection { posts { author { name } } }, lower the Strawberry max_depth below the depth accepted by its documented counting convention and assert validation errors and zero source counters; keep the allowed case at max_depth=3. The captured official extension examples include a genuinely recursive Book/User schema and a valid deep selection rejected by the limiter. This extension check is source-reviewed, not executed here.
The exact JavaScript program also includes executeWithDepth: it validates the document first, expands valid fragments/inline fragments when counting field depth, and rejects over-budget requests before executing any resolver. The local tests run the valid posts→author→name selection at depth 3 and reject it at depth 2, including an alias/fragment form, with zero source counters on rejection. This is an executed teaching guard, not a claim to have run Strawberry's extension or a general production demand-control package.
Depth is only one guard. A shallow posts(limit:1000000) is rejected by the explicit list bound, and many aliases/fragments can still require a separate cost/size budget. The local GraphQL engine tests actual argument validation and schema validation; they do not masquerade as a run of Strawberry's extension.
A bounded cost calculator
# exact-program: query_cost
def cost(posts, comments):
if type(posts) is not int or type(comments) is not int:
raise ValueError('integer limits required')
if not 1 <= posts <= 100 or not 0 <= comments <= 100:
raise ValueError('limits outside teaching contract')
return 1 + posts * (1 + comments)
if __name__ == '__main__':
assert cost(10, 20) == 211
assert cost(100, 100) == 10101
print(cost(10, 20), cost(100, 100))This is an arithmetic fixture for a declared shape, not a general GraphQL AST complexity validator. A real implementation must cover fragments, aliases, variables, directives, bounded cardinalities and resolver weights plus execution deadlines. Publish the policy to integrators and return safe operation-cost/budget feedback; do not claim it reproduces a vendor's storefront/admin rate-limit formula.
(d) Production reality · 15 min
Nested authorisation, query cost and dual APIs
An endpoint-level user(id) check does not protect Post.author.email if that path bypasses it. Enforce shared business/data-access policy on the sensitive field/object and test every reachable path with authorised and unauthorised identities. Hiding introspection may reduce discoverability but cannot replace authorisation. Request-scoped loaders prevent cross-request identity leakage only when their underlying fetches also enforce scope.
A valid expensive query can overload a backend; estimated cost, admission limits and runtime cancellation must work together. A gateway that accepts arbitrary automatic persisted queries is not an allowlist. A dual REST/GraphQL surface can be reasonable for scripts versus rich screens, but duplicated policies and schema evolution require ownership. These are concrete failure analyses, not invented GitHub bandwidth or Shopify incident claims.
Where this shows up in the rest of the plan
HTTP controls transport/cache policy, REST offers another interface shape, and a GraphQL gateway may call gRPC backends. Identity/authorisation must propagate through each hop. Frontend normalised caches need consistent entity IDs and invalidation; federation introduces additional query planning, not automatic service independence.
(e) Recall + stretch · 10 min
What problem motivates client-selected fields?
A screen may need only a subset of a fixed response plus related data. GraphQL can express that selection, but backend queries and actual byte/latency savings still require measurement.
What is a resolver?
A function/value-resolution mechanism for a selected field on a parent object. Query siblings can run concurrently; nested value completion follows types. There is no universal mandated breadth-first implementation.
N+1 and DataLoader?
One parent-list source call plus N child lookups versus batching compatible keys and request-local memoisation. Return values in key order with missing/error positions; batching windows, chunking and permissions affect counts.
Why is HTTP caching more involved?
Common POST bodies are not ordinary URL cache keys. Supported GET query conventions can be cached with document/variables/operation/Vary and safe identity policy. Persisted IDs help but are not the only cache path or automatic public-cache permission.
Three cases favouring REST?
Simple CRUD, independently cacheable resources, and familiar script/third-party integration. REST still needs bounds, authorisation and cost controls; public GraphQL can be valid too.
What does a persisted query guarantee?
It names a stored document. Only an explicit approved-only policy rejects arbitrary new operations; automatic registration does not. Variables, cardinalities and permissions remain bounded separately.
How do you protect User.email through Post.author?
Apply the same sensitive-field/data-access policy regardless of entry path, using trusted viewer context. A schema directive must have actual implementation; loaders cannot bypass object permission.
Name production controls.
Validation, bounded pagination/request/response size, depth/complexity admission, execution time/resource limits, cost-aware quotas and object/field authorisation. Introspection policy is additional, not a substitute.
What is federation and when is it justified?
Composition of separately owned subgraphs behind a gateway with coordinated schema/query planning. It can help organisational ownership but adds failure/auth/evolution complexity. No universal service-count threshold exists.
Stretch: design and communicate query cost.
Define weights and bounded list multipliers, resolve variables/fragments/aliases, publish the formula and limits, then enforce both pre-execution admission and actual runtime budgets. The example 211/10,101 points are not measured database work or a vendor formula.
Failure exercise and worked answer
Two top-level mutation fields run serially and the second fails. The first is not rolled back by GraphQL. The exact program retains effects [1,2] from successful first/third nullable fields around a failing second field. Use one explicitly transactional domain mutation when atomicity is required; null propagation can also prevent later work depending on schema/error shape.
Explain-out-loud test
Trace five posts to six naive versus two batched fixture source calls, identify the nested-email permission check, and contrast depth, cost and allowlisted operations.
What comes next
Protobuf/gRPC trades client-selected graph traversal for generated message and method contracts. Continue to S058 · gRPC & Protobuf. Hub: The 6-Month Learning Plan.
Suggested study route: intuition → request-lifecycle visual → local execution and service reference → production failure analysis → recall. Pause to explain the lookup counts and permission checks before moving on. The minute labels in the retained headings are planning cues, not measured completion times; the service reference is not a completed production deployment.
Sources and review boundary
GraphQL October 2021, GraphQL-over-HTTP draft, DataLoader v2.2.3, Strawberry loaders, depth limiter, GraphQL demand/security controls, OWASP authorisation. The HTTP draft is not the core specification or a final RFC. Runtime evidence covers the installed JavaScript engine and local models, not Strawberry HTTP, real database load or federation.
Pagination convention: Relay Cursor Connections specification, official mutable specification captured for this review. The new page model is CPU-only; cursor signing, live-database isolation and HTTP handling remain unexecuted.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.