Developing GraphQL Resolvers
A GraphQL resolver is a function that returns data for a specific schema field. Resolver quality determines API performance and maintainability: naive implementation generates N+1 queries, incorrect implementation leaks data between users.
Resolver Structure
// schema.graphql
type Query {
user(id: ID!): User
posts(limit: Int = 10, offset: Int = 0): [Post!]!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
comments: [Comment!]!
}
// resolvers.js
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
// context contains: user (from auth), dataloaders, db
if (!context.user) throw new AuthenticationError('Not authenticated')
return context.db.users.findById(id)
},
posts: async (parent, { limit, offset }, context) => {
return context.db.posts.findAll({ limit, offset })
}
},
User: {
// Resolver for posts field on User type
posts: async (parent, args, context) => {
// parent is the User object from parent resolver
// WITHOUT DataLoader this is N+1!
return context.loaders.postsByUserId.load(parent.id)
}
},
Post: {
author: async (parent, args, context) => {
return context.loaders.userById.load(parent.author_id)
},
comments: async (parent, args, context) => {
return context.loaders.commentsByPostId.load(parent.id)
}
}
}
Context and Dependency Injection
// server.js — request context formation
import { ApolloServer } from '@apollo/server'
import { DataloaderRegistry } from './dataloaders'
const server = new ApolloServer({ typeDefs, resolvers })
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => {
// Authentication
const token = req.headers.authorization?.replace('Bearer ', '')
const user = token ? await verifyToken(token) : null
// DataLoaders created per-request (important! prevents cross-user cache)
const loaders = new DataloaderRegistry(db)
return { user, db, loaders, req }
}
}))
Authorization in Resolvers
// Authorization helper functions
function requireAuth(context) {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
}
function requireRole(context, role) {
requireAuth(context)
if (!context.user.roles.includes(role)) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN' }
})
}
}
// Apply in resolvers
const resolvers = {
Mutation: {
deletePost: async (parent, { id }, context) => {
requireAuth(context)
const post = await context.db.posts.findById(id)
if (!post) throw new UserInputError('Post not found')
// Check ownership: only author or admin
if (post.author_id !== context.user.id && !context.user.roles.includes('admin')) {
throw new GraphQLError('Cannot delete others\' posts', {
extensions: { code: 'FORBIDDEN' }
})
}
await context.db.posts.delete(id)
return { success: true }
}
}
}
Error Handling
import { GraphQLError } from 'graphql'
import { ApolloServerErrorCode } from '@apollo/server/errors'
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
requireAuth(context)
// Input validation
if (!input.title?.trim()) {
throw new GraphQLError('Title is required', {
extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT }
})
}
if (input.title.length > 200) {
throw new GraphQLError('Title too long (max 200)', {
extensions: {
code: ApolloServerErrorCode.BAD_USER_INPUT,
field: 'title'
}
})
}
try {
return await context.db.posts.create({
...input,
author_id: context.user.id
})
} catch (err) {
// Don't leak DB details in production
console.error('DB error creating post:', err)
throw new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' }
})
}
}
}
}
// Format errors before sending to client
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Hide technical details in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error' }
}
}
return formattedError
}
})
Subscriptions
// schema.graphql
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
// resolvers
import { PubSub } from 'graphql-subscriptions'
const pubsub = new PubSub()
const resolvers = {
Mutation: {
createPost: async (parent, { input }, context) => {
const post = await context.db.posts.create(input)
// Publish event
pubsub.publish('POST_CREATED', { postCreated: post })
return post
}
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},
commentAdded: {
subscribe: (parent, { postId }) => {
return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
},
resolve: (payload) => payload.commentAdded
}
}
}
Testing Resolvers
// post.resolver.test.js
describe('Post resolvers', () => {
const mockDb = {
posts: {
findById: jest.fn(),
delete: jest.fn()
}
}
const mockContext = (overrides = {}) => ({
user: { id: '1', roles: ['user'] },
db: mockDb,
loaders: { userById: { load: jest.fn() } },
...overrides
})
it('deletePost: owner can delete their post', async () => {
mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '1' })
mockDb.posts.delete.mockResolvedValue(true)
const result = await resolvers.Mutation.deletePost(
null, { id: '1' }, mockContext()
)
expect(result).toEqual({ success: true })
expect(mockDb.posts.delete).toHaveBeenCalledWith('1')
})
it('deletePost: non-owner gets FORBIDDEN', async () => {
mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '99' })
await expect(
resolvers.Mutation.deletePost(null, { id: '1' }, mockContext())
).rejects.toMatchObject({ extensions: { code: 'FORBIDDEN' } })
})
})
Timelines
Developing a set of GraphQL resolvers with authorization, DataLoader integration, and tests—2–4 working days depending on number of types.







