Resolvers
Resolvers are the heart of your GraphQL API. In Axolotl, all resolver arguments are fully type-safe, automatically inferred from your schema.
The Resolver Signature
Axolotl resolvers use a consistent signature:
(input, args) => ReturnType;Where:
inputis a tuple:[source, args, context]input[0]= source (parent value from parent resolver)input[1]= args (field arguments - same as second parameter)input[2]= context (request context with auth, db, etc.)
argsis provided as a convenience (same asinput[1])
Basic Example
import { createResolvers } from '@/src/axolotl.js';
export default createResolvers({
Query: {
// Simple resolver returning a string
hello: () => 'World',
// Resolver with arguments
user: async ([, , ctx], { id }) => {
return ctx.db.user.findUnique({ where: { id } });
},
// Resolver accessing context
me: async ([, , ctx]) => {
if (!ctx.userId) {
throw new Error('Not authenticated');
}
return ctx.db.user.findUnique({
where: { id: ctx.userId },
});
},
},
Mutation: {
createPost: async ([, , ctx], { title, content }) => {
if (!ctx.userId) {
throw new Error('Not authenticated');
}
return ctx.db.post.create({
data: {
title,
content,
authorId: ctx.userId,
},
});
},
},
});Destructuring Patterns
Pattern 1: Access Context Only
When you don't need source or args:
createResolvers({
Query: {
me: async ([, , context]) => {
return getUserById(context.userId);
},
},
});Pattern 2: Access Source and Context
For nested resolvers:
createResolvers({
User: {
posts: async ([source, , context]) => {
return context.db.post.findMany({
where: { authorId: source.id },
});
},
},
});Pattern 3: Use Convenience Args Parameter
Most common pattern:
createResolvers({
Mutation: {
createTodo: async ([, , ctx], { title, description }) => {
// args are typed automatically!
return ctx.db.todo.create({
data: { title, description, userId: ctx.userId },
});
},
},
});Pattern 4: Use Underscores for Unused
createResolvers({
Query: {
// Ignore source and args
currentTime: ([_, __]) => new Date().toISOString(),
},
});Real-World Examples
Example 1: CRUD Operations
import { createResolvers } from '@/src/axolotl.js';
import { GraphQLError } from 'graphql';
export default createResolvers({
Query: {
// Get single item
post: async ([, , ctx], { id }) => {
const post = await ctx.db.post.findUnique({
where: { id },
});
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
return post;
},
// List with filtering
posts: async ([, , ctx], { authorId, limit = 10 }) => {
return ctx.db.post.findMany({
where: authorId ? { authorId } : undefined,
take: limit,
orderBy: { createdAt: 'desc' },
});
},
},
Mutation: {
// Create
createPost: async ([, , ctx], { input }) => {
if (!ctx.userId) {
throw new GraphQLError('Unauthorized', {
extensions: { code: 'UNAUTHORIZED' },
});
}
return ctx.db.post.create({
data: {
...input,
authorId: ctx.userId,
},
});
},
// Update
updatePost: async ([, , ctx], { id, input }) => {
// Check ownership
const post = await ctx.db.post.findUnique({
where: { id },
});
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (post.authorId !== ctx.userId) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN' },
});
}
return ctx.db.post.update({
where: { id },
data: input,
});
},
// Delete
deletePost: async ([, , ctx], { id }) => {
const post = await ctx.db.post.findUnique({
where: { id },
});
if (!post) {
return false;
}
if (post.authorId !== ctx.userId) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN' },
});
}
await ctx.db.post.delete({ where: { id } });
return true;
},
},
});Example 2: Authentication & Authorization
import { createResolvers } from '@/src/axolotl.js';
import { GraphQLError } from 'graphql';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
export default createResolvers({
Mutation: {
// Register
register: async ([, , ctx], { username, email, password }) => {
// Validate input
if (password.length < 8) {
throw new GraphQLError('Password must be at least 8 characters', {
extensions: { code: 'VALIDATION_ERROR' },
});
}
// Check if user exists
const existing = await ctx.db.user.findUnique({
where: { email },
});
if (existing) {
throw new GraphQLError('Email already registered', {
extensions: { code: 'CONFLICT' },
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await ctx.db.user.create({
data: {
username,
email,
password: hashedPassword,
},
});
// Generate token
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: '7d' });
return { user, token };
},
// Login
login: async ([, , ctx], { email, password }) => {
const user = await ctx.db.user.findUnique({
where: { email },
});
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHORIZED' },
});
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHORIZED' },
});
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: '7d' });
return { user, token };
},
},
Query: {
// Protected query
me: async ([, , ctx]) => {
if (!ctx.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
}
return ctx.db.user.findUnique({
where: { id: ctx.userId },
});
},
},
});Example 3: Pagination
import { createResolvers } from '@/src/axolotl.js';
export default createResolvers({
Query: {
// Cursor-based pagination
posts: async ([, , ctx], { after, limit = 10 }) => {
const posts = await ctx.db.post.findMany({
take: limit + 1, // Fetch one extra to check if there's more
...(after && {
cursor: { id: after },
skip: 1, // Skip the cursor
}),
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > limit;
const edges = hasNextPage ? posts.slice(0, -1) : posts;
return {
edges: edges.map((post) => ({
node: post,
cursor: post.id,
})),
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.id || null,
},
};
},
// Offset-based pagination
users: async ([, , ctx], { offset = 0, limit = 10 }) => {
const [users, totalCount] = await Promise.all([
ctx.db.user.findMany({
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
}),
ctx.db.user.count(),
]);
return {
edges: users,
pageInfo: {
totalCount,
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0,
},
};
},
},
});Example 4: Nested Resolvers with DataLoader
import { createResolvers } from '@/src/axolotl.js';
export default createResolvers({
Post: {
// Efficiently load author using DataLoader
author: async ([source, , ctx]) => {
return ctx.loaders.userById.load(source.authorId);
},
// Load comments with batching
comments: async ([source, , ctx]) => {
return ctx.loaders.commentsByPostId.load(source.id);
},
// Computed field
excerpt: ([source]) => {
return source.content.substring(0, 100) + '...';
},
// Async computed field
likeCount: async ([source, , ctx]) => {
return ctx.db.like.count({
where: { postId: source.id },
});
},
},
User: {
// Nested resolver
posts: async ([source, , ctx], { limit = 10 }) => {
return ctx.db.post.findMany({
where: { authorId: source.id },
take: limit,
orderBy: { createdAt: 'desc' },
});
},
},
});Subscription Resolvers
CRITICAL: All subscription resolvers MUST use createSubscriptionHandler from @aexol/axolotl-core.
Subscriptions enable real-time updates by streaming data from server to client. In Axolotl, subscriptions are implemented using async generator functions wrapped with createSubscriptionHandler.
Basic Subscription Example
import { createResolvers } from '@/src/axolotl.js';
import { createSubscriptionHandler } from '@aexol/axolotl-core';
import { setTimeout as setTimeout$ } from 'node:timers/promises';
export default createResolvers({
Subscription: {
// Simple countdown that yields values over time
countdown: createSubscriptionHandler(async function* (input, { from }) {
for (let i = from || 10; i >= 0; i--) {
await setTimeout$(1000);
yield i;
}
}),
},
});Schema:
type Subscription {
countdown(from: Int): Int @resolver
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}Usage:
subscription {
countdown(from: 5)
}Event-Based Subscriptions with PubSub
For real-world applications, use a PubSub system to broadcast events:
import { createResolvers } from '@/src/axolotl.js';
import { createSubscriptionHandler } from '@aexol/axolotl-core';
export default createResolvers({
Mutation: {
// Mutation publishes event
createPost: async ([, , ctx], { title, content }) => {
const post = await ctx.db.post.create({
data: { title, content, authorId: ctx.userId },
});
// Publish to subscribers
await ctx.pubsub.publish('POST_CREATED', post);
return post;
},
},
Subscription: {
// Subscription listens for events
postCreated: createSubscriptionHandler(async function* (input) {
const [, , ctx] = input;
const channel = ctx.pubsub.subscribe('POST_CREATED');
try {
for await (const post of channel) {
// Optional: filter based on user permissions
if (await ctx.canViewPost(post)) {
yield post;
}
}
} finally {
// Cleanup when client disconnects
await channel.unsubscribe();
}
}),
// Subscription with filters
postCreatedByAuthor: createSubscriptionHandler(async function* (input, { authorId }) {
const [, , ctx] = input;
const channel = ctx.pubsub.subscribe('POST_CREATED');
try {
for await (const post of channel) {
// Filter by author
if (post.authorId === authorId) {
yield post;
}
}
} finally {
await channel.unsubscribe();
}
}),
},
});Schema:
type Post {
id: String!
title: String!
content: String!
authorId: String!
}
type Subscription {
postCreated: Post @resolver
postCreatedByAuthor(authorId: String!): Post @resolver
}Real-Time Chat Example
import { createResolvers } from '@/src/axolotl.js';
import { createSubscriptionHandler } from '@aexol/axolotl-core';
export default createResolvers({
Mutation: {
sendMessage: async ([, , ctx], { roomId, text }) => {
const message = {
id: crypto.randomUUID(),
roomId,
text,
userId: ctx.userId,
timestamp: new Date().toISOString(),
};
await ctx.db.message.create({ data: message });
await ctx.pubsub.publish(`ROOM_${roomId}`, message);
return message;
},
},
Subscription: {
// Subscribe to messages in a specific room
messageAdded: createSubscriptionHandler(async function* (input, { roomId }) {
const [, , ctx] = input;
if (!ctx.userId) {
throw new Error('Not authenticated');
}
// Check if user has access to room
const hasAccess = await ctx.db.roomMember.findUnique({
where: {
roomId_userId: { roomId, userId: ctx.userId },
},
});
if (!hasAccess) {
throw new Error('Not authorized to access this room');
}
const channel = ctx.pubsub.subscribe(`ROOM_${roomId}`);
try {
for await (const message of channel) {
yield message;
}
} finally {
await channel.unsubscribe();
}
}),
},
});Live Updates Example
import { createResolvers } from '@/src/axolotl.js';
import { createSubscriptionHandler } from '@aexol/axolotl-core';
export default createResolvers({
Subscription: {
// Monitor document changes in real-time
documentUpdated: createSubscriptionHandler(async function* (input, { documentId }) {
const [, , ctx] = input;
// Check permissions
const doc = await ctx.db.document.findUnique({
where: { id: documentId },
});
if (!doc || doc.ownerId !== ctx.userId) {
throw new Error('Not authorized');
}
const channel = ctx.pubsub.subscribe(`DOC_${documentId}`);
try {
for await (const update of channel) {
yield {
documentId,
content: update.content,
updatedBy: update.userId,
timestamp: update.timestamp,
};
}
} finally {
await channel.unsubscribe();
}
}),
// System status monitoring
systemStatus: createSubscriptionHandler(async function* (input) {
const [, , ctx] = input;
// Only admins can subscribe
if (!ctx.isAdmin) {
throw new Error('Admin access required');
}
const channel = ctx.pubsub.subscribe('SYSTEM_STATUS');
try {
for await (const status of channel) {
yield {
cpuUsage: status.cpu,
memoryUsage: status.memory,
activeConnections: status.connections,
timestamp: new Date().toISOString(),
};
}
} finally {
await channel.unsubscribe();
}
}),
},
});Key Points About Subscriptions
- Always use
createSubscriptionHandler- Wraps your async generator and handles protocol details - Use async generators - Functions declared with
async function*thatyieldvalues - Same signature as resolvers -
(input, args) => AsyncGeneratorwhereinput = [source, args, context] - Authentication/Authorization - Check permissions before subscribing
- Cleanup - Use
try/finallyto unsubscribe when client disconnects - Filtering - Apply filters inside the generator to only yield relevant data
- Error handling - Throw errors for auth/permission issues before entering the loop
- Transport - Works with GraphQL Yoga's SSE and WebSocket transports
PubSub Setup
In your context, set up a PubSub system (example with a simple EventEmitter-based pubsub):
// context.ts
import { EventEmitter } from 'events';
const eventEmitter = new EventEmitter();
export const createPubSub = () => ({
publish: async (channel: string, data: any) => {
eventEmitter.emit(channel, data);
},
subscribe: (channel: string) => {
const queue: any[] = [];
const listeners: ((value: any) => void)[] = [];
const handler = (data: any) => {
if (listeners.length > 0) {
const listener = listeners.shift()!;
listener(data);
} else {
queue.push(data);
}
};
eventEmitter.on(channel, handler);
return {
[Symbol.asyncIterator]() {
return {
async next() {
if (queue.length > 0) {
return { value: queue.shift(), done: false };
}
return new Promise((resolve) => {
listeners.push((value) => resolve({ value, done: false }));
});
},
async return() {
eventEmitter.off(channel, handler);
return { value: undefined, done: true };
},
};
},
unsubscribe: async () => {
eventEmitter.off(channel, handler);
},
};
},
});
// axolotl.ts
const pubsub = createPubSub();
type AppContext = YogaInitialContext & {
userId: string | null;
pubsub: ReturnType<typeof createPubSub>;
};
async function buildContext(initial: YogaInitialContext): Promise<AppContext> {
return {
...initial,
userId: /* auth logic */,
pubsub,
};
}For production, use Redis PubSub or a message queue system instead of EventEmitter.
Error Handling
Always use GraphQLError from the graphql package for proper error handling:
import { GraphQLError } from 'graphql';
// Good: Structured errors with codes
throw new GraphQLError('Post not found', {
extensions: {
code: 'NOT_FOUND',
postId: id,
},
});
// Good: Authentication errors
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
// Good: Validation errors
throw new GraphQLError('Invalid input', {
extensions: {
code: 'VALIDATION_ERROR',
field: 'email',
message: 'Email format is invalid',
},
});Performance Tips
1. Use DataLoaders
Prevent N+1 queries by using DataLoaders for relationships:
Post: {
author: ([source, , ctx]) => ctx.loaders.userById.load(source.authorId);
}See Data Loaders for more details.
2. Batch Database Queries
// Bad: Multiple queries
const post = await db.post.findUnique({ where: { id } });
const author = await db.user.findUnique({ where: { id: post.authorId } });
// Good: Single query with include
const post = await db.post.findUnique({
where: { id },
include: { author: true },
});3. Select Only Needed Fields
// Only select fields you need
const user = await db.user.findUnique({
where: { id },
select: {
id: true,
username: true,
email: true,
// Don't select password!
},
});Testing Resolvers
Resolvers are just functions, so they're easy to test:
import { test } from 'node:test';
import assert from 'node:assert';
import resolvers from './resolvers.js';
test('Query.hello returns World', () => {
const result = resolvers.Query.hello([null, {}, {}], {});
assert.equal(result, 'World');
});
test('Mutation.createPost requires authentication', async () => {
const ctx = { userId: null, db: mockDb };
await assert.rejects(
() =>
resolvers.Mutation.createPost([null, {}, ctx], {
input: { title: 'Test', content: 'Content' },
}),
{ message: /Not authenticated/ },
);
});See Testing for comprehensive testing strategies.
Organizing Large Resolver Files
For larger projects, split resolvers by type:
src/
resolvers/
Query/
user.ts
posts.ts
resolvers.ts # Combines Query resolvers
Mutation/
auth.ts
posts.ts
resolvers.ts # Combines Mutation resolvers
Post/
resolvers.ts # Post type resolvers
User/
resolvers.ts # User type resolvers
resolvers.ts # Root file that merges allUse mergeAxolotls to combine:
// src/resolvers/resolvers.ts
import { mergeAxolotls } from '@aexol/axolotl-core';
import QueryResolvers from './Query/resolvers.js';
import MutationResolvers from './Mutation/resolvers.js';
import PostResolvers from './Post/resolvers.js';
import UserResolvers from './User/resolvers.js';
export default mergeAxolotls(QueryResolvers, MutationResolvers, PostResolvers, UserResolvers);Or use the CLI to generate this structure automatically:
npx @aexol/axolotl resolversBest Practices
- Always validate input - Check args before using them
- Use GraphQLError - For consistent error handling
- Check authentication - Before accessing protected resources
- Use DataLoaders - For relationships to prevent N+1 queries
- Handle edge cases - Null checks, empty arrays, etc.
- Keep resolvers thin - Move business logic to separate functions
- Type your context - For better DX and type safety
- Test your resolvers - They're just functions!
Next Steps
- Learn about Directives for cross-cutting concerns
- Set up Data Loaders for performance
- Explore Recipes for common patterns
- Check out Best Practices for project organization