Architectural Styles · Lesson 04
GraphQL: a query language for APIs
GraphQL hands the menu to the customer. Instead of the server deciding which fields arrive in every response, the client writes a query that lists exactly what it needs — and the server delivers precisely that, no more, no less.
By the end you'll be able to
- Explain the single-endpoint model, schema types, and the three GraphQL operation types.
- Write a basic query and describe how the response mirrors the query shape.
- Describe the N+1 problem and how DataLoader-style batching solves it.
The problem GraphQL was built to solve
Imagine a restaurant that only serves fixed plates — every meal has every ingredient the kitchen decided, whether you want them or not. REST APIs have the same problem: the server defines the response, so clients either get too many fields (over-fetching — bandwidth wasted) or too few (under-fetching — needs multiple trips).
Facebook built GraphQL in 2012 to solve exactly this for their mobile apps. A single News Feed screen needed data from a dozen different REST endpoints, and mobile networks were slow. They wanted one trip, tailored to each screen's exact needs. GraphQL was the result; it went open-source in 2015.
One endpoint, one schema
A GraphQL API exposes exactly one HTTP endpoint (usually /graphql). All operations — reads, writes, real-time subscriptions — go through it as POST requests with a JSON body containing the query.
The API's entire capability is described in a schema written in the GraphQL Schema Definition Language (SDL). The schema is the contract: it names every type, every field on each type, and every operation available. Clients discover capabilities by querying the schema itself (introspection), which powers tools like GraphiQL's autocomplete.
# Schema Definition Language — the contract
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int): [Post!]!
}
type Mutation {
createPost(title: String!, body: String!): Post!
}
The three operation types
- Query — reads data (safe, like GET in REST)
- Mutation — creates, updates, or deletes data (like POST/PUT/DELETE)
- Subscription — opens a live channel (server pushes data as events occur — like WebSockets)
Client-declared fields — the key innovation
The client writes a query that looks exactly like the JSON shape it wants. The server fills it in and returns precisely that structure. If the client doesn't ask for a field, it isn't sent.
posts field on User was never asked for, so it never travels over the wire.Resolvers — how fields get their values
Each field in the schema has a corresponding resolver function on the server. When a query arrives, GraphQL walks the tree of requested fields and calls each resolver to populate its value. A top-level user(id: "42") resolver might query the database; the nested posts resolver on User might fire another query. This composability is elegant — and is also the source of GraphQL's main performance trap.
Worked example: query and its response
# HTTP request
POST /graphql
Content-Type: application/json
{
"query": "query GetAuthor($id: ID!) { user(id: $id) { name posts { title } } }",
"variables": { "id": "42" }
}
# Server response — 200 OK always (errors live in the body)
{
"data": {
"user": {
"name": "Kwame Asante",
"posts": [
{ "title": "First steps with GraphQL" },
{ "title": "When to skip GraphQL" }
]
}
}
}
Notice: HTTP status is always 200. GraphQL errors (field not found, permission denied) are reported inside the "errors" key of the body, not via HTTP status codes. This means HTTP-level monitoring and caching tools need extra awareness.
The N+1 problem — GraphQL's main performance trap
Imagine fetching 20 posts, each with an author name. A naive resolver implementation hits the database once to get the 20 posts, then fires one individual query per post to load its author — that's 1 + 20 = 21 database queries to render one page. This is the N+1 problem.
The standard solution is batching via the DataLoader pattern (popularised by Facebook's DataLoader library). Instead of loading each author immediately, resolvers collect all the needed ids during the request, then issue a single SELECT … WHERE id IN (…) — 2 queries total instead of 21.
// Without DataLoader — N+1 queries
// For each of 20 posts → 1 DB query per author
posts.map(post => db.user.findById(post.authorId)) // 20 queries
// With DataLoader — batched to 1 query
posts.map(post => userLoader.load(post.authorId)) // batched
// DataLoader fires: SELECT * FROM users WHERE id IN (1,2,3,...)
Caching is harder than REST
REST GET requests are cacheable at the HTTP layer — CDNs and browsers store them keyed on the URL. GraphQL queries are POST requests; there is no URL per query. You have to implement caching yourself: persisted queries (give each query a hash, cache by hash), per-field cache hints in the schema, or client-side normalised caches (Apollo Client, Relay). These tools exist and work well, but they require explicit design work that REST gets for free.
"When would you choose GraphQL over REST?" A strong answer names two conditions: (1) multiple very different clients (mobile, web, partner) that need different subsets of the same data — REST would need many endpoints or cause over-fetching; (2) rapidly evolving front-end where new screens should not require back-end changes to add fields. Then mention the costs: harder caching, N+1 risk, complexity of schema governance at scale.
GraphQL lets clients request arbitrarily deep nesting. A malicious or careless client can write a query like { user { friends { friends { friends { posts { ... } } } } } } that triggers exponential database load. Mitigations: query depth limiting, query complexity scoring, and persisted-queries-only mode. Never expose a public GraphQL endpoint without at least one of these in place.
Do implement DataLoader (or an equivalent batching mechanism) for every resolver that loads by foreign key — treat it as a required default, not an optimisation. Don't assume that because GraphQL fires "fewer network requests" it automatically hits the database fewer times. N+1 at the DB layer is invisible to the network but just as costly.
Under the hood: how a query executes
When a GraphQL query arrives at the server, it passes through four phases before a response is sent. Understanding the phases explains both why GraphQL is flexible and where its performance traps live.
Phase 1 — Parse: query string → AST
The query string is parsed into an Abstract Syntax Tree (AST). This is similar to how a programming language compiler tokenizes source code. Invalid syntax is caught here before any resolvers run.
-- Incoming query string
query GetAuthor {
user(id: "42") {
name
posts { title }
}
}
-- AST representation (simplified):
Document {
definitions: [
OperationDefinition {
operation: "query",
name: "GetAuthor",
selectionSet: {
fields: [
Field { name: "user", arguments: [{id: "42"}],
selectionSet: {
fields: [
Field { name: "name" },
Field { name: "posts",
selectionSet: { fields: [Field { name: "title" }] }
}
]
}
}
]
}
}
]
}
Phase 2 — Validate: AST against the schema
The AST is validated against the schema. Every field name is checked to exist on its parent type; argument types are verified; required fields are confirmed. If validation fails, resolvers never run — the server returns the errors array immediately. This is why a typo in a field name gives you an error instantly.
-- Query with a typo
query { user(id: "42") { naame } } ← "naame" doesn't exist on User type
-- Server response (no DB query fired):
{
"errors": [{
"message": "Cannot query field 'naame' on type 'User'. Did you mean 'name'?",
"locations": [{ "line": 1, "column": 24 }],
"extensions": { "code": "GRAPHQL_VALIDATION_FAILED" }
}]
}
Phase 3 — Execute: resolver tree
GraphQL walks the validated AST field by field and calls each field's resolver function. Resolvers are composed into a tree that mirrors the query shape. The root resolver runs first; nested resolvers receive the parent's return value as their first argument.
-- Resolver tree for our query:
Query.user(id: "42")
→ resolver: db.users.findOne({id: "42"}) → returns user object
User.name
→ resolver: (user) => user.name → returns "Kwame Asante"
User.posts
→ resolver: (user) => db.posts.findAll({authorId: user.id})
→ returns [post1, post2]
Post.title (for post1)
→ resolver: (post) => post.title → returns "First steps with GraphQL"
Post.title (for post2)
→ resolver: (post) => post.title → returns "When to skip GraphQL"
-- Total: 2 DB queries (user + posts)
Phase 3 traced — N+1 in the resolver tree
Now imagine querying posts and their author — but from the posts side. Each post's author resolver fires independently per post. There is no built-in batching.
query {
posts(limit: 20) {
title
author { name } ← this triggers N resolver calls
}
}
-- Resolver execution trace:
Query.posts → SELECT * FROM posts LIMIT 20 (1 query)
-- For post #1: Post.author({ id: post1.authorId }) → SELECT * FROM users WHERE id = 5
-- For post #2: Post.author({ id: post2.authorId }) → SELECT * FROM users WHERE id = 7
-- For post #3: Post.author({ id: post3.authorId }) → SELECT * FROM users WHERE id = 5 ← same user, different query
-- ...
-- For post #20: Post.author({ id: post20.authorId }) → SELECT * FROM users WHERE id = 3
-- Total: 1 + 20 = 21 DB queries
-- Even if posts 1 and 3 share the same author (id=5), two separate queries are fired
Phase 3 fixed — DataLoader batching
DataLoader intercepts the individual load(id) calls. During a single event loop tick, it collects all requested IDs into a batch. After the tick, it fires a single batched query and distributes the results back to each resolver.
-- Without DataLoader
Post.author = (post) => db.users.findOne({ id: post.authorId })
-- 20 individual SELECT queries
-- With DataLoader
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findAll({ where: { id: ids } })
// DataLoader requires results in the same order as ids:
return ids.map(id => users.find(u => u.id === id))
})
Post.author = (post) => userLoader.load(post.authorId)
-- All 20 post resolvers call userLoader.load(authorId)
-- DataLoader batches after the tick:
-- SELECT * FROM users WHERE id IN (5, 7, 5, 3, ...)
-- (duplicate ids are deduplicated: IN (3, 5, 7))
-- 1 query total for all 20 posts
-- Total: 1 (posts) + 1 (users batched) = 2 DB queries
Phase 4 — Response shaping
After all resolvers return, GraphQL assembles the results into a JSON object that mirrors the query structure exactly. Fields not requested are excluded; the response key order matches the query field order. Null propagation: if a non-nullable field resolves to null, the null bubbles up to the nearest nullable parent — this can blank out whole sections of a response if a required sub-field fails.
-- Final assembled response:
{
"data": {
"user": {
"name": "Kwame Asante",
"posts": [
{ "title": "First steps with GraphQL" },
{ "title": "When to skip GraphQL" }
]
}
}
}
-- "email", "id", "created_at" are absent because the query didn't request them.
-- The server never fetched those fields even though the resolver returned the full user object.
GraphQL's validation phase does not reject deeply nested or highly expensive queries by default. A client can request friends { friends { friends { posts { comments { author { ... } } } } } } and each level multiplies the resolver calls. Add a query depth limit (max nesting levels, typically 7–10) and a query cost/complexity budget (assign a cost score to each field, reject queries that exceed a budget) before exposing any GraphQL endpoint publicly. Without these, a single malicious query can bring down the server.
How to debug & inspect it
GraphQL debugging requires three tools that REST doesn't need: body inspection for errors (not just status code), introspection queries to explore the schema, and per-operation tracing to find slow resolvers.
In browser DevTools (Network tab):
- GraphQL requests all go to the same URL (e.g.
POST /graphql). To tell them apart, look at the Payload/Request Body — the"query"key contains the operation. EnableoperationNamein your queries to get a readable label in the Network tab. - HTTP status is always 200. Click the response body and look for a top-level
"errors"key — this is the only reliable way to detect GraphQL errors in the browser. - To measure resolver performance: many GraphQL servers support a tracing extension. Add
"extensions": {"tracing": true}to the request or check your server's configuration. The response will include timing data per resolver field. - For N+1 detection: watch the server logs for repeated identical queries (e.g.
SELECT * FROM users WHERE id = Xappearing 20 times with different X values in the same second).
| Symptom | Cause | Fix |
|---|---|---|
| HTTP 400 "Must provide query string" | Content-Type header is missing or wrong — server received a POST but couldn't parse the body as JSON | Always send Content-Type: application/json; verify the body is valid JSON with the "query" key |
HTTP 200 but "errors" in response; UI shows blank |
Field-level resolver error (null propagated up from a non-nullable field, or permission denied on a field) | Check the "errors" array: each error has "path" showing which field failed and "extensions.code" for the error type |
| Query works in GraphiQL but fails in production | Introspection is enabled in dev (allows schema discovery) but disabled in prod; OR auth context is different | Confirm auth headers are being sent; confirm the field is not behind a permission check absent in playground |
| Response takes 3s for a query that fetches 50 posts | N+1 — 51 database queries are firing; no DataLoader in place for author resolver | Add DataLoader for every resolver that fetches by foreign key; enable query tracing to confirm the batchloading |
| Server returns 500 with "Query depth limit exceeded" | A depth-limiting rule rejected the query for being too deeply nested | Simplify the query; remove unnecessary nesting levels; if legitimate, raise the depth limit with careful analysis |
| Introspection query returns "Cannot query field '__schema'" | Introspection has been disabled on this endpoint (common in production as a security measure) | Use a pre-generated schema SDL file or consult the team's internal schema registry instead |
Debug checklist:
- HTTP status means almost nothing in GraphQL — always parse the response body and check for the top-level
"errors"key. - Use
operationNamein every query (e.g.query GetUserProfile { ... }) — it appears in server logs and the Network tab, making specific operations traceable. - Reproduce resolver N+1 by checking server logs for repeated queries with different single IDs in the same request window.
- Enable tracing extensions to get per-field resolve times; fields with high duration are candidates for DataLoader or caching.
- Test with introspection to verify the schema matches your expectations before debugging resolver logic.
- For depth/cost limit errors: use a query complexity analysis tool or GraphiQL extension that shows the computed cost of a query before sending it.
🧠 Quick check
1. A GraphQL subscription is used when:
Subscriptions open a persistent channel (typically over WebSockets) so the server can push new data whenever an event occurs — a chat message arrives, a price changes, a build finishes. Queries are one-shot reads; mutations are writes.
2. A GraphQL API returns HTTP 200 even when a field fails to resolve. What should a client check?
GraphQL servers almost always respond with 200 OK regardless of field-level errors. Errors appear in a top-level "errors" array in the body. This means HTTP-level alerting and caching tools need to inspect the body, not just the status code.
3. Fetching 50 posts and then firing one DB query per post to get its author is called:
The N+1 problem: 1 query to fetch the list + N individual queries for related data. With 50 posts that's 51 DB round-trips. DataLoader batches them into 2.
4. Why is HTTP caching harder in GraphQL than in REST?
HTTP caches key on the URL. REST GET /products/42 has a stable cacheable URL; a GraphQL query is a POST to /graphql with the query in the body. Standard CDN/browser caching doesn't apply — you need persisted queries, client-side normalised caches, or schema-level cache hints.
✍️ Exercise: write a query and spot the N+1 (try before opening)
Given this schema:
type Order { id: ID!; total: Float!; customer: Customer! }
type Customer { id: ID!; name: String! }
type Query { orders(limit: Int): [Order!]! }
(a) Write a query that fetches the total and customer name for the 10 most recent orders. (b) How many DB queries does a naive resolver implementation fire? (c) What would DataLoader reduce that to?
Model answer:
# (a)
query RecentOrders {
orders(limit: 10) {
total
customer { name }
}
}
# (b) Naive: 1 (orders) + 10 (customer per order) = 11 queries
# (c) DataLoader: 2 queries total
# 1 — SELECT * FROM orders LIMIT 10
# 2 — SELECT * FROM customers WHERE id IN (id1, id2, ...)
Rubric: ✓ query uses correct syntax ✓ only requested fields included ✓ naive count of 11 correctly calculated ✓ DataLoader reduces to 2 queries via batching.
Key takeaways
- GraphQL exposes one endpoint; clients declare exactly the fields they need, eliminating over- and under-fetching.
- Three operation types: query (read), mutation (write), subscription (real-time push).
- The schema (SDL) is the full contract; resolvers populate each field on the server.
- N+1 database queries are the primary performance trap — use DataLoader batching by default.
- HTTP caching doesn't apply automatically; use persisted queries or client-side caches.