Skip to content

GraphQL#

GraphQL system design lets clients request exactly the fields they need from a typed schema through a single endpoint. One round trip can stitch together data that REST would require many calls to assemble.

GraphQL logo, the query language for typed API schemas
GraphQL logo, © The GraphQL Foundation, via Wikimedia Commons.
flowchart LR
  C([Mobile/Web Client])
  GQL[GraphQL Gateway]
  R1[User Resolver]
  R2[Order Resolver]
  R3[Catalog Resolver]
  US[(Users DB)]
  OS[(Orders DB)]
  CS[(Catalog DB)]

  C -- query users, orders, catalog --> GQL
  GQL --> R1 --> US
  GQL --> R2 --> OS
  GQL --> R3 --> CS

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    class C client;
    class GQL edge;
    class R1,R2,R3 service;
    class US,OS,CS datastore;

A GraphQL server publishes a schema that declares every type, field, query, mutation, and subscription it supports. Clients submit a query that names the fields they want; the server walks the query tree, calling a resolver for each field, and returns a JSON shape that mirrors the request. There is no over-fetching of unused fields and no under-fetching that forces extra calls.

Three operation kinds cover most use cases. Queries read data, mutations write data, and subscriptions push updates over WebSocket or server-sent events. Mature deployments add a DataLoader layer to batch and cache lookups inside a request, defeating the N+1 problem that naive resolvers create. At scale, teams adopt Apollo Federation or similar (Hot Chocolate, GraphQL Mesh) so each microservice owns a slice of the unified schema while a router composes the final response.

GraphQL shines for product surfaces with many client variants (web, iOS, Android, partners) that each want different field combinations. The cost is a steeper server-side learning curve: query complexity limits, persisted queries, and authorization at the field level all need deliberate design.

Problem statement (interviewer prompt)

Design an API layer for a product with iOS, Android, web, and partner clients where each surface needs different field combinations from many backend services, while keeping latency, security, and cache hit rate acceptable.

Why GraphQL exists#

REST forces the server to define resource shapes that every client must accept. Mobile clients on poor networks pay for fields they ignore, and complex screens chain three or four REST calls to build one view. GraphQL inverts the contract: the client sends a query that names the exact fields it wants and the server returns a JSON tree shaped like the query.

flowchart LR
  CLIENT([Client query])
  SCHEMA[Typed Schema]
  EXEC[Executor]
  R1[user resolver]
  R2[orders resolver]
  R3[product resolver]
  DL[(DataLoader cache)]

  CLIENT --> SCHEMA --> EXEC
  EXEC --> R1
  EXEC --> R2
  EXEC --> R3
  R1 <--> DL
  R2 <--> DL
  R3 <--> DL

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    class CLIENT client;
    class SCHEMA,EXEC edge;
    class R1,R2,R3 service;
    class DL cache;

Schema, resolvers, operations#

The schema uses a small DSL with scalars, objects, enums, interfaces, unions, and input types. Every field maps to a resolver function (parent, args, context, info) => value. The executor walks the query depth-first, calling resolvers in parallel where the graph allows.

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  name: String!
  orders(limit: Int = 10): [Order!]!
}

type Order {
  id: ID!
  total: Money!
  items: [LineItem!]!
}

Three root operation types exist. Query is read-only and idempotent. Mutation is write-bearing and runs top-level fields sequentially. Subscription opens a long-lived stream (WebSocket, SSE, or GraphQL over HTTP multipart) and pushes events when the server-side source publishes.

Federation and the supergraph#

A single monolith schema does not scale across teams. Apollo Federation v2 (and equivalents like Hot Chocolate Fusion, GraphQL Mesh, Cosmo Router) lets each subgraph own a slice of the types. A router composes them into a supergraph at build time, then plans queries that span subgraphs at request time.

flowchart TB
  CL([Client])
  ROUT[Router/Supergraph]
  SG1[Users subgraph]
  SG2[Orders subgraph]
  SG3[Catalog subgraph]
  REG[(Schema registry)]

  CL --> ROUT
  ROUT --> SG1
  ROUT --> SG2
  ROUT --> SG3
  REG -. composed schema .-> ROUT

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    class CL client;
    class ROUT edge;
    class SG1,SG2,SG3 service;
    class REG storage;

Entity ownership is declared with @key(fields: "id"). A subgraph that does not own the type can still extend it with new fields, and the router fetches base fields from the owner before resolving the extension.

DataLoader and the N+1 problem#

A naive orders[].user resolver calls the user service once per order. With 200 orders that is 200 round trips. DataLoader batches and caches by key inside a single request: the executor enqueues all userById(7) calls during one tick, then the loader issues one usersByIds([7,9,12,...]) call and dispatches results back.

class UserLoader:
    def __init__(self, user_repo):
        self.user_repo = user_repo
        self.queue = {}

    async def load(self, user_id):
        if user_id not in self.queue:
            self.queue[user_id] = asyncio.get_event_loop().create_future()
        await asyncio.sleep(0)  # batch tick
        if not self._dispatched:
            self._dispatched = True
            users = await self.user_repo.batch_get(list(self.queue.keys()))
            for uid, fut in self.queue.items():
                fut.set_result(users.get(uid))
        return await self.queue[user_id]

Persisted queries and security#

Sending arbitrary queries from untrusted clients invites abuse: nested friends { friends { friends { ... } } } queries explode quadratically. Defenses include:

Control Purpose
Query depth limit Reject queries deeper than N levels
Query complexity scoring Assign cost per field, cap per request
Persisted (trusted) queries Client sends a hash, server has the document
Automatic Persisted Queries Optimistic hash-first, fall back to full query
Field-level authorization Resolver checks context.user role
Rate limit by query hash Per-operation throttles

Persisted queries also enable HTTP caching by hash, so a CDN can cache GET responses.

REST vs GraphQL tradeoffs#

Dimension REST GraphQL
Round trips Often N for nested data Usually 1
Over-fetching Common None by design
Caching HTTP cache, easy Needs persisted queries
Schema typing OpenAPI optional Mandatory
Subscriptions Add WebSocket separately Built into schema
Versioning URI or header Deprecate fields, no version bump
Server complexity Low High (N+1, query cost, federation)

REST still wins for file uploads, simple CRUD, and public APIs that need raw HTTP caching. GraphQL wins for rich product surfaces and rapidly changing UI requirements.

Production gotchas#

  • N+1 is the default, not the exception. Always wire DataLoader and verify with tracing.
  • Subscriptions need sticky sessions or a pub/sub layer (Redis, Kafka) so any router can forward events.
  • Errors are partial by default: a failed field returns null and adds to the errors array. Clients must handle partial responses.
  • Caching is the hard problem: at the edge use persisted query hashes as cache keys; in the client use Apollo or Relay normalized stores.
  • Schema breakage is silent: deprecate fields with @deprecated and watch usage in the schema registry before removal.
  • Federation pitfalls: entity resolution chains can hide cross-subgraph latency. Trace router-to-subgraph hops and pin SLAs per subgraph.

Quick reference#

Core concepts#

  • Typed schema declares queries, mutations, subscriptions
  • Resolver per field, signature (parent, args, ctx, info)
  • Executor walks query tree, parallel where possible
  • Response shape mirrors request shape, no over-fetch

Operations#

  • Query: read, idempotent, parallel resolvers
  • Mutation: write, top-level fields run sequentially
  • Subscription: WebSocket or SSE push stream

Federation#

  • Each subgraph owns a slice of types via @key
  • Router composes a supergraph from registered subgraphs
  • Entity reference __resolveReference fetches owner data
  • Tools: Apollo Federation v2, Hot Chocolate, Cosmo, Mesh

DataLoader#

  • Batches keys collected within one event loop tick
  • Caches by key for the lifetime of one request only
  • Solves the N+1 resolver problem

Performance and safety#

  • Depth limit, complexity score, persisted queries
  • Field-level auth in resolvers using ctx.user
  • Persisted query hash as CDN cache key
  • Trace router and subgraph hops with OpenTelemetry

Failure modes#

  • Naive resolvers cause N+1 explosions
  • Unbounded queries from untrusted clients
  • Subscriptions need sticky sessions or pub/sub fan-out
  • Partial errors confuse clients that expect all-or-nothing
  • Federation hides cross-service latency

When to use#

  • Many client variants needing different field sets
  • Rapidly changing product UI
  • Aggregation across many backend services

When to skip#

  • Simple CRUD with one client
  • Heavy file upload/download workloads
  • Public APIs that need raw HTTP caching

Refs#

  • Lee Byron and team: "GraphQL Spec" (2018, ongoing)
  • Apollo: "Federation v2 Docs" (2022)
  • Facebook Engineering: "GraphQL: A data query language" (2015)

FAQ#

What is GraphQL?#

GraphQL is a query language and runtime where clients send a typed query naming only the fields they need, and the server walks resolvers to return that exact shape.

GraphQL vs REST: when use which?#

Pick GraphQL when clients have varied data needs and round-trip count matters, like mobile apps. Pick REST when caching, simplicity, and CDN behaviour matter more.

What is the N+1 problem in GraphQL?#

Naive resolvers issue one DB query per parent and then one per child, blowing up to N+1 queries. DataLoader batches and caches lookups inside a request to fix it.

What is Apollo Federation?#

Federation lets each microservice own a subgraph of the schema. A gateway router composes a single unified API and dispatches each field to the owning service.

How do GraphQL subscriptions work?#

Subscriptions push updates over WebSocket or server-sent events. Clients open a long-lived connection, the server emits events, and the client receives a typed payload.

  • API Gateway: how a gateway can sit in front of or beside a GraphQL router.
  • BFF Pattern: often a precursor to GraphQL for per-client APIs.
  • HTTP Protocols: transport choices for queries and subscriptions.