bookworm-smart-assistant/skills/graphql-architect/references/resolvers.md

10 KiB

GraphQL Resolvers

Basic Resolver Pattern

import { GraphQLResolveInfo } from 'graphql';

// Resolver signature
type Resolver<TSource, TArgs, TContext, TReturn> = (
  parent: TSource,
  args: TArgs,
  context: TContext,
  info: GraphQLResolveInfo
) => Promise<TReturn> | TReturn;

// User resolvers
const resolvers = {
  Query: {
    user: async (
      parent,
      args: { id: string },
      context: Context
    ): Promise<User | null> => {
      return context.dataSources.users.findById(args.id);
    },

    users: async (
      parent,
      args: { first?: number; after?: string },
      context: Context
    ): Promise<User[]> => {
      return context.dataSources.users.findAll(args);
    },
  },

  Mutation: {
    createUser: async (
      parent,
      args: { input: CreateUserInput },
      context: Context
    ): Promise<User> => {
      if (!context.user) {
        throw new Error('Unauthorized');
      }
      return context.dataSources.users.create(args.input);
    },
  },
};

Context Setup

import { Request } from 'express';
import { User } from './models';
import { DataSources } from './datasources';

export interface Context {
  user: User | null;
  dataSources: DataSources;
  loaders: Loaders;
  req: Request;
  authToken: string | null;
}

// Apollo Server context
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }): Promise<Context> => {
    // Extract auth token
    const authToken = req.headers.authorization?.replace('Bearer ', '') || null;

    // Verify user
    let user: User | null = null;
    if (authToken) {
      user = await verifyToken(authToken);
    }

    // Create data sources
    const dataSources = new DataSources({
      db: prisma,
      redis: redisClient,
    });

    // Create DataLoaders
    const loaders = createLoaders(dataSources);

    return {
      user,
      dataSources,
      loaders,
      req,
      authToken,
    };
  },
});

DataLoader for N+1 Prevention

import DataLoader from 'dataloader';

// Create loaders
export function createLoaders(dataSources: DataSources): Loaders {
  return {
    userLoader: new DataLoader<string, User>(
      async (ids: readonly string[]) => {
        const users = await dataSources.users.findByIds([...ids]);
        // Return in same order as input ids
        return ids.map(id => users.find(u => u.id === id) || null);
      },
      {
        cache: true,
        batchScheduleFn: (callback) => setTimeout(callback, 10),
      }
    ),

    postsByAuthorLoader: new DataLoader<string, Post[]>(
      async (authorIds: readonly string[]) => {
        const posts = await dataSources.posts.findByAuthorIds([...authorIds]);
        // Group by author
        return authorIds.map(authorId =>
          posts.filter(p => p.authorId === authorId)
        );
      }
    ),
  };
}

// Field resolver using DataLoader
const resolvers = {
  Post: {
    author: async (
      post: Post,
      args,
      context: Context
    ): Promise<User> => {
      // Batches multiple requests into single DB query
      return context.loaders.userLoader.load(post.authorId);
    },
  },

  User: {
    posts: async (
      user: User,
      args,
      context: Context
    ): Promise<Post[]> => {
      return context.loaders.postsByAuthorLoader.load(user.id);
    },
  },
};

Field Resolvers

const resolvers = {
  User: {
    // Simple field resolver
    fullName: (user: User): string => {
      return `${user.firstName} ${user.lastName}`;
    },

    // Async field resolver with DB query
    postCount: async (
      user: User,
      args,
      context: Context
    ): Promise<number> => {
      return context.dataSources.posts.countByAuthor(user.id);
    },

    // Field resolver with arguments
    posts: async (
      user: User,
      args: { first?: number; status?: PostStatus },
      context: Context
    ): Promise<Post[]> => {
      return context.dataSources.posts.findByAuthor(user.id, {
        limit: args.first,
        status: args.status,
      });
    },

    // Nullable field with conditional logic
    profile: async (
      user: User,
      args,
      context: Context
    ): Promise<Profile | null> => {
      if (!user.hasProfile) return null;
      return context.loaders.profileLoader.load(user.id);
    },
  },
};

Interface Resolvers

const resolvers = {
  // Interface type resolver
  Searchable: {
    __resolveType(obj: Article | Video | Podcast): string {
      if ('content' in obj) return 'Article';
      if ('duration' in obj) return 'Video';
      if ('audioUrl' in obj) return 'Podcast';
      throw new Error('Unknown Searchable type');
    },
  },

  // Common interface fields (shared resolvers)
  Article: {
    id: (article: Article) => article.id,
    title: (article: Article) => article.title,
    description: (article: Article) => article.description,
  },

  Video: {
    id: (video: Video) => video.id,
    title: (video: Video) => video.title,
    description: (video: Video) => video.description,
  },
};

Union Resolvers

const resolvers = {
  // Union type resolver
  SearchResult: {
    __resolveType(
      obj: Article | Video | Podcast,
      context: Context,
      info: GraphQLResolveInfo
    ): string {
      if ('content' in obj) return 'Article';
      if ('duration' in obj && 'url' in obj) return 'Video';
      if ('audioUrl' in obj) return 'Podcast';
      throw new Error('Unknown SearchResult type');
    },
  },

  Query: {
    searchContent: async (
      parent,
      args: { query: string },
      context: Context
    ): Promise<(Article | Video | Podcast)[]> => {
      // Return mixed array of different types
      const [articles, videos, podcasts] = await Promise.all([
        context.dataSources.articles.search(args.query),
        context.dataSources.videos.search(args.query),
        context.dataSources.podcasts.search(args.query),
      ]);
      return [...articles, ...videos, ...podcasts];
    },
  },
};

Error Handling

import { GraphQLError } from 'graphql';
import { ApolloServerErrorCode } from '@apollo/server/errors';

const resolvers = {
  Query: {
    user: async (
      parent,
      args: { id: string },
      context: Context
    ): Promise<User> => {
      const user = await context.dataSources.users.findById(args.id);

      if (!user) {
        throw new GraphQLError('User not found', {
          extensions: {
            code: 'USER_NOT_FOUND',
            http: { status: 404 },
            userId: args.id,
          },
        });
      }

      return user;
    },
  },

  Mutation: {
    updateUser: async (
      parent,
      args: { id: string; input: UpdateUserInput },
      context: Context
    ): Promise<User> => {
      // Check authentication
      if (!context.user) {
        throw new GraphQLError('Unauthorized', {
          extensions: {
            code: ApolloServerErrorCode.UNAUTHENTICATED,
            http: { status: 401 },
          },
        });
      }

      // Check authorization
      if (context.user.id !== args.id && !context.user.isAdmin) {
        throw new GraphQLError('Forbidden', {
          extensions: {
            code: ApolloServerErrorCode.FORBIDDEN,
            http: { status: 403 },
          },
        });
      }

      try {
        return await context.dataSources.users.update(args.id, args.input);
      } catch (error) {
        throw new GraphQLError('Failed to update user', {
          extensions: {
            code: 'UPDATE_FAILED',
            originalError: error,
          },
        });
      }
    },
  },
};

Pagination Resolvers

import { encodeCursor, decodeCursor } from './utils/cursor';

const resolvers = {
  Query: {
    posts: async (
      parent,
      args: { first?: number; after?: string },
      context: Context
    ): Promise<PostConnection> => {
      const limit = Math.min(args.first || 10, 100);
      const cursor = args.after ? decodeCursor(args.after) : null;

      // Fetch one extra to determine hasNextPage
      const posts = await context.dataSources.posts.findAll({
        limit: limit + 1,
        cursor,
      });

      const hasNextPage = posts.length > limit;
      const edges = posts.slice(0, limit).map(post => ({
        node: post,
        cursor: encodeCursor(post.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!cursor,
          startCursor: edges[0]?.cursor || null,
          endCursor: edges[edges.length - 1]?.cursor || null,
        },
        totalCount: await context.dataSources.posts.count(),
      };
    },
  },
};

Batching Patterns

// Batch multiple queries
class UserDataSource {
  private db: PrismaClient;

  async findByIds(ids: string[]): Promise<User[]> {
    // Single query instead of N queries
    return this.db.user.findMany({
      where: { id: { in: ids } },
    });
  }

  async findByEmails(emails: string[]): Promise<User[]> {
    return this.db.user.findMany({
      where: { email: { in: emails } },
    });
  }
}

// DataLoader with caching
const userLoader = new DataLoader<string, User>(
  async (ids) => {
    console.log('Batching user queries:', ids.length);
    const users = await dataSources.users.findByIds([...ids]);
    return ids.map(id => users.find(u => u.id === id) || null);
  },
  {
    cache: true,
    maxBatchSize: 100,
    batchScheduleFn: (callback) => setTimeout(callback, 10),
  }
);

Resolver Best Practices

  1. Use DataLoader: Always batch and cache database queries
  2. Avoid N+1: Use DataLoader for all foreign key relationships
  3. Type Safety: Use TypeScript for resolver type safety
  4. Error Handling: Throw GraphQLError with proper codes and extensions
  5. Authorization: Check permissions in resolvers, not data sources
  6. Pagination: Implement cursor-based pagination for lists
  7. Context: Keep context creation lightweight
  8. Caching: Use DataLoader caching per request
  9. Batching: Batch queries with DataLoader or in data source
  10. Testing: Unit test resolvers with mocked context