API Design Advanced

GraphQL

A query language for APIs and a runtime for executing those queries, enabling clients to request exactly the data they need — including schema definition, resolvers, the N+1 problem, federation, and caching challenges.

⏱ 16 min read

What it is

GraphQL is a query language for APIs — and a server-side runtime for executing those queries — developed by Facebook and open-sourced in 2015. Instead of exposing a fixed set of URL endpoints, a GraphQL server exposes a single endpoint (typically POST /graphql) and accepts structured query documents that describe exactly what the client wants. The server validates the query against a strongly-typed schema and returns a JSON response shaped to match the query.

The three root operation types are Query (read-only, idempotent), Mutation (state-changing operations), and Subscription (real-time push via WebSocket). The schema, defined using Schema Definition Language (SDL), is the contract between client and server.

# Schema Definition Language
type User {
  id: ID!
  name: String!
  email: String!
  orders(first: Int, after: String): OrderConnection!
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  lineItems: [LineItem!]!
}

enum OrderStatus { PENDING FULFILLED CANCELLED }

type Query {
  user(id: ID!): User
  products(filter: ProductFilter): [Product!]!
}

type Mutation {
  createOrder(input: CreateOrderInput!): Order!
}

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

Why it exists

Facebook built GraphQL to solve the mobile performance problem: their REST APIs suffered from both over-fetching (returning fields the mobile app didn't need, wasting bandwidth) and under-fetching (requiring multiple serial round trips to assemble a screen). With hundreds of millions of mobile users on variable-quality networks, these inefficiencies were significant.

REST's rigid resource model also meant that each new UI feature required either a new endpoint or changes to an existing one — creating tight coupling between API servers and frontend teams. GraphQL inverts this relationship: the schema evolves independently of the queries, and clients drive what they retrieve.

When to use

  • Multiple client types (web, iOS, Android, third-party) with different data requirements for the same underlying entities.
  • Rapidly evolving frontends where teams need to add fields without backend coordination.
  • Data-heavy dashboards requiring aggregated data from multiple domain areas in a single request.
  • Developer portals where an explorable, self-documenting schema improves DX.
  • Backends serving a BFF-like aggregation role across internal microservices.

When not to use

  • Simple CRUD APIs with a single client — the overhead of schema design, resolvers, and tooling is not justified.
  • File upload-heavy APIs — multipart file upload over GraphQL is awkward; REST handles it more naturally.
  • Service-to-service internal calls — gRPC offers better performance with code generation and typed contracts.
  • APIs where HTTP caching is critical — GET-based query caching is complex with GraphQL's single endpoint POST model.
  • Teams without GraphQL expertise — the resolver execution model and schema design decisions have a significant learning curve.

Typical architecture

Client
  |
  |-- POST /graphql
  |   { query: "{ user(id:\"42\") { name orders { total } } }" }
  |
GraphQL Server (Apollo Server / Strawberry / Hasura)
  |
  |-- Query parsed & validated against Schema
  |-- Execution engine resolves fields
  |
  |-- userResolver(id: 42)     → Users DB
  |-- ordersResolver(userId:42) → Orders DB (via DataLoader)
  |
  |← { data: { user: { name: "Alice", orders: [...] } } }

DataLoader batching (N+1 fix):
  Without DataLoader:
    resolve user 1 → SELECT * FROM orders WHERE user_id=1
    resolve user 2 → SELECT * FROM orders WHERE user_id=2
    resolve user 3 → SELECT * FROM orders WHERE user_id=3  (N queries)

  With DataLoader:
    collect keys [1,2,3] in a tick
    → SELECT * FROM orders WHERE user_id IN (1,2,3)  (1 query)

Federation:
  [Supergraph Schema]
     ↙               ↘
[Users Subgraph]  [Orders Subgraph]
  (owns User)       (owns Order, extends User)

Pros and cons

Pros

  • Clients request exactly the fields they need — no over-fetching or under-fetching.
  • Single round trip can fetch deeply nested, related data across multiple entities.
  • Strongly-typed schema is a living contract and auto-generates documentation.
  • Introspection allows tooling (GraphiQL, Postman, codegen) to discover capabilities.
  • Schema evolution is additive by default — adding fields is non-breaking.
  • Federation enables a distributed supergraph composed of multiple team-owned subgraphs.

Cons

  • N+1 query problem requires DataLoader or similar batching; easy to overlook in resolvers.
  • HTTP caching doesn't work naturally because most requests are POST to the same URL.
  • Complex queries allow clients to trigger expensive resolver trees — requires query depth/cost limiting.
  • Schema design is hard; poor type design becomes a long-lived liability.
  • Error handling is non-standard: partial success returns HTTP 200 with an errors array.
  • File upload, multipart responses, and binary data are awkward compared to REST.

Implementation notes

Resolver design

Each field in a GraphQL schema has a resolver function responsible for returning its value. Resolvers form a tree that mirrors the query shape. The root resolvers (on Query/Mutation types) typically fetch the top-level entity; nested resolvers (on object types) fetch related data. Keep resolvers thin — delegate to a service or repository layer rather than embedding business logic.

The N+1 problem and DataLoader

The classic GraphQL performance trap: if you fetch a list of 100 users and each user has an orders field, a naive implementation executes 100 individual database queries. DataLoader (Facebook's library, now available in every major language) solves this by batching all keys collected within a single JavaScript event loop tick into one query, then distributing results back to individual resolvers. Always use a per-request DataLoader instance to avoid cross-request data leakage.

Persisted queries

Persisted queries (also called Automatic Persisted Queries / APQ) replace the full query string with a hash. On first request the server stores the query; subsequent requests send only the hash. Benefits: smaller request payloads, the ability to use GET requests (enabling HTTP caching), and preventing clients from sending arbitrary queries in production (allowlist mode).

Schema stitching vs Federation

Schema stitching (older approach) merges multiple GraphQL schemas at the gateway, manually delegating resolvers. Apollo Federation is the modern standard: each subgraph team decorates their schema with @key directives to define entity boundaries, and the gateway (Apollo Router or Cosmo) assembles the supergraph query plan at runtime without manual delegation wiring. Federation is preferred for large organisations with multiple API-owning teams.

Caching with GraphQL

HTTP caching is difficult because most queries are POST. Strategies include: (1) Persisted queries with GET for read-only operations, enabling CDN/edge caching by URL. (2) Response caching middleware (e.g., Apollo Server's @cacheControl directive) which sets Cache-Control headers per type. (3) Client-side normalised caching (Apollo Client, Relay) which stores entities by ID and merges updates in the local cache, avoiding redundant network calls.

Introspection and security

Introspection allows any client to query the full schema (__schema, __type). In production, disable or restrict introspection to authenticated internal users — it reveals your entire data model to potential attackers. Always implement query depth limits, query complexity limits, and rate limiting to prevent denial-of-service via deeply nested queries.

Common failure modes

  • Missing DataLoader — resolver fetches data per-object instead of batching; 100-row list becomes 101 queries.
  • Unrestricted query depth — a malicious or runaway client nests queries 50 levels deep, exhausting the server.
  • Leaking internal errors — GraphQL servers can accidentally expose stack traces in the extensions field of error responses.
  • Schema breaking changes without a deprecation cycle — removing a field immediately breaks all clients that use it; always deprecate first (@deprecated).
  • Overly broad types — a god Query type with hundreds of root fields becomes unmaintainable; structure with namespaced types or federation.
  • Per-request schema introspection in production — enabling introspection for all requests exposes the schema and adds overhead.

Decision checklist

  • Are DataLoaders used for every relationship that resolves a collection of sub-entities?
  • Is query depth limiting and/or cost analysis configured?
  • Is introspection restricted to authenticated/internal callers in production?
  • Are persisted queries used for all production client queries?
  • Is there a schema evolution policy (deprecation before removal, breaking-change alerts)?
  • Is error handling distinguishing between business errors (expected) and system errors (unexpected)?
  • For multi-team setups, is Apollo Federation or similar being used instead of manual schema stitching?
  • Is a per-request DataLoader instance used (not a shared singleton)?

Example use cases

  • GitHub's public API (v4) — GraphQL enables third-party developers to query exactly the repository, issue, and pull request fields they need, dramatically reducing response sizes compared to the v3 REST API.
  • Shopify Storefront API — a single GraphQL query fetches products, variants, prices, and inventory status in one round trip for e-commerce storefronts.
  • Internal developer platform supergraph — each domain team (users, orders, payments, fulfilment) owns a subgraph; platform teams compose the router without any team needing to know the others' internals.
  • Mobile BFF — GraphQL used as a Backend-for-Frontend aggregation layer that fetches from four microservices and returns a single shaped response optimised for the mobile screen layout.
  • REST API Design — the alternative to GraphQL for resource-oriented APIs.
  • gRPC — binary RPC for high-performance internal APIs.
  • Backend for Frontend (BFF) — GraphQL is a common implementation of BFF aggregation.
  • API Gateway — GraphQL router/gateway pattern for federated schemas.
  • API Pagination — Relay connection spec is the GraphQL pagination standard.

Further reading

  • GraphQL specification — graphql.github.io/graphql-spec
  • Apollo Federation documentation — apollographql.com/docs/federation
  • DataLoader library — github.com/graphql/dataloader
  • Lee Byron & Dan Schafer — GraphQL: A data query language (Engineering at Meta blog, 2015).
  • Marc-André Giroux — Production Ready GraphQL (book, 2020).
  • Relay Pagination Spec — relay.dev/graphql/connections.htm