Engineering Notes
Home/Backend/API Design/GraphQL
Backend · API Design

GraphQL

GraphQL lets clients ask for exactly the fields they need from one typed schema. Watch resolvers execute a nested query level by level, then see how the N+1 problem appears and how DataLoader batches it away.

⚙️ Backend● Intermediate⏱ 10 min read▶ 2 interactive🛠️ 4 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • One endpoint and a query language
  • The schema as a typed contract; queries vs mutations
  • Resolvers and execution
  • N+1 and DataLoader, subscriptions and schema evolution

📚 Before you start

Part 1Schema, Queries, and Resolvers

This topic assumes the REST-based model built up across the rest of this group (01-rest_fundamentals through 11-api_documentation). GraphQL is presented here as an alternative to that model, not a superset — this part covers the schema/type system, how a query becomes data, and the single-endpoint model. Part 2 covers the N+1 query problem and DataLoader batching, over/under-fetching compared to REST, subscriptions, and schema evolution.

1One Endpoint, a Query Language Instead of Many URLs

A REST API exposes many URLs, each returning a fixed shape (GET /orders/482 always returns the same fields). GraphQL exposes exactly one endpoint (conventionally POST /graphql), and the client's request body — the query — specifies exactly which fields it wants back:

graphql
query {
  order(id: 482) {
    id
    status
    customer {
      name
    }
  }
}
json
{
  "data": {
    "order": { "id": 482, "status": "confirmed", "customer": { "name": "Ana" } }
  }
}

The server never sends fields the client didn't ask for, and the client never has to make a second request to fetch customer.name — both problems that REST's fixed-shape responses run into (over-fetching and under-fetching, covered in depth in Part 2).

2The Schema: a Strongly-Typed Contract

Every field the API can ever return is declared up front, in the Schema Definition Language (SDL):

graphql
type Order {
  id: ID!
  status: OrderStatus!
  total: Int!
  customer: Customer!
  items: [OrderItem!]!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  CANCELLED
}

type Query {
  order(id: ID!): Order
  orders(customerId: ID!): [Order!]!
}

! marks a field non-nullable — the server guarantees it's never null in a valid response (an "Order" always has a status, an Order field on Query may legitimately return null if no order with that id exists). This schema is the entire contract: a client can only request fields that exist on it, and a query requesting a field that doesn't exist fails before execution, at validation time — this is GraphQL's equivalent of REST's OpenAPI schema (backend/api-design/11-api_documentation), except it's enforced by the GraphQL engine itself at request time, not just documented separately.

3Queries vs. Mutations

Reads and writes are distinct root types, mirroring (loosely) REST's GET-is-safe vs POST/PUT/DELETE-changes-state distinction:

graphql
type Mutation {
  cancelOrder(id: ID!): Order!
  createOrder(input: CreateOrderInput!): Order!
}

input CreateOrderInput {
  customerId: ID!
  itemIds: [ID!]!
}
graphql
mutation {
  cancelOrder(id: 482) {
    id
    status
  }
}

A mutation's response can (and usually should) select fields back from the result of the write, in the same request — a client cancelling an order gets the updated status back in the same round trip, without a follow-up GET.

4Resolvers: Where Fields Actually Get Their Data

Every field in the schema is backed by a resolver function — the code that actually produces that field's value, given its parent object:

ts
const resolvers = {
  Query: {
    order: async (_parent, args: { id: string }, ctx: Context) => {
      return ctx.db.orders.findById(args.id); // resolves the Query.order field
    },
  },
  Order: {
    customer: async (parent: OrderRow, _args, ctx: Context) => {
      return ctx.db.customers.findById(parent.customerId); // resolves Order.customer, given the Order already resolved
    },
  },
};

The GraphQL engine walks the query, calling Query.order's resolver first (producing an Order row), then — because the query also asked for customer.name — calling Order.customer's resolver with that row as its parent argument, and so on recursively until every requested field has a resolver-produced value. This resolver-per-field execution model is the root cause of Part 2's N+1 problem: each nested resolver call is, by default, independent and unaware of its siblings.

⚠️ Common Pitfalls

  1. Treating the schema as optional documentation. Unlike a REST OpenAPI spec (which can silently drift from the code, per backend/api-design/11-api_documentation), a GraphQL schema is enforced live — a query for a non-existent field is rejected before any resolver runs, which is a real guarantee, not just a convention.
  2. Confusing null with "field doesn't exist." A non-nullable (!) field that a resolver returns null for is a server error, not a valid empty result — GraphQL engines reject the entire response subtree in that case.
  3. Writing a resolver per field without considering the whole request. Section 4's naive per-field resolution is exactly what causes N+1 query storms — Part 2 covers the fix.
  4. Putting side effects in a Query resolver. Query fields are meant to be read-only, mirroring REST's GET-is-safe convention — a "query" that secretly writes breaks client assumptions about retries and caching.

📋 Quick Reference

text
ONE endpoint (POST /graphql), client's QUERY selects the exact fields
Schema (SDL)  -- type Order { field: Type! }, enforced at request time
Query          -- reads, mirrors GET
Mutation        -- writes, mirrors POST/PUT/DELETE, can select result fields in the same request
Resolver        -- one function per field, given (parent, args, context)
! suffix         -- non-nullable; a null there from a resolver is a SERVER ERROR
Part 2N+1, DataLoader, Over/Under-Fetching, and Evolution

Part 1 covered the schema, queries/mutations, and the resolver-per-field execution model — including flagging that independent per-field resolvers are the root cause of this part's first topic. This part assumes Part 1.

1The N+1 Problem

Consider a query listing orders, each with its customer's name:

graphql
query { orders(customerId: "c1") { id customer { name } } }

With Part 1's naive resolver model, if orders returns 50 rows, the engine calls Order.customer's resolver separately, once per order — 50 individual findById calls, plus the 1 call that fetched the orders list: N+1 queries for a list of N items, even though every customer lookup could have been one batched query:

ts
// NAIVE — fires one DB query per order, 50 orders = 50 extra round trips
Order: {
  customer: (parent) => ctx.db.customers.findById(parent.customerId),
}

This is the GraphQL-specific flavor of the same N+1 problem covered generically in databases/indexing-storage/05-n_plus_1_query_problem — GraphQL doesn't cause it any more than an ORM does, but GraphQL's per-field resolver model makes it the default behavior unless explicitly fixed, which is why it comes up so often in GraphQL-specific discussions.

2DataLoader: Batching and Caching Within One Request

The standard fix is DataLoader — a per-request utility that collects every individual load call issued during one tick of the event loop, then fires one batched query for all of them:

ts
import DataLoader from "dataloader";

function createCustomerLoader(db: Db) {
  return new DataLoader<string, Customer>(async (customerIds) => {
    // called ONCE per request-tick with ALL the ids collected so far, e.g. ["c1","c2","c3",...]
    const rows = await db.customers.findByIds(customerIds); // ONE query, WHERE id IN (...)
    const byId = new Map(rows.map((r) => [r.id, r]));
    return customerIds.map((id) => byId.get(id)!); // MUST return results in the SAME order as customerIds
  });
}

// resolver: looks identical to the naive version...
Order: {
  customer: (parent, _args, ctx) => ctx.loaders.customer.load(parent.customerId), // ...but .load() is batched under the hood
}

Even though Order.customer's resolver still looks like it's called once per order, DataLoader.load() doesn't hit the database immediately — it queues the id and returns a Promise, and on the next microtask tick, every queued id fires together in one findByIds call. This turns the 50 individual lookups from section 1 into exactly 2 queries total (orders + one batched customer lookup), without changing the shape of the resolver code at all. DataLoader also caches within a single request by default, so requesting the same id twice in one query never re-fetches it.

Naive resolvers issue one query for posts plus one per author; a per-request DataLoader collects author ids in one tick and loads them in a single IN query
Naive resolvers issue one query for posts plus one per author; a per-request DataLoader collects author ids in one tick and loads them in a single IN query

3Over-Fetching and Under-Fetching, Solved by Construction

  • Over-fetching: a REST endpoint returning a fixed shape that includes fields the client doesn't need (e.g. GET /orders/482 always including a large items array even when the client only wanted status) — GraphQL's client-specified field selection (Part 1, section 1) eliminates this by construction, since the client only ever gets what it asked for.
  • Under-fetching: a REST client needing data from multiple endpoints to render one view (GET /orders/482 then GET /customers/7 to get the customer's name) — GraphQL's resolver graph (Part 1, section 4) resolves this in one request, one round trip, regardless of how many "REST endpoints" worth of data it touches.

This is the core pitch for GraphQL over REST for client-driven UIs with varied data needs (e.g. a mobile app and a web dashboard wanting different subsets of the same underlying data) — each client sends its own query shaped exactly to what it renders, without the server needing bespoke per-client endpoints or a REST ?fields= query-param workaround.

4Subscriptions: a Third Root Type

Beyond Query and Mutation, GraphQL defines Subscription for long-lived, server-push updates over a persistent connection (typically WebSockets):

graphql
type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}
ts
subscribe: () => pubsub.asyncIterator(`ORDER_STATUS_CHANGED_${orderId}`),

A client subscribes once and receives a new payload every time the server publishes an update matching that subscription — conceptually similar to networking-web/browser-security/04-websockets's persistent-connection model, with GraphQL's type system and field-selection layered on top of the transport.

5Schema Evolution: Deprecation Instead of URL Versioning

REST versions by URL path (backend/api-design/05-api_versioning's /v1/orders -> /v2/orders). GraphQL, with its single endpoint, versions fields, not the API as a whole:

graphql
type Order {
  total: Int! @deprecated(reason: "Use totalCents instead — this field will be removed on 2026-01-01.")
  totalCents: Int!
}

A deprecated field keeps working for existing clients (their queries still select total and still get a value) while tooling (schema introspection, IDE autocomplete) surfaces the deprecation warning to anyone writing a new query against it. This avoids REST's all-or-nothing "the whole API moved to /v2" — individual fields evolve independently, and a client's query only breaks if it explicitly selects a field that's been fully removed after its deprecation window closes.

⚠️ Common Pitfalls

  1. Shipping a resolver-per-field design without DataLoader. Section 1's N+1 is the default outcome, not an edge case — any list-of-objects-with-a-nested-relation query hits it immediately at real data volumes.
  2. A DataLoader batch function returning results out of order. DataLoader matches results back to callers positionally — the batch function's return array MUST be the same length and same order as the ids it was called with, or wrong data gets attached to wrong callers.
  3. Treating GraphQL as automatically faster than REST. It solves over/under-fetching at the network-request level; it does nothing by itself about the underlying database work — an unbatched N+1 resolver graph can be slower than a well-designed REST endpoint doing one JOIN.
  4. Removing a field instead of deprecating it first. Breaks every client still selecting it with no warning — deprecation exists specifically to give clients a migration window.
  5. Forgetting Subscription needs its own transport concerns. Unlike Query/Mutation over plain HTTP POST, Subscriptions need a persistent connection (WebSocket) and its own auth/origin-validation story (networking-web/browser-security/04-websockets).

📋 Quick Reference

text
N+1               -- naive per-field resolver = 1 query per parent row in a list
DataLoader.load()  -- queues + batches all loads in one tick into ONE query, caches per-request
Over-fetching       -- fixed REST shape includes unwanted fields -- solved by client field selection
Under-fetching       -- REST needs multiple endpoints for one view -- solved by resolver graph, one request
Subscription           -- 3rd root type, server-push over a persistent connection (WebSocket)
Schema evolution         -- @deprecated on fields, NOT URL versioning -- fields evolve independently

🧠 Key takeaways

  • One endpoint, a typed schema; clients select fields; queries read, mutations write.
  • Resolvers run per field; nested lists cause N+1 unless batched.
  • DataLoader batches and caches per request: create loaders per request.
  • Evolve with @deprecated instead of versions; protect with depth/complexity limits and persisted queries.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What problems does GraphQL solve?core▾

Clients request exactly the fields they need in one query, avoiding over-fetching and multiple round trips; a strongly typed schema enables tooling and introspection; and frontends can evolve without new endpoints.

Follow-ups interviewers ask
  • When is REST better? Simple CRUD, heavy HTTP caching needs, file transfer, or public APIs with simple usage.
Q2How do you solve the N+1 problem in GraphQL?core▾

Use DataLoader to batch and cache loads per request: resolvers request items by key, and DataLoader issues one batched query per tick (WHERE id IN (…)).

Follow-ups interviewers ask
  • Why per-request cache? Avoids stale or cross-user data while deduplicating within a request.
Q3How do you protect a GraphQL API from expensive queries?senior▾

Limit query depth and complexity (cost analysis), paginate lists, set timeouts, rate-limit by cost, and use persisted queries (allowlisted query hashes) for first-party clients.

Follow-ups interviewers ask
  • What are persisted queries? Clients send a hash of a pre-registered query instead of arbitrary text.
Q4How is caching different with GraphQL?core▾

Everything goes through one POST endpoint, so HTTP/CDN caching doesn't work out of the box. Use client-side normalized caches (Apollo, urql), persisted queries over GET for CDN caching, and server-side resolver/data caches.

Follow-ups interviewers ask
  • What does normalized caching mean? Storing objects by type and ID so updates reflect everywhere.
Q5How do you handle errors and versioning in GraphQL?core▾

Errors come in an errors array (often with HTTP 200); model expected business errors as part of the schema (union result types). Versioning is avoided by evolving the schema: add fields, deprecate old ones with @deprecated.

Follow-ups interviewers ask
  • What's federation? Composing a single graph from multiple services' subgraphs (Apollo Federation).

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why does GraphQL often hit N+1?
Per-item resolution.
2. DataLoader instances should be…
Avoid cross-user cache leaks.
3. How does GraphQL evolve schemas?
Additive evolution.
4. A malicious deeply nested query is mitigated by…
Cost analysis.