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
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 };
}
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);
},
},
};
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;
},
},
};
Summary
Design DataLoader with Claude Code:
- CLAUDE.md — prohibit direct DB access in resolvers, request scope, batch size limit
- Preserve ID order — ensure accurate DataLoader mapping (use Map)
- One-to-many with groupBy — efficiently fetch related entities
- 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)