Designing GraphQL APIs from scratch
Scope: Schema design, resolvers, N+1 prevention, pagination, authorization Lines: ~260 Last Updated: 2025-10-18
Activate this skill when:
type User {
id: ID! # Non-null ID
email: String! # Non-null String
age: Int # Nullable Int
isActive: Boolean! # Non-null Boolean
createdAt: DateTime # Custom scalar
}
type Post {
id: ID!
title: String!
author: User! # Relationship
comments: [Comment!]! # Non-null list of non-null items
}
Nullability rules:
! for required fields! on top-level lists: [Item!]! allows empty list, [Item] allows null itemstype Query {
user(id: ID!): User
posts(limit: Int, after: String): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): DeletePostPayload!
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}
type CreatePostPayload {
post: Post
errors: [ValidationError!]!
}
type ValidationError {
field: String!
message: String!
}
Mutation patterns:
type Subscription {
postCreated: Post!
postUpdated(postId: ID!): Post!
commentAdded(postId: ID!): Comment!
}
Implementation (Node.js):
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
}
},
Mutation: {
createPost: async (_, { input }) => {
const post = await db.createPost(input);
pubsub.publish('POST_CREATED', { postCreated: post });
return { post };
}
}
};
Example: Fetching 10 posts with authors
// This resolver is called 10 times (once per post)
Post: {
author: async (post, args, context) => {
// N+1: 1 query for posts + 10 queries for authors
return await db.query('SELECT * FROM users WHERE id = ?', [post.authorId]);
}
}
Result: 11 database queries (inefficient)
DataLoader batches and caches requests within a single request
const DataLoader = require('dataloader');
// Batch function: receives array of IDs, returns array of users
const batchGetUsers = async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id IN (?)',
[userIds]
);
// CRITICAL: Return users in same order as userIds
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || new Error('Not found'));
};
// Create DataLoader in context (per-request)
context: ({ req }) => ({
loaders: {
userLoader: new DataLoader(batchGetUsers)
}
})
// Use in resolver
Post: {
author: async (post, args, context) => {
// DataLoader batches all calls and executes once
return await context.loaders.userLoader.load(post.authorId);
}
}
Result: 2 database queries (1 for posts + 1 batched for authors)
// ✅ CORRECT: Create loaders in context (per-request)
context: ({ req }) => ({
loaders: {
userLoader: new DataLoader(batchGetUsers),
postLoader: new DataLoader(batchGetPosts)
}
})
// ❌ WRONG: Global DataLoader (caches across requests)
const globalUserLoader = new DataLoader(batchGetUsers); // Memory leak!
// ✅ CORRECT: Return array in same order as input
const batchGetUsers = async (ids) => {
const users = await fetchUsers(ids);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || new Error('Not found'));
};
Mutation: {
createPost: async (parent, { input }, context) => {
const errors = [];
if (input.title.length < 5) {
errors.push({ field: 'title', message: 'Title too short' });
}
if (input.content.length < 20) {
errors.push({ field: 'content', message: 'Content too short' });
}
if (errors.length > 0) {
return { post: null, errors };
}
const post = await db.createPost(input);
return { post, errors: [] };
}
}
const { AuthenticationError, UserInputError } = require('apollo-server');
Query: {
user: async (parent, { id }, context) => {
if (!context.user) {
throw new AuthenticationError('Must be logged in');
}
if (!id.match(/^\d+$/)) {
throw new UserInputError('Invalid ID format');
}
return await db.getUserById(id);
}
}
type Query {
posts(first: Int = 10, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Implementation:
Query: {
posts: async (parent, { first = 10, after }) => {
const cursor = after ? decodeCursor(after) : 0;
// Fetch one extra to check hasNextPage
const posts = await db.query(
'SELECT * FROM posts WHERE id > ? ORDER BY id ASC LIMIT ?',
[cursor, first + 1]
);
const hasNextPage = posts.length > first;
const nodes = hasNextPage ? posts.slice(0, -1) : posts;
const edges = nodes.map(post => ({
node: post,
cursor: encodeCursor(post.id)
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.cursor
}
};
}
}
const encodeCursor = (id) => Buffer.from(id.toString()).toString('base64');
const decodeCursor = (cursor) => parseInt(Buffer.from(cursor, 'base64').toString());
Pros: Consistent results, efficient for large datasets Cons: Can't jump to arbitrary page
type Query {
posts(limit: Int = 10, offset: Int = 0): PostPage!
}
type PostPage {
items: [Post!]!
total: Int!
}
Pros: Simple, can jump to page N Cons: Inconsistent if data changes, slow for large offsets
const resolvers = {
Query: {
users: async (parent, args, context) => {
// Query-level auth
if (!context.user?.isAdmin) {
throw new AuthenticationError('Admin only');
}
return await db.getUsers();
}
},
User: {
email: (user, args, context) => {
// Field-level auth (hide email from non-owners)
if (context.user?.id !== user.id && !context.user?.isAdmin) {
return null;
}
return user.email;
}
}
};
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
USER
ADMIN
}
type Query {
posts: [Post!]!
users: [User!]! @auth(requires: ADMIN)
}
type User {
id: ID!
name: String!
email: String! @auth(requires: USER)
}
interface Searchable {
id: ID!
title: String!
}
type Post implements Searchable {
id: ID!
title: String!
content: String!
}
type Video implements Searchable {
id: ID!
title: String!
url: String!
}
type Query {
search(query: String!): [Searchable!]!
}
Resolver:
Searchable: {
__resolveType(obj) {
if (obj.content) return 'Post';
if (obj.url) return 'Video';
return null;
}
}
union SearchResult = Post | Video | User
type Query {
search(query: String!): [SearchResult!]!
}
Client query:
query Search($query: String!) {
search(query: $query) {
__typename
... on Post { title content }
... on Video { title url }
... on User { name email }
}
}
| Anti-Pattern | Problem | Solution | |-------------|---------|----------| | N+1 queries | Not using DataLoader | Batch with DataLoader | | Deep queries | No depth limits | validationRules: [depthLimit(5)] | | Large lists | No pagination | Use cursor pagination | | Scalar mutations | deletePost: Boolean | Return payload object | | Global DataLoader | Caches across requests | Create per-request in context | | No error handling | Throw on validation | Return errors in payload | | Logic in resolvers | Business logic mixed | Resolvers call service layer |
Location: skills/api/graphql-schema-design/resources/REFERENCE.md
Comprehensive 900+ line reference covering:
Location: skills/api/graphql-schema-design/resources/scripts/
``bash ./analyze_schema.py schema.graphql ./analyze_schema.py schema.graphql --json ./analyze_schema.py schema.graphql --min-score 80 ``
``bash ./generate_types.py schema.graphql -o types.ts ./generate_types.py schema.graphql --nullable-by-default ./generate_types.py schema.graphql --json ``
``bash ./benchmark_queries.js -e http://localhost:4000/graphql -q "{ users { id } }" ./benchmark_queries.js -e http://localhost:4000/graphql --query-file query.graphql -c 10 -i 100 ./benchmark_queries.js -e http://localhost:4000/graphql -q "{ posts { title } }" --json ``
Location: skills/api/graphql-schema-design/resources/examples/
All scripts are executable and include --help:
cd skills/api/graphql-schema-design/resources/scripts
./analyze_schema.py --help
./generate_types.py --help
./benchmark_queries.js --help
Examples are runnable with dependencies installed:
# Python server
cd examples/python
pip install strawberry-graphql fastapi uvicorn
python graphql_server.py
# TypeScript server
cd examples/typescript
npm install @apollo/server dataloader
ts-node graphql_server.ts
# TypeScript client
cd examples/typescript
npm install @apollo/client
ts-node graphql_client.ts
rest-api-design.md - Comparing REST vs GraphQLdataloader-optimization.md - Advanced DataLoader patternspostgres-query-optimization.md - Optimizing database queriesapi-authentication.md - JWT and OAuth patternsLast Updated: 2025-10-27 Format Version: 1.0 (Atomic)