GraphQL: Client-Selected Data Through a Typed Schema
📑 On this page
- A concrete example: a profile screen
- The schema is the contract
- Queries read data
- Mutations change state
- Resolvers provide field values
- The N+1 problem
- Variables separate data from query text
- Fragments reuse field selections
- Nullability is a promise
- Errors can coexist with data
- Authorization is field and object aware
- Query complexity control
- Pagination
- HTTP caching is less direct
- Normalized client caches
- Subscriptions
- Schema evolution
- When GraphQL fits
- Knowledge check
- The one idea to remember
Screens often need data from several related resources but only a few fields from each.
GraphQL lets the client describe that shape.
GraphQL is a typed API query language and execution model in which clients select fields and relationships from a server-defined schema.
This flexibility can reduce over-fetching and multiple endpoint calls. It moves important complexity into schema design, resolver performance, authorization, and query control.
A concrete example: a profile screen
A client requests:
query Profile($userId: ID!) {
user(id: $userId) {
name
avatarUrl
recentPosts(first: 3) {
nodes {
id
title
}
}
}
}The response follows the requested shape.
The client does not receive email, address, every post, or other available fields.
The server still decides whether the caller may access each value.
The schema is the contract
A schema defines types:
type User {
id: ID!
name: String!
avatarUrl: String
recentPosts(first: Int!, after: String): PostConnection!
}The schema describes:
- Fields
- Input arguments
- Return types
- Nullability
- Relationships
Clients can inspect the schema and tools can validate queries before execution.
Field names and descriptions should express domain meaning, not merely mirror database columns.
Queries read data
GraphQL query operations request data.
One query can traverse related fields:
query Order($id: ID!) {
order(id: $id) {
status
customer {
displayName
}
items {
quantity
product {
name
}
}
}
}The graph describes relationships in the API schema.
It does not require a graph database. Resolvers may read relational databases, caches, services, or other APIs.
Mutations change state
Mutations represent state-changing operations:
mutation CancelOrder($input: CancelOrderInput!) {
cancelOrder(input: $input) {
order {
id
status
}
userErrors {
code
message
field
}
}
}Use intention-revealing domain operations rather than one generic update mutation for every field.
The mutation should validate authorization, current state, idempotency, and business rules.
GraphQL syntax does not provide transaction safety automatically.
Resolvers provide field values
A resolver is server code that supplies a field.
For Order.customer, it may:
- Read a local table
- Call a customer service
- Use a cache
- Return preloaded data
Resolvers allow the schema to remain stable while implementation changes.
They can also hide expensive call graphs.
A simple-looking client query may trigger hundreds of backend operations unless the server plans and batches carefully.
The N+1 problem
Suppose a query requests 100 posts and each post's author.
A naive implementation:
- Runs one query for posts.
- Runs one author query per post.
That is 101 database queries.
Batching tools collect requested author IDs and load them in one operation:
SELECT * FROM users WHERE id IN (...)Data loading should also deduplicate and cache within one request.
Performance must be evaluated from query shape to backend execution.
Variables separate data from query text
Use variables:
query Product($id: ID!) {
product(id: $id) {
name
}
}with:
{ "id": "p-42" }Variables support:
- Reuse
- Type validation
- Cleaner logging
- Safer client construction
- Persisted queries
They do not remove the need to validate business meaning and authorization.
Fragments reuse field selections
Fragments define reusable selections:
fragment ProductCard on Product {
id
name
thumbnailUrl
price {
amount
currency
}
}Several screens can share a consistent product-card shape.
Fragments are client organization, not server-side versioned views.
Adding too many broad fragments can cause screens to fetch fields they no longer need, weakening the benefit of selection.
Nullability is a promise
String! means the field is non-null.
If a non-null resolver fails or returns null, the error propagates upward until it reaches a nullable boundary.
This can turn one missing nested value into a null parent object.
Use non-null where the server can genuinely uphold the guarantee.
Making every field nullable forces clients to handle uncertainty everywhere; making every field non-null makes partial data fragile.
Schema nullability is an architectural decision.
Errors can coexist with data
A GraphQL response can contain:
{
"data": {
"user": {
"name": "Mira",
"avatarUrl": null
}
},
"errors": [
{
"message": "Avatar service unavailable",
"path": ["user", "avatarUrl"]
}
]
}Partial data can improve resilience.
Clients need a policy for which errors are:
- Field-level
- User-correctable
- Authentication-related
- Retryable
- Fatal to the screen
Stable extension codes are more useful than parsing human messages.
Authorization is field and object aware
Authentication at the GraphQL endpoint is not enough.
The server must authorize:
- Root operation
- Object
- Field
- Tenant
- Mutation
A user may view an employee's name but not salary.
Authorization should live in reusable policy or domain layers rather than be inconsistently repeated in resolvers.
Avoid fetching forbidden data and filtering it only after retrieval when doing so expands exposure.
Query complexity control
Clients can request expensive nested data:
users {
posts {
comments {
author {
posts {
...
}
}
}
}
}Protect the server with:
- Depth limits
- Field cost estimates
- Result limits
- Timeouts
- Pagination
- Rate limits
- Persisted approved queries
One HTTP request can contain vastly different amounts of work.
Rate limiting only by request count is often insufficient.
Pagination
Collections need bounded results.
Cursor-style connections commonly expose:
posts(first: 20, after: $cursor) {
nodes { id title }
pageInfo {
endCursor
hasNextPage
}
}The cursor represents a position in a stable ordering.
Servers should cap page size even when clients request more.
Nested pagination needs care because requesting many parents each with many children can multiply results rapidly.
HTTP caching is less direct
Many GraphQL APIs use one endpoint and POST requests, so URL-based shared HTTP caches cannot distinguish queries easily.
Alternatives include:
- Client normalized caches
- Persisted query identifiers
- GET for safe persisted queries
- Resolver or data-source caches
- CDN configurations aware of operation and variables
Caching needs identity and field-level sensitivity.
A cached response for one user must never be served to another.
Normalized client caches
GraphQL clients can store objects by type and ID:
Product:p-42Different queries that include the same object can share fields.
This improves UI responsiveness but requires:
- Stable identity
- Merge policies
- Pagination handling
- Invalidation
- Optimistic update rollback
The client cache is a copy, not automatically the source of truth.
Subscriptions
GraphQL subscriptions deliver updates over a long-lived connection.
Examples:
- New message
- Order status changed
- Collaborative presence
The server needs:
- Connection authentication
- Reauthorization over time
- Backpressure
- Reconnection
- Missed-event recovery
- Scaling
Subscriptions are not guaranteed durable event delivery unless the surrounding architecture provides it.
For many integrations, webhooks or queues are more appropriate.
Schema evolution
GraphQL commonly evolves additively:
- Add type
- Add optional field
- Add enum value with client care
Fields can be marked deprecated with a reason and replacement.
Because clients select fields, unused fields can remain without affecting response size.
Removing a field safely requires usage telemetry and a migration window.
Changing field meaning is breaking even if the type stays the same.
When GraphQL fits
GraphQL can fit when:
- Several clients need different data shapes.
- The domain has rich relationships.
- Frontend teams benefit from schema tooling.
- An aggregation layer can hide service composition.
It may be unnecessary when:
- Operations are few and stable.
- HTTP caching is central.
- File transfer dominates.
- The team lacks resolver and schema operations expertise.
REST, GraphQL, RPC, and events solve different interface needs. A product can use more than one deliberately.
Knowledge check
- Why does GraphQL not require a graph database?
- What causes the N+1 problem?
- Why is field nullability a meaningful guarantee?
- Why can one GraphQL request require more protection than request-count rate limiting?
- What evidence is needed before removing a deprecated field?
The one idea to remember
GraphQL gives clients precise selection through a typed schema. That flexibility is valuable only when the server controls authorization, query cost, resolver performance, pagination, caching, and compatible schema evolution.