GraphQL API Development
A comprehensive skill for building production-ready GraphQL APIs using graphql-js. Master schema design, type systems, resolvers, queries, mutations, subscriptions, authentication, authorization, caching, testing, and deployment strategies.
When to Use This Skill
Use this skill when:
-
Building a new API that requires flexible data fetching for web or mobile clients
-
Replacing or augmenting REST APIs with more efficient data access patterns
-
Developing APIs for applications with complex, nested data relationships
-
Creating APIs that serve multiple client types (web, mobile, desktop) with different data needs
-
Building real-time applications requiring subscriptions and live updates
-
Designing APIs where clients need to specify exactly what data they need
-
Developing GraphQL servers with Node.js and Express
-
Implementing type-safe APIs with strong schema validation
-
Creating self-documenting APIs with built-in introspection
-
Building microservices that need to be composed into a unified API
When GraphQL Excels Over REST
GraphQL Advantages
-
Precise Data Fetching: Clients request exactly what they need, no over/under-fetching
-
Single Request: Fetch multiple resources in one roundtrip instead of multiple REST endpoints
-
Strongly Typed: Schema defines exact types, enabling validation and tooling
-
Introspection: Self-documenting API with queryable schema
-
Versioning Not Required: Add new fields without breaking existing queries
-
Real-time Updates: Built-in subscription support for live data
-
Nested Resources: Naturally handle complex relationships without N+1 queries
-
Client-Driven: Clients control data shape, reducing backend changes
When to Stick with REST
-
Simple CRUD operations with standard resources
-
File uploads/downloads (GraphQL requires multipart handling)
-
HTTP caching is critical (GraphQL typically uses POST)
-
Team unfamiliar with GraphQL (learning curve)
-
Existing REST infrastructure works well
Core Concepts
The GraphQL Type System
GraphQL's type system is its foundation. Every GraphQL API defines:
-
Scalar Types: Basic data types (String, Int, Float, Boolean, ID)
-
Object Types: Complex types with fields
-
Query Type: Entry point for read operations
-
Mutation Type: Entry point for write operations
-
Subscription Type: Entry point for real-time updates
-
Input Types: Complex inputs for mutations
-
Enums: Fixed set of values
-
Interfaces: Abstract types that objects implement
-
Unions: Types that can be one of several types
-
Non-Null Types: Types that cannot be null
-
List Types: Arrays of types
Schema Definition
Two approaches for defining GraphQL schemas:
- Schema Definition Language (SDL) - Declarative, readable:
type User { id: ID! name: String! email: String! posts: [Post!]! }
type Post { id: ID! title: String! content: String author: User! }
type Query { user(id: ID!): User posts: [Post!]! }
- Programmatic API - Type-safe, programmatic:
const UserType = new GraphQLObjectType({ name: 'User', fields: { id: { type: new GraphQLNonNull(GraphQLID) }, name: { type: new GraphQLNonNull(GraphQLString) }, email: { type: new GraphQLNonNull(GraphQLString) }, posts: { type: new GraphQLList(new GraphQLNonNull(PostType)) } } });
Resolvers
Resolvers are functions that return data for schema fields. Every field can have a resolver:
const resolvers = { Query: { user: (parent, args, context, info) => { return context.db.findUserById(args.id); } }, User: { posts: (user, args, context) => { return context.db.findPostsByAuthorId(user.id); } } };
Resolver Function Signature:
-
parent : The result from the parent resolver
-
args : Arguments passed to the field
-
context : Shared context (database, auth, etc.)
-
info : Field-specific metadata
Queries
Queries fetch data from your API:
query GetUser { user(id: "123") { id name email posts { title content } } }
Mutations
Mutations modify data:
mutation CreatePost { createPost(input: { title: "GraphQL is awesome" content: "Here's why..." authorId: "123" }) { id title author { name } } }
Subscriptions
Subscriptions enable real-time updates:
subscription OnPostCreated { postCreated { id title author { name } } }
Schema Design Patterns
Pattern 1: Input Types for Mutations
Always use input types for complex mutation arguments:
input CreateUserInput { name: String! email: String! age: Int bio: String }
type Mutation { createUser(input: CreateUserInput!): User! }
Why: Easier to extend, better organization, reusable across mutations.
Pattern 2: Interfaces for Shared Fields
Use interfaces when multiple types share fields:
interface Node { id: ID! createdAt: String! updatedAt: String! }
type User implements Node { id: ID! createdAt: String! updatedAt: String! name: String! email: String! }
type Post implements Node { id: ID! createdAt: String! updatedAt: String! title: String! content: String }
Pattern 3: Unions for Polymorphic Returns
Use unions when a field can return different types:
union SearchResult = User | Post | Comment
type Query { search(query: String!): [SearchResult!]! }
Pattern 4: Pagination Patterns
Offset-based pagination:
type Query { posts(offset: Int, limit: Int): PostConnection! }
type PostConnection { items: [Post!]! total: Int! hasMore: Boolean! }
Cursor-based pagination (Relay-style):
type Query { posts(first: Int, after: String): PostConnection! }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! }
type PostEdge { node: Post! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }
Pattern 5: Error Handling
Field-level errors:
type MutationPayload { success: Boolean! message: String user: User errors: [Error!] }
type Error { field: String! message: String! }
Union-based error handling:
union CreateUserResult = User | ValidationError | DatabaseError
type ValidationError { field: String! message: String! }
Pattern 6: Versioning with Directives
Deprecate fields instead of versioning:
type User { name: String! @deprecated(reason: "Use firstName and lastName") firstName: String! lastName: String! }
Query Optimization and Performance
The N+1 Problem
Problem: Fetching nested data causes multiple database queries:
// BAD: N+1 queries const UserType = new GraphQLObjectType({ name: 'User', fields: { posts: { type: new GraphQLList(PostType), resolve: (user) => { // This runs once PER user! return db.getPostsByUserId(user.id); } } } });
// Query for 100 users = 1 query for users + 100 queries for posts = 101 queries
DataLoader Solution
DataLoader batches and caches requests:
import DataLoader from 'dataloader';
// Create DataLoader const postLoader = new DataLoader(async (userIds) => { // Single query for all user IDs const posts = await db.getPostsByUserIds(userIds);
// Group posts by userId const postsByUserId = {}; posts.forEach(post => { if (!postsByUserId[post.authorId]) { postsByUserId[post.authorId] = []; } postsByUserId[post.authorId].push(post); });
// Return in same order as userIds return userIds.map(id => postsByUserId[id] || []); });
// Use in resolver const UserType = new GraphQLObjectType({ name: 'User', fields: { posts: { type: new GraphQLList(PostType), resolve: (user, args, context) => { return context.loaders.postLoader.load(user.id); } } } });
// Add to context const context = { loaders: { postLoader: new DataLoader(batchLoadPosts) } };
Query Complexity Analysis
Limit expensive queries:
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
const complexity = getComplexity({ schema, query, estimators: [ simpleEstimator({ defaultComplexity: 1 }) ] });
if (complexity > 1000) { throw new Error('Query too complex'); }
Depth Limiting
Prevent deeply nested queries:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({ schema, validationRules: [depthLimit(5)] });
Mutations and Input Validation
Mutation Design Pattern
input CreatePostInput { title: String! content: String! authorId: ID! tags: [String!] }
type CreatePostPayload { post: Post errors: [UserError!] success: Boolean! }
type UserError { message: String! field: String }
type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! }
Input Validation
const Mutation = new GraphQLObjectType({ name: 'Mutation', fields: { createPost: { type: CreatePostPayload, args: { input: { type: new GraphQLNonNull(CreatePostInput) } }, resolve: async (_, { input }, context) => { // Validate input const errors = [];
if (input.title.length < 3) {
errors.push({
field: 'title',
message: 'Title must be at least 3 characters'
});
}
if (input.content.length < 10) {
errors.push({
field: 'content',
message: 'Content must be at least 10 characters'
});
}
if (errors.length > 0) {
return { errors, success: false, post: null };
}
// Create post
const post = await context.db.createPost(input);
return { post, errors: [], success: true };
}
}
} });
Subscriptions and Real-time Updates
Setting Up Subscriptions
import { GraphQLObjectType, GraphQLString } from 'graphql'; import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const Subscription = new GraphQLObjectType({
name: 'Subscription',
fields: {
postCreated: {
type: PostType,
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},
messageReceived: {
type: MessageType,
args: {
channelId: { type: new GraphQLNonNull(GraphQLID) }
},
subscribe: (_, { channelId }) => {
return pubsub.asyncIterator([MESSAGE_${channelId}]);
}
}
}
});
Publishing Events
const Mutation = new GraphQLObjectType({ name: 'Mutation', fields: { createPost: { type: PostType, args: { input: { type: new GraphQLNonNull(CreatePostInput) } }, resolve: async (_, { input }, context) => { const post = await context.db.createPost(input);
// Publish to subscribers
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
}
}
} });
WebSocket Server Setup
import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { execute, subscribe } from 'graphql'; import express from 'express';
const app = express(); const httpServer = createServer(app);
// WebSocket server for subscriptions const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer( { schema, execute, subscribe, context: (ctx) => { // Access connection params, headers return { userId: ctx.connectionParams?.userId, db: database }; } }, wsServer );
httpServer.listen(4000);
Authentication and Authorization
Context-Based Authentication
import jwt from 'jsonwebtoken';
// Middleware to extract user const authMiddleware = async (req) => { const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) { return { user: null }; }
try { const decoded = jwt.verify(token, process.env.JWT_SECRET); const user = await db.findUserById(decoded.userId); return { user }; } catch (error) { return { user: null }; } };
// Add to GraphQL context app.all('/graphql', async (req, res) => { const auth = await authMiddleware(req);
createHandler({ schema, context: { user: auth.user, db: database } })(req, res); });
Resolver-Level Authorization
const Query = new GraphQLObjectType({ name: 'Query', fields: { me: { type: UserType, resolve: (, __, context) => { if (!context.user) { throw new Error('Authentication required'); } return context.user; } }, adminData: { type: GraphQLString, resolve: (, __, context) => { if (!context.user) { throw new Error('Authentication required'); }
if (context.user.role !== 'admin') {
throw new Error('Admin access required');
}
return 'Secret admin data';
}
}
} });
Field-Level Authorization
const PostType = new GraphQLObjectType({ name: 'Post', fields: { title: { type: GraphQLString }, content: { type: GraphQLString }, draft: { type: GraphQLBoolean, resolve: (post, args, context) => { // Only author can see draft status if (post.authorId !== context.user?.id) { return null; } return post.draft; } } } });
Directive-Based Authorization
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN MODERATOR }
type Query { publicData: String userData: String @auth(requires: USER) adminData: String @auth(requires: ADMIN) }
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirective(schema, directiveName) { return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new Error('Authentication required');
}
if (context.user.role !== requires) {
throw new Error(`${requires} role required`);
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
}
}); }
Caching Strategies
In-Memory Caching
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5 // 5 minutes });
const Query = new GraphQLObjectType({
name: 'Query',
fields: {
product: {
type: ProductType,
args: { id: { type: new GraphQLNonNull(GraphQLID) } },
resolve: async (_, { id }, context) => {
const cacheKey = product:${id};
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const product = await context.db.findProductById(id);
cache.set(cacheKey, product);
return product;
}
}
} });
Redis Caching
import Redis from 'ioredis';
const redis = new Redis({ host: process.env.REDIS_HOST, port: process.env.REDIS_PORT });
const Query = new GraphQLObjectType({
name: 'Query',
fields: {
user: {
type: UserType,
args: { id: { type: new GraphQLNonNull(GraphQLID) } },
resolve: async (_, { id }, context) => {
const cacheKey = user:${id};
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const user = await context.db.findUserById(id);
// Cache for 10 minutes
await redis.setex(cacheKey, 600, JSON.stringify(user));
return user;
}
}
} });
Cache Invalidation
const Mutation = new GraphQLObjectType({ name: 'Mutation', fields: { updateUser: { type: UserType, args: { id: { type: new GraphQLNonNull(GraphQLID) }, input: { type: new GraphQLNonNull(UpdateUserInput) } }, resolve: async (_, { id, input }, context) => { const user = await context.db.updateUser(id, input);
// Invalidate cache
const cacheKey = `user:${id}`;
await redis.del(cacheKey);
// Also invalidate list caches
await redis.del('users:all');
return user;
}
}
} });
Error Handling
Custom Error Classes
class AuthenticationError extends Error { constructor(message) { super(message); this.name = 'AuthenticationError'; this.extensions = { code: 'UNAUTHENTICATED' }; } }
class ForbiddenError extends Error { constructor(message) { super(message); this.name = 'ForbiddenError'; this.extensions = { code: 'FORBIDDEN' }; } }
class ValidationError extends Error { constructor(message, fields) { super(message); this.name = 'ValidationError'; this.extensions = { code: 'BAD_USER_INPUT', fields }; } }
Error Formatting
import { formatError } from 'graphql';
const customFormatError = (error) => { // Log error for monitoring console.error('GraphQL Error:', { message: error.message, locations: error.locations, path: error.path, extensions: error.extensions });
// Don't expose internal errors to clients if (error.message.startsWith('Database')) { return { message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } }; }
return formatError(error); };
const server = new ApolloServer({ schema, formatError: customFormatError });
Graceful Error Responses
const Query = new GraphQLObjectType({ name: 'Query', fields: { user: { type: UserType, args: { id: { type: new GraphQLNonNull(GraphQLID) } }, resolve: async (_, { id }, context) => { try { const user = await context.db.findUserById(id);
if (!user) {
throw new Error(`User with ID ${id} not found`);
}
return user;
} catch (error) {
// Log error
console.error('Error fetching user:', error);
// Re-throw with user-friendly message
if (error.code === 'ECONNREFUSED') {
throw new Error('Unable to connect to database');
}
throw error;
}
}
}
} });
Testing GraphQL APIs
Unit Testing Resolvers
import { describe, it, expect, jest } from '@jest/globals';
describe('User resolver', () => { it('returns user by ID', async () => { const mockDb = { findUserById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice', email: 'alice@example.com' }) };
const context = { db: mockDb };
const result = await userResolver.resolve(null, { id: '1' }, context);
expect(mockDb.findUserById).toHaveBeenCalledWith('1');
expect(result).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com'
});
});
it('throws error for non-existent user', async () => { const mockDb = { findUserById: jest.fn().mockResolvedValue(null) };
const context = { db: mockDb };
await expect(
userResolver.resolve(null, { id: '999' }, context)
).rejects.toThrow('User with ID 999 not found');
}); });
Integration Testing
import { graphql } from 'graphql'; import { schema } from './schema';
describe('GraphQL Schema', () => {
it('executes user query', async () => {
const query = query { user(id: "1") { id name email } } ;
const result = await graphql({
schema,
source: query,
contextValue: {
db: mockDatabase,
user: null
}
});
expect(result.errors).toBeUndefined();
expect(result.data?.user).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com'
});
});
it('handles authentication errors', async () => {
const query = query { me { id name } } ;
const result = await graphql({
schema,
source: query,
contextValue: {
db: mockDatabase,
user: null
}
});
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toBe('Authentication required');
}); });
Testing with Apollo Server
import { ApolloServer } from '@apollo/server';
const testServer = new ApolloServer({ schema, });
describe('User queries', () => {
it('fetches user successfully', async () => {
const response = await testServer.executeOperation({
query: query GetUser($id: ID!) { user(id: $id) { id name } } ,
variables: { id: '1' }
});
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.user).toMatchObject({
id: '1',
name: expect.any(String)
});
}); });
Production Best Practices
Schema Organization
src/ ├── schema/ │ ├── index.js # Combine all types │ ├── types/ │ │ ├── user.js # User type and resolvers │ │ ├── post.js # Post type and resolvers │ │ └── comment.js # Comment type and resolvers │ ├── queries/ │ │ ├── user.js # User queries │ │ └── post.js # Post queries │ ├── mutations/ │ │ ├── user.js # User mutations │ │ └── post.js # Post mutations │ └── subscriptions/ │ └── post.js # Post subscriptions ├── directives/ │ └── auth.js # Authorization directive ├── utils/ │ ├── loaders.js # DataLoader instances │ └── context.js # Context builder └── server.js # Server setup
Monitoring and Logging
import { ApolloServerPluginLandingPageGraphQLPlayground } from '@apollo/server-plugin-landing-page-graphql-playground';
const server = new ApolloServer({ schema, plugins: [ // Request logging { async requestDidStart(requestContext) { console.log('Request started:', requestContext.request.query);
return {
async didEncounterErrors(ctx) {
console.error('Errors:', ctx.errors);
},
async willSendResponse(ctx) {
console.log('Response sent');
}
};
}
},
// Performance monitoring
{
async requestDidStart() {
const start = Date.now();
return {
async willSendResponse() {
const duration = Date.now() - start;
console.log(`Request duration: ${duration}ms`);
}
};
}
}
] });
Rate Limiting
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per window message: 'Too many requests, please try again later' });
app.use('/graphql', limiter);
Query Whitelisting
const allowedQueries = new Set([ 'query GetUser { user(id: $id) { id name email } }', 'mutation CreatePost { createPost(input: $input) { id title } }' ]);
const validateQuery = (query) => { const normalized = query.replace(/\s+/g, ' ').trim(); if (!allowedQueries.has(normalized)) { throw new Error('Query not whitelisted'); } };
Security Headers
import helmet from 'helmet';
app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'"], } }, crossOriginEmbedderPolicy: false }));
Advanced Patterns
Federation (Microservices)
import { buildSubgraphSchema } from '@apollo/subgraph';
// Users service
const userSchema = buildSubgraphSchema({
typeDefs: type User @key(fields: "id") { id: ID! name: String! email: String! } ,
resolvers: {
User: {
__resolveReference(user) {
return findUserById(user.id);
}
}
}
});
// Posts service const postSchema = buildSubgraphSchema({ typeDefs: ` type Post { id: ID! title: String! author: User! }
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
`, resolvers: { Post: { author(post) { return { __typename: 'User', id: post.authorId }; } }, User: { posts(user) { return findPostsByAuthorId(user.id); } } } });
Custom Scalars
import { GraphQLScalarType, Kind } from 'graphql';
const DateTimeScalar = new GraphQLScalarType({ name: 'DateTime', description: 'ISO-8601 DateTime string',
serialize(value) { // Send to client return value instanceof Date ? value.toISOString() : null; },
parseValue(value) { // From variables return new Date(value); },
parseLiteral(ast) { // From query string if (ast.kind === Kind.STRING) { return new Date(ast.value); } return null; } });
// Use in schema const schema = new GraphQLSchema({ types: [DateTimeScalar], query: new GraphQLObjectType({ name: 'Query', fields: { now: { type: DateTimeScalar, resolve: () => new Date() } } }) });
Batch Operations
const Mutation = new GraphQLObjectType({ name: 'Mutation', fields: { batchCreateUsers: { type: new GraphQLList(UserType), args: { inputs: { type: new GraphQLNonNull( new GraphQLList(new GraphQLNonNull(CreateUserInput)) ) } }, resolve: async (_, { inputs }, context) => { const users = await Promise.all( inputs.map(input => context.db.createUser(input)) ); return users; } } } });
Common Patterns Summary
-
Use Input Types: For all mutations with multiple arguments
-
Implement DataLoader: Solve N+1 queries for nested data
-
Add Pagination: For list fields that can grow unbounded
-
Handle Errors Gracefully: Return user-friendly error messages
-
Validate Inputs: At resolver level before database operations
-
Use Context for Shared State: Database, authentication, loaders
-
Implement Authorization: At resolver or directive level
-
Cache Aggressively: Use Redis or in-memory for frequently accessed data
-
Monitor Performance: Track query complexity and execution time
-
Version with @deprecated: Never break existing queries
-
Test Thoroughly: Unit test resolvers, integration test queries
-
Document Schema: Use descriptions in SDL
-
Use Non-Null Wisely: Only for truly required fields
-
Organize Schema: Split into modules by domain
-
Secure Production: Rate limiting, query whitelisting, depth limiting
Resources and Tools
Essential Libraries
-
graphql-js: Core GraphQL implementation
-
express: Web server framework
-
graphql-http: HTTP handler for GraphQL
-
dataloader: Batching and caching
-
graphql-ws: WebSocket server for subscriptions
-
graphql-scalars: Common custom scalars
-
graphql-tools: Schema manipulation utilities
Development Tools
-
GraphiQL: In-browser GraphQL IDE
-
GraphQL Playground: Advanced GraphQL IDE
-
Apollo Studio: Schema registry and monitoring
-
GraphQL Code Generator: Generate TypeScript types
-
eslint-plugin-graphql: Lint GraphQL queries
Learning Resources
-
GraphQL Official Documentation: https://graphql.org
-
GraphQL.js Repository: https://github.com/graphql/graphql-js
-
How to GraphQL: https://howtographql.com
-
Apollo GraphQL: https://apollographql.com
-
GraphQL Weekly Newsletter: https://graphqlweekly.com
Skill Version: 1.0.0 Last Updated: October 2025 Skill Category: API Development, Backend, GraphQL, Web Development Compatible With: Node.js, Express, TypeScript, JavaScript