GraphQL API Design
GraphQL API Design
GraphQL API design patterns provide the vocabulary for schema-first contract design, resolver execution semantics, N+1 batching with DataLoader, and federation for distributed graph composition.
Intent
GraphQL is a query language and runtime for APIs that lets clients request exactly the fields they need. This note covers four complementary design concerns: (1) schema-first design as the default workflow, (2) resolver structure and field-level error semantics, (3) the N+1 problem and DataLoader as the mandatory solution, and (4) GraphQL Federation vocabulary for distributed graphs. Each is a design decision framework, not a GraphQL tutorial. This note assumes familiarity with basic GraphQL query/mutation syntax.
These patterns answer the questions a team faces when choosing and implementing GraphQL: whether GraphQL is appropriate at all, how to structure a schema and its resolvers, how to prevent the structural N+1 performance problem every GraphQL deployment faces, and how to understand the vocabulary of federated graphs when single-service GraphQL is no longer sufficient.
When NOT to Use
-
Simple CRUD APIs with no nested relationships — GraphQL adds resolver infrastructure, schema definition, and client complexity. A REST endpoint returning flat JSON is simpler and sufficient. If your API has no relationship traversal, GraphQL's field selection provides marginal benefit at significant setup cost.
-
Public APIs consumed by untrusted clients without query complexity controls — GraphQL allows arbitrary query depth and breadth. Without depth limiting, query cost analysis, and rate limiting, a single malicious query can cause denial-of-service. REST endpoints have implicit query bounds; GraphQL does not.
-
High-throughput binary protocols between internal services — gRPC with protobuf provides schema enforcement, streaming, and binary efficiency. GraphQL's JSON serialisation and resolver overhead add latency compared to direct protobuf calls when you control both sides.
-
When your team does not have DataLoader expertise — N+1 is structural in GraphQL, not an edge case. Deploying GraphQL without DataLoader batching guarantees production performance problems. If the team cannot implement per-request DataLoader correctly, choose REST.
When to Use
- APIs with deeply nested or graph-shaped data where clients benefit from selecting exactly the fields they need (mobile apps, dashboards, aggregation layers)
- Multi-consumer APIs where different clients need different field subsets from the same data (avoids over-fetching and multiple REST endpoints)
- BFF layers where the frontend team owns the query contract — see
[[BFF-Pattern]] - APIs where the schema serves as the contract between frontend and backend teams (schema-first development with code generation)
How It Works
Schema-First Design (GQL-01)
Write the GraphQL SDL (Schema Definition Language) before any resolver implementation. The schema is the contract; resolvers fulfill it. Schema-first forces contract thinking, enables code generation (@graphql-codegen/typescript + @graphql-codegen/typescript-resolvers), and allows parallel client/server development against a mock schema.
The SDL defines every type, field, and relationship before a line of resolver code exists. Code generation produces TypeScript resolver type signatures directly from the SDL, ensuring that resolver signatures always match the schema contract. Client teams can begin building against a mock schema while server teams implement the resolvers — parallel development without a shared deployment dependency.
// Source: GraphQL Specification (spec.graphql.org) — schema definition language
// Schema-first: SDL defines the contract before resolver code
type Query {
post(id: ID!): Post
posts(first: Int, after: String): PostConnection
}
type Post {
id: ID!
title: String!
author: User! # ← each Post resolver triggers a User fetch — N+1 risk
publishedAt: String
}
type User {
id: ID!
name: String!
}
# Relay cursor connection (see [[Operational-API-Patterns]])
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge { node: Post!; cursor: String! }
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}The author: User! field above annotates the N+1 risk inline. Every Post in a list query triggers a separate Post.author resolver call — without DataLoader, each resolver makes a database call. See the N+1 Problem and DataLoader section below.
Resolver Structure and Field-Level Error Semantics (GQL-01)
GraphQL returns partial results — some fields may resolve successfully while others return null with errors in the errors array. The schema's nullability decisions directly control error propagation behaviour.
Nullable fields (author: User): When a nullable field resolver throws or returns null, the error is collected in the errors array and the field value is null. The parent object continues resolving — the caller receives a partial result with the failure recorded in errors.
Non-null fields (author: User!): When a non-null field resolver throws, the error propagates upward to the nearest nullable ancestor, which becomes null. The error is still collected in errors. A single failed author lookup can null the entire parent Post object if author: User! is non-null.
// Source: GraphQL Specification (spec.graphql.org) — sections 3.5.5, 6.4.4
// Nullable field: error collected in errors[], parent continues resolving
// Non-null field (!): error propagates upward to nearest nullable ancestor
// Schema design decision:
// author: User → nullable; author failure returns null + error entry
// author: User! → non-null; author failure nulls entire Post
// Response shape when author resolver throws (nullable schema):
// {
// "data": { "post": { "id": "1", "title": "Hello", "author": null } },
// "errors": [{ "message": "User not found", "path": ["post", "author"] }]
// }Overusing ! (non-null) is a common mistake. When a non-null field fails, the error propagates upward and nullifies the nearest nullable parent. A single failed author lookup can null an entire post. Use nullable fields for any data that can fail independently — reserve ! for fields that are truly always present (like id).
Schema evolution: GraphQL does not use versioned type names (UserV2). Add fields to existing types, mark obsolete fields with the @deprecated directive, and remove them after a migration window. Type name versioning is an anti-pattern per official GraphQL best practices.
N+1 Problem and DataLoader (GQL-01)
The N+1 problem is a structural guarantee of GraphQL's resolver execution model, not an optional optimization concern or an edge case that only affects large production systems.
Why N+1 is structural: Field resolvers in GraphQL execute per-item, not per-query. The Post.author resolver does not know it will be called 100 times in the same request. Each call executes independently and, without DataLoader, makes its own database call. 100 posts without DataLoader = 101 database calls (1 for the post list + 100 for individual authors). This is not a programmer mistake — it is how the resolver execution model works.
DataLoader is the canonical solution. It coalesces all individual load(key) calls within a single event-loop tick into one batch call, reducing 101 database calls to 2.
DataLoader execution model:
- Within a single event-loop tick, DataLoader collects all individual
load(key)calls without executing them. - At the end of the tick, DataLoader flushes all collected keys as a single batch call to the
batchFn. - The
batchFnreceives an array of keys and returns an array of values in the same order. - DataLoader distributes the results back to each individual
load(key)promise.
// Source: github.com/graphql/dataloader — DataLoader 2.x
// DataLoader MUST be instantiated per-request inside the context factory
import DataLoader from 'dataloader';
// Batch function: receives array of user IDs, returns array of Users in same order
async function batchLoadUsers(ids: readonly string[]): Promise<User[]> {
const users = await db.query(
`SELECT * FROM users WHERE id = ANY($1)`, [ids]
);
// CRITICAL: return in same order as input ids
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
}
// Context factory — called once per request; DataLoader is per-request
function createContext(): GraphQLContext {
return {
// Per-request DataLoader: cache is isolated to this request lifecycle
userLoader: new DataLoader<string, User>(batchLoadUsers),
};
}
// Resolver: Post.author uses the per-request DataLoader
const resolvers = {
Post: {
author: (post: Post, _args: unknown, ctx: GraphQLContext) =>
ctx.userLoader.load(post.authorId),
},
};DataLoader maintains an in-memory cache of key-value pairs. This cache MUST be scoped to a single request. A shared (singleton) DataLoader leaks data between requests and causes stale-read bugs. Always instantiate DataLoader inside the context factory function — never at module scope.
Warning signs: new DataLoader(batchFn) at the top level of a file, or DataLoader stored in a module-level variable. Tests that run multiple queries against the same server instance and see stale data are a reliable indicator of this bug.
For resolver resilience — circuit breakers wrapping downstream service calls from resolvers — see [[Circuit-Breaker-Pattern]].
Relay Cursor Connections
The Relay Connection Spec (relay.dev/graphql/connections.htm) formalises cursor pagination for GraphQL. It defines: a Connection type (containing edges and pageInfo), an Edge type (containing node and cursor), and PageInfo fields (hasNextPage, hasPreviousPage, startCursor, endCursor).
The SDL example in the Schema-First section above includes the full PostConnection / PostEdge / PageInfo type structure following the Relay spec. For the full cursor-vs-offset comparison, opaque cursor encoding, and limit-plus-one query implementation, see [[Operational-API-Patterns]] — that note covers the Relay Connection Spec in detail. Do not duplicate that content here.
Sequence Diagram
GraphQL query resolution with DataLoader batching -- N+1 prevention reduces 101 database calls to 2.
sequenceDiagram
participant C as Client
participant GQL as GraphQL Server
participant R as Resolver Layer
participant DL as DataLoader<br/>(per-request)
participant DB as Database
C->>GQL: POST /graphql<br/>{ query: "{ posts(first:3) {<br/> title, author { name } } }" }
GQL->>GQL: Parse SDL + validate query
GQL->>R: Execute Query.posts resolver
R->>DB: SELECT * FROM posts LIMIT 3
DB-->>R: [post1, post2, post3]
Note over R,DL: Post.author resolver called per-item
R->>DL: load(post1.authorId)
R->>DL: load(post2.authorId)
R->>DL: load(post3.authorId)
Note over DL: End of event-loop tick --<br/>DataLoader flushes batch
DL->>DB: SELECT * FROM users<br/>WHERE id = ANY([id1, id2, id3])
Note right of DB: N+1 prevented:<br/>1 post query + 1 user query = 2<br/>(not 1 + 3 = 4, or 1 + 100 = 101)
DB-->>DL: [user1, user2, user3]
DL-->>R: Distribute results to<br/>individual load() promises
R-->>GQL: Resolved posts with authors
GQL-->>C: 200 OK<br/>{ data: { posts: [<br/> { title: "...", author: { name: "..." } },<br/> ...] } }
Note over C,DB: Per-request DataLoader:<br/>cache scoped to this request only
Subscriptions Caveat
GraphQL subscriptions use the graphql-ws WebSocket subprotocol for real-time server-to-client push. Use graphql-ws for all new projects. subscriptions-transport-ws is deprecated (2021) and should not be used — it is unmaintained and browser-incompatible in modern environments.
GraphQL subscriptions are the API-layer expression of the Observer pattern — the server pushes events to subscribed clients, and each subscriber's resolver executes on each event. See [[Observer-Pattern]] for the underlying pattern.
Scope boundary: GraphQL subscriptions add significant infrastructure complexity — WebSocket servers, connection management, horizontal scaling concerns, and reconnection logic. For simple server-push use cases (notifications, live feeds), Server-Sent Events (SSE) may be simpler. Evaluate SSE before defaulting to GraphQL subscriptions.
GraphQL Federation (GQL-02)
GraphQL Federation allows multiple GraphQL services (subgraphs) to compose into a single unified graph (supergraph) via a gateway layer. This section covers vocabulary only — it is not a federation implementation tutorial.
Vocabulary:
-
Subgraph — an independent GraphQL service that owns a portion of the schema. Decorated with
@keydirectives to expose entity resolution. Each subgraph is a standalone GraphQL API that can be deployed and evolved independently. -
Supergraph — the composed schema produced by the federation gateway. Clients query the supergraph as if it were a single GraphQL API. The gateway routes field resolution requests to the appropriate subgraph.
-
@keydirective — marks the primary key field(s) that uniquely identify an entity across subgraphs. The gateway uses@keyfields to route entity resolution requests to the owning subgraph. -
Entity resolution — when Subgraph A references a type owned by Subgraph B, the gateway calls Subgraph B's
__resolveReferenceto hydrate the full entity. -
Reference resolver (
__resolveReference) — a special resolver in each subgraph that hydrates an entity given only its@keyfields. This is the DataLoader integration point in federated graphs —__resolveReferenceshould use DataLoader to batch entity hydration across the gateway's parallel resolution calls.
Minimal SDL example:
# Source: Apollo Federation 2 — https://www.apollographql.com/docs/federation/
# Subgraph A (Users service) — owns User entity
type User @key(fields: "id") {
id: ID!
name: String!
}
# Subgraph B (Posts service) — references User entity from Subgraph A
type Post {
id: ID!
title: String!
author: User # gateway resolves User via Subgraph A's __resolveReference
}
# In Subgraph A: entity resolution — called by gateway with { id } representation
# Resolver: __resolveReference({ id }) → fetch User by id
# This is the DataLoader integration point — batch entity hydration hereFor implementation details, see the Apollo Federation 2.x documentation. Do not conflate federation with the base GraphQL pattern — single-service schema-first GraphQL does not require federation.
Federation uses a gateway layer for schema composition and routing — see [[API-Gateway-Pattern]] for gateway patterns.
TypeScript Examples
Apollo Server 5 with DataLoader
// Source: Apollo Server 5 documentation — https://www.apollographql.com/docs/apollo-server/
// Apollo Server 5: expressMiddleware API (standalone server also available)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import DataLoader from 'dataloader';
const typeDefs = `
type Query { posts: [Post!]! }
type Post { id: ID!; title: String!; author: User! }
type User { id: ID!; name: String! }
`;
const resolvers = {
Query: {
posts: (_: unknown, __: unknown, ctx: Context) => ctx.db.getAllPosts(),
},
Post: {
// DataLoader.load() returns a Promise; resolver returns Promise<User>
author: (post: Post, _: unknown, ctx: Context) =>
ctx.userLoader.load(post.authorId),
},
};
interface Context {
db: Db;
userLoader: DataLoader<string, User>;
}
const server = new ApolloServer<Context>({ typeDefs, resolvers });
await server.start();
const app = express();
app.use('/graphql', expressMiddleware(server, {
// Context factory is called once per request — DataLoader is per-request
context: async () => ({
db,
userLoader: new DataLoader<string, User>(async (ids) => {
const users = await db.getUsersByIds([...ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? new Error(`User ${id} not found`));
}),
}),
}));DataLoader Batch Function
// Source: github.com/graphql/dataloader — DataLoader 2.x
// Standalone batch function showing the per-request DataLoader pattern
import DataLoader from 'dataloader';
// Batch function: receives array of user IDs, returns array of Users in same order
async function batchLoadUsers(ids: readonly string[]): Promise<User[]> {
const users = await db.query(
`SELECT * FROM users WHERE id = ANY($1)`, [ids]
);
// CRITICAL: return in same order as input ids
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
}
// Context factory — called once per request
function createContext(): GraphQLContext {
return {
// Per-request DataLoader: cache is isolated to this request lifecycle
userLoader: new DataLoader<string, User>(batchLoadUsers),
};
}
// Resolver usage: Post.author field
const resolvers = {
Post: {
author: (post: Post, _args: unknown, ctx: GraphQLContext) =>
ctx.userLoader.load(post.authorId),
},
};Java Examples
Spring for GraphQL 2.0 — @QueryMapping
// Source: Spring for GraphQL 2.0 documentation
// https://docs.spring.io/spring-graphql/reference/
// Requires: spring-boot-starter-graphql (Spring Boot 3.4+), graphql-java 25.0
@Controller
public class PostController {
private final PostRepository postRepo;
@QueryMapping
public List<Post> posts() {
return postRepo.findAll();
}
// Field mapping: Post.author resolved via DataLoader (registered below)
@SchemaMapping(typeName = "Post", field = "author")
public CompletableFuture<User> author(Post post, DataLoader<String, User> loader) {
return loader.load(post.getAuthorId());
}
}BatchLoaderRegistry
// Source: Spring for GraphQL 2.0 documentation
// https://docs.spring.io/spring-graphql/reference/
// BatchLoaderRegistry creates a per-request DataLoader instance automatically
@Configuration
public class DataLoaderConfig {
@Bean
public BatchLoaderRegistry batchLoaderRegistry(UserRepository userRepo) {
BatchLoaderRegistry registry = new DefaultBatchLoaderRegistry();
// BatchLoaderRegistry creates a per-request DataLoader instance automatically
registry.forTypePair(String.class, User.class)
.withName("userLoader")
.registerBatchLoader((ids, env) ->
Flux.fromIterable(userRepo.findAllById(ids))
);
return registry;
}
}Lineage
Chain 14 (Query Flexibility):
- Lineage Backward:
[[REST-API-Design]]— REST provides the baseline request-response API model; GraphQL adds client-driven field selection on top of the same HTTP transport - Lineage Forward: Federation (distributed graph) — Federation extends single-service GraphQL to a composed supergraph; see Phase 27
[[API-Protocol-Selection-MOC]]for the full chain
This note is the middle node of Chain 14: REST → GraphQL (field selection) → Federation (distributed graph).
Related Concepts
| Concept | Relationship |
|---|---|
[[REST-API-Design]] | REST is the baseline API protocol; GraphQL adds field selection and nested query capabilities |
[[Operational-API-Patterns]] | Relay cursor connections formalise cursor pagination for GraphQL |
[[BFF-Pattern]] | GraphQL is a common BFF protocol choice — frontend teams own the query contract |
[[API-Gateway-Pattern]] | Federation uses a gateway layer for schema composition |
[[Observer-Pattern]] | GraphQL subscriptions are the API-layer expression of the Observer pattern |
[[Circuit-Breaker-Pattern]] | Resolver resilience via circuit breakers for downstream service calls |
Sources
- GraphQL Specification (spec.graphql.org, June 2021) — SDL, resolver execution model, error handling (sections 3, 6): https://spec.graphql.org/
- GraphQL Best Practices (graphql.org/learn/best-practices) — N+1, pagination, schema versioning guidance: https://graphql.org/learn/best-practices/
- Relay Cursor Connections Specification (relay.dev) — cursor pagination schema conventions: https://relay.dev/graphql/connections.htm
- DataLoader 2.x README (github.com/graphql/dataloader) — batch function contract, per-request instantiation, cache isolation: https://github.com/graphql/dataloader
- Apollo Server 5 documentation — server setup, context factory, expressMiddleware: https://www.apollographql.com/docs/apollo-server/
- npm:
@apollo/server@5.4.x— Apollo Server 4 EOL date 2026-01-26 confirmed - Spring for GraphQL 2.0 reference documentation: https://docs.spring.io/spring-graphql/reference/
- Spring for GraphQL 2.0.0 GA release (spring.io, November 2025) — graphql-java 25.0 requirement
- Apollo Federation 2 documentation — subgraph/supergraph vocabulary, @key directive: https://www.apollographql.com/docs/federation/
- Production Ready GraphQL (Marc-Andre Giroux, 2020) — schema design, N+1, persisted queries