Stylesgraphqlschemaresolversqueriesmutationssubscriptions

GraphQL: Client-Shaped Queries Over One Schema

GraphQL replaces many endpoints with one typed schema that clients query for exactly the fields they need: queries, mutations, subscriptions, resolvers behind each field. The benefits — no over/under-fetching, one contract for many client shapes, introspection — are real; so are the costs, which get their own lesson.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When many clients need different slices of one connected data graph, what does letting the client choose the shape buy — and what does the server take on to make that safe?
Consumers
Product clients — web, mobile, partner dashboards — with divergent, frequently changing screen shapes over a shared, relationship-rich domain, where endpoint-per-screen has become unsustainable and a typed contract is worth funding.
The promise
One schema describes every type, field and relationship; clients request exactly the fields they need in one round trip; the response mirrors the query shape; and the schema is introspectable, so tooling can validate, complete and document queries without a separate spec.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Schema, query, resolvers, sources

A GraphQL API is a typed schema — object types with fields, relationships between them, and three root operations: Query for reads, Mutation for writes, Subscription for server-pushed events. A client sends a query naming the fields it wants, including nested ones across relationships, and the response is JSON in exactly that shape. Nothing else is returned; nothing requested is missing. That is the whole pitch, and for a mobile screen that needs a user's name, three recent orders and their totals, it replaces four REST calls or one bespoke endpoint with one query (Over-Fetching and Under-Fetching).

Behind every field is a resolver: a function that produces that field's value given its parent, arguments and context. Resolvers call databases, services, caches — whatever the data source is. The schema is the contract; resolvers are the implementation; the client never sees which fields come from which service. This is the same boundary REST draws between response model and storage (Response Contracts Are Not Database Rows), enforced by the type system instead of by discipline.

query { user { name orders { total } } }field by fielduserorderstotalsClient querySchema (validate, plan)Resolvers per fieldDatabaseOrders serviceCacheResponse in query shape
UserLLMAgentToolDataDecisionHumanGuardrail

What the client gets

Field selection removes the over-fetching argument entirely and, through nesting, most of the under-fetching one — a screen is one query. The typed schema is machine-readable and introspectable, so editors autocomplete fields, clients generate types, and the documentation is generated from the same source the server enforces — the schema-first advantage without a separate spec file (Schema-First vs Code-First). Many client shapes share one contract: adding a field for the mobile app does not touch web, and neither client is coupled to the other's needs.

Mutations are named operations with typed inputs and a selectable result — closer to RPC: Operation-Oriented Contracts than to resource methods, which is honest for write-heavy domains. Subscriptions declare a stream of events over a transport the server chooses (usually WebSockets, sometimes SSE), giving real-time a place in the same schema (WebSocket Message Contracts, Server-Sent Events). Evolution is additive by default: new fields are invisible until requested, and @deprecated on a field carries the migration signal to every tool that reads the schema (Deprecation as a Process, Not a Label).

One query, one round trip, the exact shape the screen needs
Request
POST /graphql
Authorization: Bearer …
Content-Type: application/json

{ "query": "query Dashboard($id: ID!) {
  user(id: $id) {
    name
    orders(first: 3, orderBy: CREATED_DESC) {
      edges { node { id total { amount currency } status } }
      pageInfo { hasNextPage endCursor }
    }
  }
}", "variables": { "id": "usr_7" } }
Response
HTTP/1.1 200 OK
Content-Type: application/json

{ "data": { "user": { "name": "Ada",
  "orders": { "edges": [
    { "node": { "id": "ord_42", "total": { "amount": 1999, "currency": "EUR" }, "status": "SHIPPED" } },
    { "node": { "id": "ord_41", "total": { "amount": 500, "currency": "EUR" }, "status": "DELIVERED" } }
  ], "pageInfo": { "hasNextPage": true, "endCursor": "b3JkXzQx" } } } } }

What the schema must still decide

GraphQL relocates design questions; it does not remove them. Lists still need a pagination contract — the connection pattern with cursors above is a convention, not a language feature, and an unbounded orders field is an unbounded collection with a nicer syntax (Unbounded Collections: The Anti-Pattern With a Fuse, Cursor Pagination: An Opaque Bookmark, Not a Position). Errors travel in a errors array beside partial data with HTTP 200, which means the error model — codes, retryability, field paths — must be designed into extensions rather than borrowed from status codes (The Error Model: Structure Over Apology). Authorization is per field and per parent, not per endpoint (Authorization Design in the Contract).

And the flexibility that clients enjoy is the server's exposure: any client can compose any query, including one that walks the graph five levels deep across every user. Resolver-level N+1, query cost, depth limits, persisted queries and per-operation observability are the machinery GraphQL requires to be safe in production; they are the subject of What GraphQL Costs, and a team that adopts the schema without budgeting for them has adopted half of GraphQL.

  • Pagination — connections with cursors on every list field; no unbounded lists.
  • Errors — machine-readable codes in extensions; the HTTP status is always 200.
  • Authorization — per field and per parent object, in resolvers or a directive layer.
  • Cost — depth, complexity and rate limits; persisted queries for public clients.
  • Observability — per-operation-name metrics, not per-URL.

Key points

  • One typed schema, client-selected fields, response in query shape: the over/under-fetching problem is solved at the contract level.
  • Resolvers are the implementation boundary; the client never sees which source serves which field.
  • Introspection makes the schema the documentation and the tooling source; evolution is additive with @deprecated signals.
  • Mutations behave like typed RPC; subscriptions bring real-time into the same contract.
  • Pagination, errors, authorization and cost control still have to be designed — GraphQL relocates them into the schema and resolvers.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → schema: exposes every database table as a type with every column and relationship, calling it "the graph".
  2. 2
    Client → query: requests users { orders { items { product { reviews } } } } for a search page; resolvers fan out into thousands of queries.
  3. 3
    Client → errors: a mutation fails with HTTP 200 and an errors array containing only a message string; the app cannot branch on it.
  4. 4
    Product → list: the orders field has no arguments and returns everything; a customer with 40,000 orders times out the dashboard.
  5. 5
    Security → review: a field on User exposes email to any query that reaches a user through any relationship.
What breaks
  • Expensive arbitrary queries take the API down; the flexibility is also the attack surface.
  • Clients cannot branch on errors; retry logic and UX degrade to "something went wrong".
  • Unbounded list fields grow until they time out — the same failure as REST, one level deeper.
  • Authorization gaps appear on fields reachable through relationships nobody reviewed.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Design the schema from consumer screens and domain concepts, not from tables; every list field is a connection with limits.
  • • Define an error contract in `extensions` — code, retryability, field path — and document it as carefully as REST status codes.
  • • Enforce authorization per field and parent, reviewed as part of schema changes.
  • • Budget the cost-control machinery from [[graphql-costs]] before the first public client ships.
  • • Name every operation and report metrics per operation name.
Observe in production
  • • Resolver call counts per request — the N+1 signal — and p99 per operation name.
  • • Query depth and complexity distributions; a growing tail means clients are composing what you did not anticipate.
  • • Error codes per operation from the `extensions` field, not HTTP status, which is always 200.
  • • Field usage telemetry from introspection-aware tooling, which is what makes `@deprecated` removals safe.
Evolve without breaking
  • • Add fields and types freely; they are invisible until requested.
  • • Deprecate fields with `@deprecated(reason:)` and remove after field-level usage telemetry reaches zero ([[removing-fields]]).
  • • Schema stitching or federation lets multiple teams own parts of one graph as it grows.
  • • GraphQL can be introduced as a BFF over existing REST services and, if it stops earning its cost, retired the same way.
What it costs
  • • The client's flexibility is the server's exposure; cost control is mandatory, not optional.
  • • HTTP caching is mostly lost — every query is a POST to one URL — and must be rebuilt at the resolver or client layer.
  • • Per-field authorization and error design are more work than per-endpoint equivalents.

Misconceptions

Claim
“GraphQL replaces the need for API design.”
Reality
It replaces endpoint design with schema design. Pagination, errors, authorization, naming and evolution are all still decisions — made in a type system instead of in URL conventions.
Claim
“GraphQL is a database query language.”
Reality
It is a contract language over resolvers. Exposing tables as types recreates the raw-row anti-pattern with better tooling; the schema should be shaped by consumer tasks.
Claim
“One endpoint means simpler operations.”
Reality
One URL means standard HTTP metrics, caches and gateways see nothing useful. Operation-name metrics, cost limits and resolver batching are the replacements — real work that REST got from infrastructure.

Apply it