DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Designing DataLoader with Claude Code: Solve GraphQL N+1 Problem with Batching

Introduction

Ignoring N+1 in GraphQL sends 101 DB queries to return 100 Posts. Convert to batch processing with DataLoader to reduce to 1 query. Generate designs with Claude Code.


CLAUDE.md DataLoader Rules

## DataLoader Design Rules

### Required Rules
- No direct DB access from GraphQL resolvers (only via DataLoader)
- DataLoader = 1 instance per request (injected into context)
- Max batch size: 100 (IN clause limit)

### Cache Strategy
- DataLoader's default cache is request-scoped
- After mutations: loader.clearAll() to clear cache
- Security: don't cache data from different users

### Batch Function Implementation
- Return results in same order as input IDs (DataLoader requirement)
- Return null for non-existent IDs (don't throw)
- Use groupBy / Map for efficient related entity mapping
Enter fullscreen mode Exit fullscreen mode

Generated DataLoader Implementation

// src/graphql/dataLoaders.ts
import DataLoader from 'dataloader';

export function createDataLoaders() {
  // Basic: ID batch loader
  const userLoader = new DataLoader<string, User | null>(
    async (ids: readonly string[]) => {
      const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id) ?? null); // Preserve order
    },
    { maxBatchSize: 100 }
  );

  // One-to-many: posts by authorId
  const postsByAuthorLoader = new DataLoader<string, Post[]>(
    async (authorIds: readonly string[]) => {
      const posts = await prisma.post.findMany({
        where: { authorId: { in: [...authorIds] } },
        orderBy: { createdAt: 'desc' },
      });

      const postsByAuthor = new Map<string, Post[]>();
      for (const authorId of authorIds) postsByAuthor.set(authorId, []);
      for (const post of posts) postsByAuthor.get(post.authorId)?.push(post);

      return authorIds.map(id => postsByAuthor.get(id) ?? []);
    }
  );

  // Aggregate: comment count per postId
  const commentCountLoader = new DataLoader<string, number>(
    async (postIds: readonly string[]) => {
      const counts = await prisma.comment.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: { id: true },
      });
      const countMap = new Map(counts.map(c => [c.postId, c._count.id]));
      return postIds.map(id => countMap.get(id) ?? 0);
    }
  );

  return { userLoader, postsByAuthorLoader, commentCountLoader };
}
Enter fullscreen mode Exit fullscreen mode

Inject into Context and Use in Resolvers

// 1 DataLoader instance per request (for batching within request)
export function createContext({ req }: { req: Request }): Context {
  return {
    user: req.user ?? null,
    loaders: createDataLoaders(),
  };
}

export const resolvers = {
  Post: {
    // BAD: direct query (N+1 problem)
    // author: (post) => prisma.user.findUnique({ where: { id: post.authorId } }),

    // GOOD: batch processing via DataLoader
    author: (post: Post, _: unknown, ctx: Context) => {
      return ctx.loaders.userLoader.load(post.authorId);
    },

    commentCount: (post: Post, _: unknown, ctx: Context) => {
      return ctx.loaders.commentCountLoader.load(post.id);
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Clear Cache After Mutations

const resolvers = {
  Mutation: {
    updateUser: async (_: unknown, { id, input }: UpdateUserArgs, ctx: Context) => {
      const user = await prisma.user.update({ where: { id }, data: input });
      ctx.loaders.userLoader.clear(id); // Clear cache after update
      return user;
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Summary

Design DataLoader with Claude Code:

  1. CLAUDE.md — prohibit direct DB access in resolvers, request scope, batch size limit
  2. Preserve ID order — ensure accurate DataLoader mapping (use Map)
  3. One-to-many with groupBy — efficiently fetch related entities
  4. clear() after mutations — explicitly invalidate cache

Review DataLoader/GraphQL designs with **Code Review Pack (¥980)* using /code-review at prompt-works.jp*

myouga (@myougatheaxo) — Axolotl VTuber.

Top comments (0)