# 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 ```javascript // 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 ```javascript 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**: ```javascript // 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**: ```javascript // 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**: ```javascript 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): ```graphql 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**: ```graphql { node(id: "user_123") { id ... on User { email name } ... on Post { title } } } ``` **Union** (no shared fields required): ```graphql 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 ```graphql 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 ```graphql 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**: ```graphql 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**: ```javascript 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): ```graphql 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**: ```javascript 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**: ```graphql 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**: ```javascript // 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 ```javascript 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 ```graphql type Subscription { postAdded: Post! commentAdded(postId: ID!): Comment! } type Mutation { createPost(input: CreatePostInput!): Post! } ``` **Implementation (Apollo Server)**: ```javascript 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 ```javascript 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 ```javascript 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 ```graphql # user-service schema type User @key(fields: "id") { id: ID! email: String! name: String! } type Query { user(id: ID!): User } ``` ### Post Service ```graphql # 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: ```javascript 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): ```javascript // 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 ```javascript 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) ```javascript 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) ```javascript 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 ```graphql 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: 1. Add new field 2. Deprecate old field 3. Monitor usage 4. Remove old field in next major version (if removing) ## Testing Strategies ### Schema Validation ```javascript const { buildSchema, validateSchema } = require('graphql'); test('schema is valid', () => { const schema = buildSchema(typeDefs); const errors = validateSchema(schema); expect(errors).toHaveLength(0); }); ``` ### Resolver Testing ```javascript 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 ```javascript 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 ```javascript // ❌ 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 ```graphql # ❌ 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 ```javascript // ❌ 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