Resolvers

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:

  • input is 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.)
  • args is provided as a convenience (same as input[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

  1. Always use createSubscriptionHandler - Wraps your async generator and handles protocol details
  2. Use async generators - Functions declared with async function* that yield values
  3. Same signature as resolvers - (input, args) => AsyncGenerator where input = [source, args, context]
  4. Authentication/Authorization - Check permissions before subscribing
  5. Cleanup - Use try/finally to unsubscribe when client disconnects
  6. Filtering - Apply filters inside the generator to only yield relevant data
  7. Error handling - Throw errors for auth/permission issues before entering the loop
  8. 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 all

Use 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 resolvers

Best Practices

  1. Always validate input - Check args before using them
  2. Use GraphQLError - For consistent error handling
  3. Check authentication - Before accessing protected resources
  4. Use DataLoaders - For relationships to prevent N+1 queries
  5. Handle edge cases - Null checks, empty arrays, etc.
  6. Keep resolvers thin - Move business logic to separate functions
  7. Type your context - For better DX and type safety
  8. Test your resolvers - They're just functions!

Next Steps

← Getting Started | Directives →