GraphQL API Design Principles
A well-designed GraphQL API is self-documenting, efficient, and a joy to use. These are the patterns that separate good APIs from great ones.
Schema-First Design
Define your schema before writing resolvers. This forces you to think about the consumer experience.
# schema.graphql
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: PaginationInput): UserConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
type Subscription {
userCreated: User!
postPublished(authorId: ID): Post!
}
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
createdAt: DateTime!
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Solving N+1 with DataLoader
The N+1 problem: fetching 10 posts → 10 separate author queries.
import DataLoader from 'dataloader';
// Create DataLoader per request (NOT globally — prevents cross-request caching)
function createLoaders(db) {
return {
userById: new DataLoader(async (ids) => {
const users = await db.users.findMany({
where: { id: { in: ids } },
});
// Must return array in same order as ids
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? null);
}),
postsByAuthorId: new DataLoader(async (authorIds) => {
const posts = await db.posts.findMany({
where: { authorId: { in: authorIds } },
});
const grouped = authorIds.map(id =>
posts.filter(p => p.authorId === id)
);
return grouped;
}),
};
}
// In Apollo Server context
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
db,
loaders: createLoaders(db),
userId: getAuthUserId(req),
}),
});
Resolver Pattern
const resolvers = {
Query: {
user: async (_, { id }, { loaders }) => {
return loaders.userById.load(id);
},
users: async (_, { filter, pagination }, { db }) => {
const { first = 20, after } = pagination ?? {};
const cursor = after ? decodeCursor(after) : null;
const items = await db.users.findMany({
where: buildFilter(filter),
take: first + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = items.length > first;
const nodes = hasNextPage ? items.slice(0, -1) : items;
return {
edges: nodes.map(node => ({
node,
cursor: encodeCursor(node.id),
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1).id) : null,
},
totalCount: await db.users.count({ where: buildFilter(filter) }),
};
},
},
User: {
posts: async (user, { first = 10 }, { loaders }) => {
return loaders.postsByAuthorId.load(user.id);
},
},
Mutation: {
createUser: async (_, { input }, { db, userId }) => {
if (!userId) throw new AuthenticationError('Not authenticated');
const user = await db.users.create({ data: input });
return { user, success: true };
},
},
};
Error Handling Patterns
import { GraphQLError } from 'graphql';
// Domain-specific errors
class NotFoundError extends GraphQLError {
constructor(resource, id) {
super(`${resource} ${id} not found`, {
extensions: { code: 'NOT_FOUND', resource, id },
});
}
}
class AuthenticationError extends GraphQLError {
constructor(message = 'Not authenticated') {
super(message, {
extensions: { code: 'UNAUTHENTICATED' },
});
}
}
class ForbiddenError extends GraphQLError {
constructor(message = 'Forbidden') {
super(message, {
extensions: { code: 'FORBIDDEN' },
});
}
}
// Global error formatter
const server = new ApolloServer({
formatError: (formattedError, error) => {
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new GraphQLError('Internal server error');
}
}
return formattedError;
},
});
Subscriptions with WebSockets
import { createClient } from 'graphql-ws';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
const wsServer = new WebSocketServer({ port: 4001 });
const serverCleanup = useServer({ schema }, wsServer);
// Resolver
const resolvers = {
Subscription: {
postPublished: {
subscribe: async function* (_, { authorId }, { pubsub }) {
for await (const event of pubsub.subscribe('POST_PUBLISHED')) {
if (!authorId || event.authorId === authorId) {
yield { postPublished: event };
}
}
},
},
},
};
Rate Limiting with Cost Analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost, document) => {
console.log('Query cost:', cost);
},
createError: (max, actual) =>
new GraphQLError(`Query cost ${actual} exceeds maximum ${max}`),
}),
],
});
Persisted Queries
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })],
cache: new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 30 }),
persistedQueries: {
cache: new InMemoryLRUCache(),
},
});
Schema Design Rules
- Nullable by default, non-null when certain — avoid breaking clients
- Use interfaces for polymorphism —
interface Node { id: ID! } - Input types for mutations — not individual args
- Payload types for mutations — allows adding fields later
- Relay pagination — cursors > offset for large datasets
- Enums for finite sets —
enum PostStatus { DRAFT PUBLISHED ARCHIVED }