GraphQL Patterns - Deep Dive
Advanced GraphQL schema design, resolver optimization, subscriptions, federation, and production best practices.
Schema Design Best Practices
Type System Fundamentals
Scalar Types:
type User {
id: ID! # Unique identifier
email: String! # Required string
age: Int # Optional integer
balance: Float # Optional float
isActive: Boolean! # Required boolean
}
Custom Scalars:
scalar DateTime
scalar URL
scalar EmailAddress
scalar JSON
type Post {
id: ID!
publishedAt: DateTime!
website: URL
content: JSON
}
Implementation (GraphQL Scalars library):
import { DateTimeResolver, URLResolver, EmailAddressResolver, JSONResolver } from 'graphql-scalars';
const resolvers = {
DateTime: DateTimeResolver,
URL: URLResolver,
EmailAddress: EmailAddressResolver,
JSON: JSONResolver,
};
Object Types and Interfaces
Interface (shared fields):
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
email: String!
name: String
}
type Post implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
content: String!
author: User!
}
Query interface implementations:
query {
node(id: "123") {
id
... on User {
email
name
}
... on Post {
title
author { name }
}
}
}
Union Types
union SearchResult = User | Post | Comment
type Query {
search(query: String!): [SearchResult!]!
}
Query with fragments:
query {
search(query: "graphql") {
... on User {
id
name
email
}
... on Post {
id
title
content
}
... on Comment {
id
text
author { name }
}
}
}
Enums
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type User {
id: ID!
role: UserRole!
orders: [Order!]!
}
type Order {
id: ID!
status: OrderStatus!
}
Input Types
input CreateUserInput {
email: String!
name: String
role: UserRole = USER # Default value
}
input UpdateUserInput {
email: String
name: String
role: UserRole
}
input UserFilterInput {
role: UserRole
isActive: Boolean
createdAfter: DateTime
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
type Query {
users(filter: UserFilterInput): [User!]!
}
Nullable vs Non-Null Fields
Design philosophy: Nullable by default, non-null where guaranteed
✅ Good: Defensive nullability
type User {
id: ID! # Always present
email: String! # Required, validated
name: String # Optional (nullable)
profile: Profile # May not exist
posts: [Post!]! # Array never null, posts never null
# Can return empty array []
}
❌ Bad: Over-promising with non-null
type User {
id: ID!
email: String!
lastLoginAt: DateTime! # What if never logged in?
favoritePost: Post! # What if no favorite?
# Breaking change if needs to be nullable later
}
Nullability rules:
String: Nullable stringString!: Non-null string[String]: Nullable array of nullable strings[String!]: Nullable array of non-null strings[String!]!: Non-null array of non-null strings[String]!: Non-null array of nullable strings
Resolver Patterns
Basic Resolvers
const resolvers = {
Query: {
user: async (_parent, { id }, context) => {
return context.db.users.findUnique({ where: { id } });
},
users: async (_parent, { filter }, context) => {
return context.db.users.findMany({ where: filter });
},
},
User: {
// Field resolver (computed field)
fullName: (user) => `${user.firstName} ${user.lastName}`,
// Async field resolver (database fetch)
posts: async (user, _args, context) => {
return context.db.posts.findMany({ where: { authorId: user.id } });
},
},
Mutation: {
createUser: async (_parent, { input }, context) => {
return context.db.users.create({ data: input });
},
},
};
DataLoader (N+1 Solution)
Problem: N+1 query pattern
// BAD: Triggers separate query for each user's posts
const resolvers = {
User: {
posts: async (user, _args, context) => {
// Called once PER user in result set
return context.db.posts.findMany({ where: { authorId: user.id } });
},
},
};
// Query for 100 users = 1 query + 100 queries for posts = 101 queries!
Solution: DataLoader batches requests
import DataLoader from 'dataloader';
// Create loader in context (per-request)
const createLoaders = (db) => ({
postsLoader: new DataLoader(async (userIds: string[]) => {
// Single query for all users
const posts = await db.posts.findMany({
where: { authorId: { in: userIds } },
});
// Group by userId
const postsByUser = userIds.map(userId =>
posts.filter(post => post.authorId === userId)
);
return postsByUser;
}),
userLoader: new DataLoader(async (userIds: string[]) => {
const users = await db.users.findMany({
where: { id: { in: userIds } },
});
// Maintain order matching userIds
return userIds.map(id => users.find(user => user.id === id));
}),
});
// Context setup
const context = ({ req }) => ({
db: prisma,
loaders: createLoaders(prisma),
userId: req.userId,
});
// Resolver using DataLoader
const resolvers = {
User: {
posts: (user, _args, context) => {
return context.loaders.postsLoader.load(user.id);
},
},
Post: {
author: (post, _args, context) => {
return context.loaders.userLoader.load(post.authorId);
},
},
};
// Query for 100 users = 1 query + 1 batched query for posts = 2 queries!
Resolver Chain and Parent
const resolvers = {
Query: {
user: async (_parent, { id }, context) => {
// Returns user object passed to User resolvers
return context.db.users.findUnique({ where: { id } });
},
},
User: {
// parent is the user object from Query.user
fullName: (parent) => `${parent.firstName} ${parent.lastName}`,
// Can access parent fields
posts: async (parent, _args, context) => {
return context.db.posts.findMany({
where: { authorId: parent.id },
});
},
// Nested resolver chain
profile: async (parent, _args, context) => {
// Returns profile object passed to Profile resolvers
return context.db.profiles.findUnique({
where: { userId: parent.id },
});
},
},
Profile: {
// parent is the profile object from User.profile
avatarUrl: (parent) => {
return parent.avatar
? `https://cdn.example.com/${parent.avatar}`
: 'https://cdn.example.com/default-avatar.png';
},
},
};
Error Handling
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_parent, { id }, context) => {
const user = await context.db.users.findUnique({ where: { id } });
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
argumentName: 'id',
},
});
}
return user;
},
},
Mutation: {
createUser: async (_parent, { input }, context) => {
// Validation error
if (!input.email.includes('@')) {
throw new GraphQLError('Invalid email format', {
extensions: {
code: 'INVALID_INPUT',
field: 'email',
},
});
}
try {
return await context.db.users.create({ data: input });
} catch (error) {
// Database unique constraint
if (error.code === 'P2002') {
throw new GraphQLError('Email already exists', {
extensions: {
code: 'DUPLICATE_EMAIL',
field: 'email',
},
});
}
// Unexpected error
throw new GraphQLError('Failed to create user', {
extensions: {
code: 'INTERNAL_ERROR',
},
});
}
},
},
};
Error response:
{
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"argumentName": "id"
}
}
],
"data": {
"user": null
}
}
Pagination Patterns
Offset Pagination (Simple)
type Query {
users(limit: Int = 10, offset: Int = 0): UserConnection!
}
type UserConnection {
nodes: [User!]!
totalCount: Int!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
Resolver:
const resolvers = {
Query: {
users: async (_parent, { limit, offset }, context) => {
const [nodes, totalCount] = await Promise.all([
context.db.users.findMany({ take: limit, skip: offset }),
context.db.users.count(),
]);
return {
nodes,
totalCount,
pageInfo: {
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0,
},
};
},
},
};
Cursor Pagination (Relay Connection)
Schema:
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
nodes: [User!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Resolver (using graphql-relay):
import { connectionFromArraySlice, cursorToOffset } from 'graphql-relay';
const resolvers = {
Query: {
users: async (_parent, args, context) => {
const { first, after, last, before } = args;
// Decode cursors to offsets
const afterOffset = after ? cursorToOffset(after) + 1 : 0;
const beforeOffset = before ? cursorToOffset(before) : undefined;
// Calculate limit and offset
const limit = first || last || 10;
const offset = afterOffset;
// Fetch data
const [users, totalCount] = await Promise.all([
context.db.users.findMany({
take: limit + 1, // Fetch one extra to check hasNextPage
skip: offset,
}),
context.db.users.count(),
]);
// Build connection
const hasNextPage = users.length > limit;
const nodes = hasNextPage ? users.slice(0, -1) : users;
return connectionFromArraySlice(nodes, args, {
sliceStart: offset,
arrayLength: totalCount,
});
},
},
};
Query:
query {
users(first: 10, after: "cursor123") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Mutations
Input Object Pattern
✅ Good: Single input object
input CreatePostInput {
title: String!
content: String!
tags: [String!]
publishedAt: DateTime
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
❌ Bad: Multiple arguments
type Mutation {
createPost(
title: String!
content: String!
tags: [String!]
publishedAt: DateTime
): Post!
}
Payload Object Pattern
Include user errors and edge for optimistic updates:
type CreatePostPayload {
post: Post
postEdge: PostEdge
errors: [UserError!]
clientMutationId: String
}
type UserError {
message: String!
field: String
code: String!
}
type PostEdge {
cursor: String!
node: Post!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
Resolver:
const resolvers = {
Mutation: {
createPost: async (_parent, { input }, context) => {
// Validation
const errors = [];
if (input.title.length < 3) {
errors.push({
message: 'Title must be at least 3 characters',
field: 'title',
code: 'TITLE_TOO_SHORT',
});
}
if (errors.length > 0) {
return { post: null, postEdge: null, errors };
}
// Create post
const post = await context.db.posts.create({
data: {
...input,
authorId: context.userId,
},
});
return {
post,
postEdge: {
cursor: encodeCursor(post.id),
node: post,
},
errors: [],
};
},
},
};
Optimistic Updates (Client)
const [createPost] = useMutation(CREATE_POST, {
optimisticResponse: {
createPost: {
__typename: 'CreatePostPayload',
post: {
__typename: 'Post',
id: 'temp-id',
title: variables.input.title,
content: variables.input.content,
createdAt: new Date().toISOString(),
},
errors: [],
},
},
update: (cache, { data }) => {
// Update cache with new post
const existing = cache.readQuery({ query: GET_POSTS });
cache.writeQuery({
query: GET_POSTS,
data: {
posts: {
...existing.posts,
edges: [
data.createPost.postEdge,
...existing.posts.edges,
],
},
},
});
},
});
Subscriptions (Real-Time)
Schema
type Subscription {
postAdded: Post!
postUpdated(id: ID!): Post!
commentAdded(postId: ID!): Comment!
userStatusChanged(userId: ID!): UserStatus!
}
type UserStatus {
userId: ID!
isOnline: Boolean!
lastSeen: DateTime
}
Resolver (with PubSub)
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = {
Subscription: {
postAdded: {
subscribe: () => pubsub.asyncIterator(['POST_ADDED']),
},
postUpdated: {
subscribe: (_parent, { id }) => {
return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
},
},
commentAdded: {
subscribe: (_parent, { postId }, context) => {
// Auth check
if (!context.userId) {
throw new GraphQLError('Unauthorized');
}
return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
},
// Optional resolve function
resolve: (payload) => payload.comment,
},
},
Mutation: {
createPost: async (_parent, { input }, context) => {
const post = await context.db.posts.create({ data: input });
// Trigger subscription
pubsub.publish('POST_ADDED', { postAdded: post });
return { post };
},
updatePost: async (_parent, { id, input }, context) => {
const post = await context.db.posts.update({
where: { id },
data: input,
});
pubsub.publish(`POST_UPDATED_${id}`, { postUpdated: post });
return { post };
},
},
};
Redis PubSub (Production)
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
retryStrategy: (times) => Math.min(times * 50, 2000),
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
WebSocket Setup (Apollo Server)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);
// WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer({ schema }, wsServer);
// Apollo Server
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);
Client Subscription (Apollo Client)
import { useSubscription } from '@apollo/client';
const POST_ADDED = gql`
subscription OnPostAdded {
postAdded {
id
title
author { name }
}
}
`;
function RecentPosts() {
const { data, loading } = useSubscription(POST_ADDED, {
onData: ({ client, data }) => {
// Update cache
client.cache.modify({
fields: {
posts: (existing) => ({
...existing,
edges: [
{ node: data.postAdded, cursor: '' },
...existing.edges,
],
}),
},
});
},
});
return <div>New post: {data?.postAdded.title}</div>;
}
Directives
Built-in Directives
query GetUser($includeEmail: Boolean!, $skipProfile: Boolean!) {
user(id: "123") {
id
name
email @include(if: $includeEmail)
profile @skip(if: $skipProfile) {
bio
}
}
}
Custom Directives
Schema:
directive @auth(requires: UserRole!) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION
directive @length(min: Int, max: Int) on INPUT_FIELD_DEFINITION
type Query {
users: [User!]! @auth(requires: ADMIN)
me: User!
}
type User {
id: ID!
email: String! @deprecated(reason: "Use contactEmail instead")
contactEmail: String!
}
input CreateUserInput {
name: String! @length(min: 3, max: 50)
email: String!
}
Implementation (using graphql-tools):
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 || context.user.role !== requires) {
throw new GraphQLError('Unauthorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirective(schema, 'auth');
Performance Optimization
Query Complexity Analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 10,
introspectionListFactor: 10,
onCost: (cost) => {
console.log('Query cost:', cost);
},
}),
],
});
Custom cost per field:
const typeDefs = gql`
type Query {
users: [User!]! @cost(complexity: 100)
expensiveAnalytics: Analytics! @cost(complexity: 500)
}
`;
Query Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(10)],
});
Persisted Queries
Benefits: Reduce payload size, prevent arbitrary queries in production
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
schema,
persistedQueries: {
cache: new Map(), // Use Redis in production
},
allowBatchedHttpRequests: false,
introspection: process.env.NODE_ENV !== 'production',
});
Client sends hash:
POST /graphql
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123..."
}
}
}
Response Caching
HTTP caching:
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
schema,
plugins: [
responseCachePlugin({
sessionId: (context) => context.user?.id || null,
}),
],
});
Cache hints:
type Query {
user(id: ID!): User @cacheControl(maxAge: 60, scope: PRIVATE)
publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
}
Schema Stitching and Federation
Apollo Federation
Service 1 (Users):
type User @key(fields: "id") {
id: ID!
email: String!
name: String
}
extend type Query {
user(id: ID!): User
}
Service 2 (Posts):
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
extend type Query {
posts: [Post!]!
}
Gateway:
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
import { ApolloServer } from '@apollo/server';
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://localhost:4001/graphql' },
{ name: 'posts', url: 'http://localhost:4002/graphql' },
],
}),
});
const server = new ApolloServer({ gateway });
Testing
Unit Testing Resolvers
import { resolvers } from './resolvers';
describe('User Resolvers', () => {
it('fetches user by ID', async () => {
const mockDb = {
users: {
findUnique: jest.fn().mockResolvedValue({
id: '123',
email: 'test@example.com',
}),
},
};
const result = await resolvers.Query.user(
{},
{ id: '123' },
{ db: mockDb }
);
expect(mockDb.users.findUnique).toHaveBeenCalledWith({
where: { id: '123' },
});
expect(result).toEqual({
id: '123',
email: 'test@example.com',
});
});
});
Integration Testing
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({ typeDefs, resolvers });
it('creates a user', async () => {
const response = await server.executeOperation({
query: `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user {
id
email
}
errors {
message
}
}
}
`,
variables: {
input: {
email: 'test@example.com',
name: 'Test User',
},
},
});
expect(response.body.kind).toBe('single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.createUser.user).toHaveProperty('id');
});
Best Practices Summary
✅ Nullable by default: Only use non-null (!) when guaranteed
✅ Use DataLoader: Batch queries to prevent N+1
✅ Pagination: Use cursor-based for large lists
✅ Input objects: Group mutation arguments
✅ Payload objects: Return errors with data
✅ Custom scalars: Use DateTime, Email, URL, JSON
✅ Interfaces: Share common fields across types
✅ Query complexity: Limit expensive queries
✅ Persisted queries: Reduce payload, improve security
✅ Error handling: Return specific error codes and fields
❌ Avoid over-fetching: Let clients request exact fields ❌ Don't expose internal IDs: Use opaque IDs or UUIDs ❌ Don't ignore N+1: Always use DataLoader for relationships ❌ Don't make everything non-null: Breaks schema evolution ❌ Don't use query strings for mutations: Use input objects ❌ Don't skip authorization: Check permissions in resolvers