21 KiB
GraphQL API Design
Overview
GraphQL API specialist covering schema design, query optimization, real-time subscriptions, federation, and production patterns.
Core principle: GraphQL enables clients to request exactly the data they need in a single query - but requires careful schema design, batching strategies, and security measures to prevent performance and security issues.
When to Use This Skill
Use when encountering:
- N+1 query problems: Too many database queries for nested resolvers
- Schema design: Types, interfaces, unions, input types, directives
- Pagination: Connections, cursors, offset patterns
- Performance: Query complexity, caching, batching, persisted queries
- Real-time: Subscriptions, WebSocket patterns, live queries
- Federation: Splitting schema across multiple services
- Security: Query depth limiting, cost analysis, allowlisting
- Testing: Schema validation, resolver testing, integration tests
- Migrations: Schema evolution, deprecation, versioning
Do NOT use for:
- REST API design →
rest-api-design - Framework-specific implementation →
fastapi-development,express-development - Microservices architecture →
microservices-architecture(use with Federation)
GraphQL vs REST Decision Matrix
| Factor | Choose GraphQL | Choose REST |
|---|---|---|
| Client needs | Mobile apps, varying data needs | Uniform data requirements |
| Over/under-fetching | Problem | Not a problem |
| Real-time features | Subscriptions built-in | Need SSE/WebSockets separately |
| Schema-first | Strong typing required | Flexible, schema optional |
| Caching | Complex (field-level) | Simple (HTTP caching) |
| File uploads | Non-standard (multipart) | Native (multipart/form-data) |
| Team expertise | GraphQL experience | REST experience |
| API consumers | Known clients | Public/third-party |
| Rate limiting | Complex (field-level) | Simple (endpoint-level) |
Hybrid approach: GraphQL for internal/mobile, REST for public APIs
Quick Reference - Core Patterns
| Pattern | Use Case | Key Concept |
|---|---|---|
| DataLoader | N+1 queries | Batch and cache within request |
| Connection | Pagination | Cursor-based with edges/nodes |
| Union | Heterogeneous results | Search, activity feeds |
| Interface | Shared fields | Polymorphic types with guarantees |
| Directive | Field behavior | @auth, @deprecated, custom logic |
| Input types | Mutations | Type-safe input validation |
| Federation | Microservices | Distributed schema composition |
| Subscription | Real-time | WebSocket-based live updates |
N+1 Query Optimization
The Problem
// Schema
type Post {
id: ID!
title: String!
author: User! // Requires fetching user
}
type Query {
posts: [Post!]!
}
// Naive resolver (N+1 problem)
const resolvers = {
Query: {
posts: () => db.posts.findAll() // 1 query
},
Post: {
author: (post) => db.users.findOne(post.authorId) // N queries!
}
};
// Result: 100 posts = 101 database queries
DataLoader Solution
const DataLoader = require('dataloader');
// Batch loading function
const batchUsers = async (userIds) => {
const users = await db.users.findMany({
where: { id: { in: userIds } }
});
// CRITICAL: Return in same order as requested IDs
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
};
// Create loader per-request (avoid stale cache)
const createLoaders = () => ({
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts),
// ... other loaders
});
// Add to context
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
loaders: createLoaders(),
db,
user: getCurrentUser()
})
});
// Use in resolver
const resolvers = {
Post: {
author: (post, args, { loaders }) => {
return loaders.user.load(post.authorId); // Batched!
}
}
};
Result: 100 posts = 2 queries (1 for posts, 1 batched for unique authors)
Advanced DataLoader Patterns
Composite Keys:
// For multi-field lookups
const batchUsersByEmail = async (keys) => {
// keys = [{domain: 'example.com', email: 'user@example.com'}, ...]
const users = await db.users.findMany({
where: {
OR: keys.map(k => ({ email: k.email, domain: k.domain }))
}
});
const userMap = new Map(
users.map(u => [`${u.domain}:${u.email}`, u])
);
return keys.map(k => userMap.get(`${k.domain}:${k.email}`));
};
const userByEmailLoader = new DataLoader(batchUsersByEmail, {
cacheKeyFn: (key) => `${key.domain}:${key.email}`
});
Priming Cache:
// After fetching posts, prime user loader
const posts = await db.posts.findAll();
posts.forEach(post => {
if (post.authorData) {
loaders.user.prime(post.authorId, post.authorData);
}
});
return posts;
Error Handling in Batch:
const batchUsers = async (userIds) => {
const users = await db.users.findMany({
where: { id: { in: userIds } }
});
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => {
const user = userMap.get(id);
if (!user) {
return new Error(`User ${id} not found`); // Per-item error
}
return user;
});
};
Schema Design Patterns
Interface vs Union
Interface (shared fields enforced):
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
email: String!
name: String!
}
type Post implements Node & Timestamped {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
content: String!
}
type Query {
node(id: ID!): Node # Can return any Node implementer
nodes(ids: [ID!]!): [Node!]!
}
Query:
{
node(id: "user_123") {
id
... on User {
email
name
}
... on Post {
title
}
}
}
Union (no shared fields required):
union SearchResult = User | Post | Comment
type Query {
search(query: String!): [SearchResult!]!
}
When to use each:
| Use Case | Pattern | Why |
|---|---|---|
| Global ID lookup | Interface (Node) | Guarantees id field |
| Polymorphic lists with shared fields | Interface | Can query shared fields without fragments |
| Heterogeneous results | Union | No shared field requirements |
| Activity feeds | Union | Different event types |
| Search results | Union | Mixed content types |
Input Types and Validation
input CreatePostInput {
title: String!
content: String!
tags: [String!]
publishedAt: DateTime
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
}
Benefits:
- Reusable across multiple mutations
- Clear separation of create vs update requirements
- Type-safe in generated code
- Can add descriptions per field
Custom Directives
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(limit: Int!, window: Int!) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION | ENUM_VALUE
enum Role {
USER
ADMIN
SUPER_ADMIN
}
type Query {
publicData: String
userData: User @auth(requires: USER)
adminData: String @auth(requires: ADMIN)
expensiveQuery: Result @rateLimit(limit: 10, window: 60)
}
type User {
id: ID!
email: String! @auth(requires: USER) # Only authenticated users
internalId: String @deprecated(reason: "Use `id` instead")
}
Pagination Patterns
Relay Connection Specification
Standard connection pattern:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int # Optional
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(
first: Int
after: String
last: Int
before: String
): PostConnection!
}
Implementation:
const resolvers = {
Query: {
posts: async (parent, { first, after, last, before }) => {
const limit = first || last || 10;
const cursor = after || before;
// Decode cursor
const offset = cursor ? decodeCursor(cursor) : 0;
// Fetch one extra to determine hasNextPage
const posts = await db.posts.findMany({
skip: offset,
take: limit + 1,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = posts.length > limit;
const edges = posts.slice(0, limit).map((post, index) => ({
node: post,
cursor: encodeCursor(offset + index)
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: offset > 0,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
}
};
}
}
};
// Opaque cursor encoding
const encodeCursor = (offset) =>
Buffer.from(`arrayconnection:${offset}`).toString('base64');
const decodeCursor = (cursor) =>
parseInt(Buffer.from(cursor, 'base64').toString().split(':')[1]);
Alternative: Offset pagination (simpler but less robust):
type PostPage {
items: [Post!]!
total: Int!
page: Int!
pageSize: Int!
}
type Query {
posts(page: Int = 1, pageSize: Int = 20): PostPage!
}
Performance Optimization
Query Complexity Analysis
Prevent expensive queries:
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // Max 10 levels deep
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10
})
]
});
Custom complexity:
type Query {
posts(first: Int!): [Post!]! @cost(complexity: 10, multipliers: ["first"])
expensiveAnalytics: AnalyticsReport! @cost(complexity: 1000)
}
Automatic Persisted Queries (APQ)
Client sends hash instead of full query:
// Client
const query = gql`
query GetUser($id: ID!) {
user(id: $id) { name email }
}
`;
const queryHash = sha256(query);
// First request: Send hash only
fetch('/graphql', {
body: JSON.stringify({
extensions: {
persistedQuery: {
version: 1,
sha256Hash: queryHash
}
},
variables: { id: '123' }
})
});
// If server doesn't have it (PersistedQueryNotFound)
// Second request: Send full query + hash
fetch('/graphql', {
body: JSON.stringify({
query,
extensions: {
persistedQuery: {
version: 1,
sha256Hash: queryHash
}
},
variables: { id: '123' }
})
});
// Future requests: Just send hash
Benefits:
- Reduced bandwidth (hash << full query)
- CDN caching of GET requests
- Query allowlisting (if configured)
Field-Level Caching
const resolvers = {
Query: {
user: async (parent, { id }, { cache }) => {
const cacheKey = `user:${id}`;
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findOne(id);
await cache.set(cacheKey, JSON.stringify(user), { ttl: 300 });
return user;
}
}
};
Subscriptions (Real-Time)
Basic Subscription
type Subscription {
postAdded: Post!
commentAdded(postId: ID!): Comment!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
Implementation (Apollo Server):
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const resolvers = {
Mutation: {
createPost: async (parent, { input }) => {
const post = await db.posts.create(input);
pubsub.publish('POST_ADDED', { postAdded: post });
return post;
}
},
Subscription: {
postAdded: {
subscribe: () => pubsub.asyncIterator(['POST_ADDED'])
},
commentAdded: {
subscribe: (parent, { postId }) =>
pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
}
}
};
// Client
subscription {
postAdded {
id
title
author { name }
}
}
Scaling Subscriptions
Problem: In-memory PubSub doesn't work across servers
Solution: Redis PubSub
const { RedisPubSub } = require('graphql-redis-subscriptions');
const Redis = require('ioredis');
const pubsub = new RedisPubSub({
publisher: new Redis(),
subscriber: new Redis()
});
// Now works across multiple server instances
Subscription Authorization
const resolvers = {
Subscription: {
secretDataUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['SECRET_DATA']),
(payload, variables, context) => {
// Only admin users can subscribe
return context.user?.role === 'ADMIN';
}
)
}
}
};
Federation (Distributed Schema)
Split schema across multiple services:
User Service
# user-service schema
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
type Query {
user(id: ID!): User
}
Post Service
# post-service schema
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
authorId: ID!
author: User!
}
Gateway
Composes schemas and routes requests:
const { ApolloGateway } = require('@apollo/gateway');
const gateway = new ApolloGateway({
serviceList: [
{ name: 'users', url: 'http://user-service:4001/graphql' },
{ name: 'posts', url: 'http://post-service:4002/graphql' }
]
});
const server = new ApolloServer({
gateway,
subscriptions: false // Not yet supported in federation
});
Reference Resolver (fetch extended fields):
// post-service resolvers
const resolvers = {
User: {
__resolveReference: async (user) => {
// Receive { __typename: 'User', id: '123' }
// Don't need to fetch user, just return it for field resolution
return user;
},
posts: async (user) => {
return db.posts.findMany({ where: { authorId: user.id } });
}
}
};
Security Patterns
Query Depth Limiting
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
validationRules: [depthLimit(7)] // Max 7 levels deep
});
// Prevents: user { posts { author { posts { author { ... } } } }
Query Allowlisting (Production)
const allowedQueries = new Map([
['GetUser', 'query GetUser($id: ID!) { user(id: $id) { name } }'],
['ListPosts', 'query ListPosts { posts { title } }']
]);
const server = new ApolloServer({
validationRules: [
(context) => ({
Document(node) {
const queryName = node.definitions[0]?.name?.value;
if (!allowedQueries.has(queryName)) {
context.reportError(
new GraphQLError('Query not allowed')
);
}
}
})
]
});
Rate Limiting (Field-Level)
const { shield, rule, and } = require('graphql-shield');
const isRateLimited = rule({ cache: 'contextual' })(
async (parent, args, ctx, info) => {
const key = `rate:${ctx.user.id}:${info.fieldName}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60); // 1 minute window
}
return count <= 10; // 10 requests per minute
}
);
const permissions = shield({
Query: {
expensiveQuery: isRateLimited
}
});
Schema Evolution
Deprecation
type User {
id: ID!
username: String @deprecated(reason: "Use `name` instead")
name: String!
}
Tooling shows warnings to clients
Breaking Changes (Avoid)
❌ Breaking:
- Removing fields
- Changing field types
- Making nullable → non-nullable
- Removing enum values
- Changing arguments
✅ Non-breaking:
- Adding fields
- Adding types
- Deprecating fields
- Making non-nullable → nullable
- Adding arguments with defaults
Versioning Strategy
Don't version schema - evolve incrementally:
- Add new field
- Deprecate old field
- Monitor usage
- Remove old field in next major version (if removing)
Testing Strategies
Schema Validation
const { buildSchema, validateSchema } = require('graphql');
test('schema is valid', () => {
const schema = buildSchema(typeDefs);
const errors = validateSchema(schema);
expect(errors).toHaveLength(0);
});
Resolver Testing
const resolvers = require('./resolvers');
test('user resolver fetches user', async () => {
const mockDb = {
users: { findOne: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }) }
};
const result = await resolvers.Query.user(
null,
{ id: '1' },
{ db: mockDb, loaders: { user: mockDataLoader() } }
);
expect(result).toEqual({ id: '1', name: 'Alice' });
expect(mockDb.users.findOne).toHaveBeenCalledWith('1');
});
Integration Testing
const { ApolloServer } = require('apollo-server');
const { createTestClient } = require('apollo-server-testing');
const server = new ApolloServer({ typeDefs, resolvers });
const { query } = createTestClient(server);
test('GetUser query', async () => {
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
name
email
}
}
`;
const res = await query({ query: GET_USER, variables: { id: '1' } });
expect(res.errors).toBeUndefined();
expect(res.data.user).toMatchObject({
name: 'Alice',
email: 'alice@example.com'
});
});
Anti-Patterns
| Anti-Pattern | Why Bad | Fix |
|---|---|---|
| No DataLoader | N+1 queries kill performance | Use DataLoader for all entity fetching |
| Offset pagination | Breaks with real-time data | Use cursor-based connections |
| No query complexity | DoS via deeply nested queries | Set depth/complexity limits |
| Shared DataLoader instances | Stale cache across requests | Create new loaders per request |
| No error masking | Leaks internal errors to clients | Mask in production, log internally |
| mutations returning Boolean | Can't extend response | Return object type |
| Nullable IDs | IDs should never be null | Use ID! not ID |
| Over-fetching in resolvers | Selecting * wastes bandwidth | Select only requested fields |
Common Mistakes
1. DataLoader Return Order
// ❌ WRONG - Returns in database order
const batchUsers = async (ids) => {
return await db.users.findMany({ where: { id: { in: ids } } });
};
// ✅ CORRECT - Returns in requested order
const batchUsers = async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id));
};
2. Mutations Returning Primitives
# ❌ BAD - Can't extend
type Mutation {
deletePost(id: ID!): Boolean!
}
# ✅ GOOD - Extensible
type DeletePostPayload {
success: Boolean!
deletedPostId: ID
message: String
}
type Mutation {
deletePost(id: ID!): DeletePostPayload!
}
3. No Context in Subscriptions
// ❌ Missing auth context
const server = new ApolloServer({
subscriptions: {
onConnect: () => {
return {}; // No user context!
}
}
});
// ✅ Include auth
const server = new ApolloServer({
subscriptions: {
onConnect: (connectionParams) => {
const token = connectionParams.authToken;
const user = verifyToken(token);
return { user };
}
}
});
Tooling Ecosystem
Schema Management:
- Apollo Studio: Schema registry, operation tracking, metrics
- GraphQL Inspector: Schema diffing, breaking change detection
- Graphql-eslint: Linting for schema and queries
Code Generation:
- GraphQL Code Generator: TypeScript types from schema
- Apollo Codegen: Client types for queries
Development:
- GraphiQL: In-browser IDE
- Apollo Sandbox: Modern GraphQL explorer
- Altair: Desktop GraphQL client
Testing:
- EasyGraphQL Test: Schema mocking
- GraphQL Tools: Schema stitching, mocking
Cross-References
Related skills:
- REST comparison →
rest-api-design(when to use each) - FastAPI implementation →
fastapi-development(Strawberry, Graphene) - Express implementation →
express-development(Apollo Server, GraphQL Yoga) - Microservices →
microservices-architecture(use with Federation) - Security →
ordis-security-architect(OWASP API Security) - Testing →
api-testing(integration testing strategies) - Authentication →
api-authentication(JWT, OAuth2 with GraphQL)
Further Reading
- GraphQL Spec: https://spec.graphql.org/
- Apollo Docs: Federation, caching, tooling
- Relay Spec: Connection specification
- DataLoader GitHub: facebook/dataloader
- Production Ready GraphQL: Book by Marc-André Giroux