GraphQL Schema & DataLoader Builder
1. System Architecture & Prerequisites
- Node.js >= 18 LTS (runtime), npm >= 9 (package manager), TypeScript >= 5.5 compiler.
- Runtime deps:
@apollo/server@^4.11.0, graphql@^16.9.0, dataloader@^2.2.2.
- Dev deps:
typescript@^5.5.2, tsx@^4.16.2, @types/node@^20.14.9.
- The server uses Apollo Server 4's
startStandaloneServer with an async context factory that creates fresh DataLoader instances per HTTP request so batched caches never leak across requests or sessions.
- The reference implementation uses an in-memory repository (
db.ts) to remain fully runnable; swapping in a pg/Prisma/Knex repository keeps the loaders and resolvers unchanged.
2. Input/Output Data Contracts
Execution inputs
- GraphQL query/mutation documents over SDL with types
User, Post, Comment, interface Node, enum SortOrder, and post/comment input objects.
context shape injected into every resolver: { loaders: Loaders } where Loaders exposes usersById, postsByAuthorId, commentsByPostId, commentsByAuthorId, postById, commentById (each a DataLoader<K, V>).
N+1 elimination contract
- Resolver fields that resolve related entities (
Post.author, Post.comments, Comment.author, User.posts) MUST go through a per-request loader, never a raw repository call in a loop.
- Any mutation that writes to a collection served by a loader MUST call
loader.clear(key) after the write so cached rows stay consistent within the in-flight request.
Output artifact paths
skills/graphql-schema-dataloader-builder/src/schema.ts (SDL type definitions)
skills/graphql-schema-dataloader-builder/src/resolvers.ts (typed resolvers)
skills/graphql-schema-dataloader-builder/src/loaders.ts (createLoaders factory)
skills/graphql-schema-dataloader-builder/src/db.ts (in-memory repository with seed data)
skills/graphql-schema-dataloader-builder/src/server.ts (Apollo Server 4 bootstrap)
skills/graphql-schema-dataloader-builder/package.json, tsconfig.json
3. Production Reference Implementation
// package.json
{
"name": "graphql-dataloader-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts"
},
"dependencies": {
"@apollo/server": "^4.11.0",
"dataloader": "^2.2.2",
"graphql": "^16.9.0"
},
"devDependencies": {
"@types/node": "^20.14.9",
"tsx": "^4.16.2",
"typescript": "^5.5.2"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": false
},
"include": ["src/**/*.ts"]
}
// src/db.ts
export interface User {
id: string;
username: string;
email: string;
bio: string | null;
}
export interface Post {
id: string;
authorId: string;
title: string;
body: string;
}
export interface Comment {
id: string;
postId: string;
authorId: string;
body: string;
createdAt: string;
}
export const users: User[] = [
{ id: 'user-1', username: 'ada', email: 'ada@example.com', bio: 'First programmable computer pioneer.' },
{ id: 'user-2', username: 'grace', email: 'grace@example.com', bio: null },
{ id: 'user-3', username: 'alan', email: 'alan@example.com', bio: 'Works on formal verification.' }
];
export const posts: Post[] = [
{ id: 'post-1', authorId: 'user-1', title: 'Notes on the Analytical Engine', body: 'A sketch of the first algorithm.' },
{ id: 'post-2', authorId: 'user-1', title: 'On divisors', body: 'Contributions to the mathematical tables.' },
{ id: 'post-3', authorId: 'user-2', title: 'The future of computing', body: 'Stored-program machines and compilers.' },
{ id: 'post-4', authorId: 'user-3', title: 'Universal Turing test', body: 'Decidability and the halting problem.' }
];
export const comments: Comment[] = [
{ id: 'comment-1', postId: 'post-1', authorId: 'user-2', body: 'Groundbreaking for 1843!', createdAt: '2024-01-01T10:00:00Z' },
{ id: 'comment-2', postId: 'post-1', authorId: 'user-3', body: 'I traced the loop table by hand.', createdAt: '2024-01-02T11:30:00Z' },
{ id: 'comment-3', postId: 'post-3', authorId: 'user-1', body: 'COBOL still runs payroll today.', createdAt: '2024-02-01T09:00:00Z' },
{ id: 'comment-4', postId: 'post-4', authorId: 'user-2', body: 'You mean the busy beaver function.', createdAt: '2024-03-05T14:45:00Z' }
];
// src/loaders.ts
import DataLoader from 'dataloader';
import * as db from './db';
export type Loaders = {
usersById: DataLoader<string, db.User | null>;
postsByAuthorId: DataLoader<string, db.Post[]>;
commentsByPostId: DataLoader<string, db.Comment[]>;
commentsByAuthorId: DataLoader<string, db.Comment[]>;
postById: DataLoader<string, db.Post | null>;
commentById: DataLoader<string, db.Comment | null>;
};
export function createLoaders(): Loaders {
const usersById = new DataLoader<string, db.User | null>(
async (ids: readonly string[]): Promise<Array<db.User | null>> =>
ids.map((id) => db.users.find((u) => u.id === id) ?? null),
{ cache: true }
);
const postsByAuthorId = new DataLoader<string, db.Post[]>(
async (authorIds: readonly string[]): Promise<db.Post[][]> =>
authorIds.map((authorId) => db.posts.filter((p) => p.authorId === authorId)),
{ cache: true }
);
const commentsByPostId = new DataLoader<string, db.Comment[]>(
async (postIds: readonly string[]): Promise<db.Comment[][]> =>
postIds.map((postId) => db.comments.filter((c) => c.postId === postId)),
{ cache: true }
);
const commentsByAuthorId = new DataLoader<string, db.Comment[]>(
async (authorIds: readonly string[]): Promise<db.Comment[][]> =>
authorIds.map((authorId) => db.comments.filter((c) => c.authorId === authorId)),
{ cache: true }
);
const postById = new DataLoader<string, db.Post | null>(
async (ids: readonly string[]): Promise<Array<db.Post | null>> =>
ids.map((id) => db.posts.find((p) => p.id === id) ?? null),
{ cache: true }
);
const commentById = new DataLoader<string, db.Comment | null>(
async (ids: readonly string[]): Promise<Array<db.Comment | null>> =>
ids.map((id) => db.comments.find((c) => c.id === id) ?? null),
{ cache: true }
);
return { usersById, postsByAuthorId, commentsByPostId, commentsByAuthorId, postById, commentById };
}
// src/resolvers.ts
import { randomUUID } from 'node:crypto';
import * as db from './db';
import type { Loaders } from './loaders';
export interface ResolverContext {
loaders: Loaders;
}
interface PostArgs {
limit?: number;
sort?: 'ASC' | 'DESC';
}
interface SliceArgs {
limit?: number;
}
export const resolvers = {
Node: {
__resolveType(obj: db.User | db.Post | db.Comment): string {
if ('username' in obj) return 'User';
if ('authorId' in obj) return 'Post';
return 'Comment';
}
},
Query: {
node: async (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) => {
const [user, post, comment] = await Promise.all([
ctx.loaders.usersById.load(id),
ctx.loaders.postById.load(id),
ctx.loaders.commentById.load(id)
]);
return user ?? post ?? comment ?? null;
},
users: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>
ctx.loaders.usersById.loadMany(ids),
posts: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>
ctx.loaders.postById.loadMany(ids),
user: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>
ctx.loaders.usersById.load(id),
post: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>
ctx.loaders.postById.load(id),
comment: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>
ctx.loaders.commentById.load(id)
},
User: {
posts: async (parent: db.User, args: PostArgs, ctx: ResolverContext) => {
const all = await ctx.loaders.postsByAuthorId.load(parent.id);
const sorted = [...all].sort((a, b) =>
args.sort === 'ASC' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id)
);
return sorted.slice(0, args.limit ?? 10);
},
comments: (parent: db.User, args: SliceArgs, ctx: ResolverContext) =>
ctx.loaders.commentsByAuthorId
.load(parent.id)
.then((all) => all.slice(0, args.limit ?? 20))
},
Post: {
author: (parent: db.Post, _args: unknown, ctx: ResolverContext) =>
ctx.loaders.usersById.load(parent.authorId),
comments: (parent: db.Post, args: SliceArgs, ctx: ResolverContext) =>
ctx.loaders.commentsByPostId
.load(parent.id)
.then((all) => all.slice(0, args.limit ?? 20)),
commentCount: async (parent: db.Post, _args: unknown, ctx: ResolverContext) =>
(await ctx.loaders.commentsByPostId.load(parent.id)).length
},
Comment: {
post: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>
ctx.loaders.postById.load(parent.postId),
author: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>
ctx.loaders.usersById.load(parent.authorId)
},
Mutation: {
createPost: async (
_parent: unknown,
{ input }: { input: { title: string; body: string } },
ctx: ResolverContext
) => {
const post: db.Post = { id: randomUUID(), authorId: 'user-1', title: input.title, body: input.body };
db.posts.push(post);
ctx.loaders.postsByAuthorId.clear('user-1');
return post;
},
createComment: async (
_parent: unknown,
{ input }: { input: { postId: string; body: string } },
ctx: ResolverContext
) => {
const post = await ctx.loaders.postById.load(input.postId);
if (!post) throw new Error(`Post ${input.postId} does not exist`);
const comment: db.Comment = {
id: randomUUID(),
postId: input.postId,
authorId: 'user-2',
body: input.body,
createdAt: new Date().toISOString()
};
db.comments.push(comment);
ctx.loaders.commentsByPostId.clear(input.postId);
ctx.loaders.commentsByAuthorId.clear('user-2');
return comment;
}
}
};
// src/schema.ts
export const typeDefs = `#graphql
interface Node {
id: ID!
}
enum SortOrder {
ASC
DESC
}
type User implements Node {
id: ID!
username: String!
email: String!
bio: String
posts(limit: Int = 10, sort: SortOrder = DESC): [Post!]!
comments(limit: Int = 20): [Comment!]!
}
type Post implements Node {
id: ID!
author: User!
title: String!
body: String!
comments(limit: Int = 20): [Comment!]!
commentCount: Int!
}
type Comment implements Node {
id: ID!
post: Post!
author: User!
body: String!
createdAt: String!
}
input PostInput {
title: String!
body: String!
}
input CommentInput {
postId: ID!
body: String!
}
type Query {
node(id: ID!): Node
users(ids: [ID!]!): [User]!
posts(ids: [ID!]!): [Post]!
post(id: ID!): Post
user(id: ID!): User
comment(id: ID!): Comment
}
type Mutation {
createPost(input: PostInput!): Post!
createComment(input: CommentInput!): Comment!
}
`;
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createLoaders } from './loaders';
export const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
loaders: createLoaders(),
requestId: typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : null
}),
listen: { port: 4000 }
});
console.log(`GraphQL server ready at ${url}`);
4. Execution Protocol & Step-by-Step Workflow
- Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json,
src/*).
- Install: run
npm install.
- Run: execute
npm start; the endpoint is http://localhost:4000/graphql.
- Verify SDL auto-documentation: open
http://localhost:4000/graphql (Apollo Sandbox) and confirm User, Post, Comment, Node, SortOrder, Query, and Mutation appear in the schema reference.
- Verify N+1 elimination: execute the query below and confirm via server logs/query profiling that all authors resolve in a single batched load and all comment lookups in a second batch (never one query per row).
- Verify mutations: run
createPost, then re-query User.posts for user-1 and confirm the new post appears because postsByAuthorId.clear('user-1') invalidated the stale cache.
- Verify interface resolution: query
node(id: "comment-2") { id ... on Comment { body } } and confirm __resolveType routes to Comment.
query {
posts(ids: ["post-1", "post-3"]) {
title
author { username }
comments { body author { username } }
}
}
5. Edge Cases & Error Handling
- Loaders are created per-request in
context so user-specific caching and batching never bleed between requests or authenticated sessions.
loadMany preserves the input order even when some ids are missing (null entries), keeping positional resolver indices aligned; nullable list items in SDL ([User]!) allow graceful nulls.
- Mutations invalidate the affected loader keys (
clear) immediately after writes; skipping this step leaves stale rows cached for the remainder of the request.
- A resolver error on any nested field is propagated by Apollo into the
errors array with an extensions.code per GraphQL spec; the field resolves to null while sibling fields still execute, so a single failed author lookup never fails the whole graph.
new Error in mutations aborts the mutation atomically (no partial writes) and surfaces as a GRAPHQL_VALIDATION_FAILED-style client error; production deployments should map it through custom formatError to standardized codes such as BAD_USER_INPUT.
- If the repository is swapped for a real database, keep batch functions single-query (
WHERE id = ANY($1) style) — an implementation that loops inside the batch function reintroduces N+1 inside the "batch".
- In multi-instance deployments, per-request in-memory loader caches remain correct by construction; cross-instance cache coherence is out of scope for loaders and must be handled by the repository layer, not by sharing loader instances.
1---2name: graphql-schema-dataloader-builder3description: Designs performance-optimized GraphQL APIs with strict type definitions, clean Query/Mutation resolvers, and batching mechanisms to eliminate N+1 database queries.4metadata:5 source: skills/graphql-schema-dataloader-builder/graphql-schema-dataloader-builder.md6---78# GraphQL Schema & DataLoader Builder910## 1. System Architecture & Prerequisites1112- Node.js >= 18 LTS (runtime), npm >= 9 (package manager), TypeScript >= 5.5 compiler.13- Runtime deps: `@apollo/server@^4.11.0`, `graphql@^16.9.0`, `dataloader@^2.2.2`.14- Dev deps: `typescript@^5.5.2`, `tsx@^4.16.2`, `@types/node@^20.14.9`.15- The server uses Apollo Server 4's `startStandaloneServer` with an async `context` factory that creates fresh `DataLoader` instances per HTTP request so batched caches never leak across requests or sessions.16- The reference implementation uses an in-memory repository (`db.ts`) to remain fully runnable; swapping in a `pg`/Prisma/Knex repository keeps the loaders and resolvers unchanged.1718## 2. Input/Output Data Contracts1920### Execution inputs2122- GraphQL query/mutation documents over SDL with types `User`, `Post`, `Comment`, interface `Node`, enum `SortOrder`, and `post`/`comment` input objects.23- `context` shape injected into every resolver: `{ loaders: Loaders }` where `Loaders` exposes `usersById`, `postsByAuthorId`, `commentsByPostId`, `commentsByAuthorId`, `postById`, `commentById` (each a `DataLoader<K, V>`).2425### N+1 elimination contract2627- Resolver fields that resolve related entities (`Post.author`, `Post.comments`, `Comment.author`, `User.posts`) MUST go through a per-request loader, never a raw repository call in a loop.28- Any mutation that writes to a collection served by a loader MUST call `loader.clear(key)` after the write so cached rows stay consistent within the in-flight request.2930### Output artifact paths3132- `skills/graphql-schema-dataloader-builder/src/schema.ts` (SDL type definitions)33- `skills/graphql-schema-dataloader-builder/src/resolvers.ts` (typed resolvers)34- `skills/graphql-schema-dataloader-builder/src/loaders.ts` (`createLoaders` factory)35- `skills/graphql-schema-dataloader-builder/src/db.ts` (in-memory repository with seed data)36- `skills/graphql-schema-dataloader-builder/src/server.ts` (Apollo Server 4 bootstrap)37- `skills/graphql-schema-dataloader-builder/package.json`, `tsconfig.json`3839## 3. Production Reference Implementation4041```json42// package.json43{44 "name": "graphql-dataloader-api",45 "version": "1.0.0",46 "private": true,47 "type": "module",48 "scripts": {49 "dev": "tsx watch src/server.ts",50 "start": "tsx src/server.ts"51 },52 "dependencies": {53 "@apollo/server": "^4.11.0",54 "dataloader": "^2.2.2",55 "graphql": "^16.9.0"56 },57 "devDependencies": {58 "@types/node": "^20.14.9",59 "tsx": "^4.16.2",60 "typescript": "^5.5.2"61 }62}63```6465```json66// tsconfig.json67{68 "compilerOptions": {69 "target": "ES2022",70 "module": "ESNext",71 "moduleResolution": "Bundler",72 "strict": true,73 "esModuleInterop": true,74 "skipLibCheck": true,75 "forceConsistentCasingInFileNames": true,76 "types": ["node"],77 "noUnusedLocals": true,78 "noUnusedParameters": false79 },80 "include": ["src/**/*.ts"]81}82```8384```typescript85// src/db.ts86export interface User {87 id: string;88 username: string;89 email: string;90 bio: string | null;91}9293export interface Post {94 id: string;95 authorId: string;96 title: string;97 body: string;98}99100export interface Comment {101 id: string;102 postId: string;103 authorId: string;104 body: string;105 createdAt: string;106}107108export const users: User[] = [109 { id: 'user-1', username: 'ada', email: 'ada@example.com', bio: 'First programmable computer pioneer.' },110 { id: 'user-2', username: 'grace', email: 'grace@example.com', bio: null },111 { id: 'user-3', username: 'alan', email: 'alan@example.com', bio: 'Works on formal verification.' }112];113114export const posts: Post[] = [115 { id: 'post-1', authorId: 'user-1', title: 'Notes on the Analytical Engine', body: 'A sketch of the first algorithm.' },116 { id: 'post-2', authorId: 'user-1', title: 'On divisors', body: 'Contributions to the mathematical tables.' },117 { id: 'post-3', authorId: 'user-2', title: 'The future of computing', body: 'Stored-program machines and compilers.' },118 { id: 'post-4', authorId: 'user-3', title: 'Universal Turing test', body: 'Decidability and the halting problem.' }119];120121export const comments: Comment[] = [122 { id: 'comment-1', postId: 'post-1', authorId: 'user-2', body: 'Groundbreaking for 1843!', createdAt: '2024-01-01T10:00:00Z' },123 { id: 'comment-2', postId: 'post-1', authorId: 'user-3', body: 'I traced the loop table by hand.', createdAt: '2024-01-02T11:30:00Z' },124 { id: 'comment-3', postId: 'post-3', authorId: 'user-1', body: 'COBOL still runs payroll today.', createdAt: '2024-02-01T09:00:00Z' },125 { id: 'comment-4', postId: 'post-4', authorId: 'user-2', body: 'You mean the busy beaver function.', createdAt: '2024-03-05T14:45:00Z' }126];127```128129```typescript130// src/loaders.ts131import DataLoader from 'dataloader';132import * as db from './db';133134export type Loaders = {135 usersById: DataLoader<string, db.User | null>;136 postsByAuthorId: DataLoader<string, db.Post[]>;137 commentsByPostId: DataLoader<string, db.Comment[]>;138 commentsByAuthorId: DataLoader<string, db.Comment[]>;139 postById: DataLoader<string, db.Post | null>;140 commentById: DataLoader<string, db.Comment | null>;141};142143export function createLoaders(): Loaders {144 const usersById = new DataLoader<string, db.User | null>(145 async (ids: readonly string[]): Promise<Array<db.User | null>> =>146 ids.map((id) => db.users.find((u) => u.id === id) ?? null),147 { cache: true }148 );149150 const postsByAuthorId = new DataLoader<string, db.Post[]>(151 async (authorIds: readonly string[]): Promise<db.Post[][]> =>152 authorIds.map((authorId) => db.posts.filter((p) => p.authorId === authorId)),153 { cache: true }154 );155156 const commentsByPostId = new DataLoader<string, db.Comment[]>(157 async (postIds: readonly string[]): Promise<db.Comment[][]> =>158 postIds.map((postId) => db.comments.filter((c) => c.postId === postId)),159 { cache: true }160 );161162 const commentsByAuthorId = new DataLoader<string, db.Comment[]>(163 async (authorIds: readonly string[]): Promise<db.Comment[][]> =>164 authorIds.map((authorId) => db.comments.filter((c) => c.authorId === authorId)),165 { cache: true }166 );167168 const postById = new DataLoader<string, db.Post | null>(169 async (ids: readonly string[]): Promise<Array<db.Post | null>> =>170 ids.map((id) => db.posts.find((p) => p.id === id) ?? null),171 { cache: true }172 );173174 const commentById = new DataLoader<string, db.Comment | null>(175 async (ids: readonly string[]): Promise<Array<db.Comment | null>> =>176 ids.map((id) => db.comments.find((c) => c.id === id) ?? null),177 { cache: true }178 );179180 return { usersById, postsByAuthorId, commentsByPostId, commentsByAuthorId, postById, commentById };181}182```183184```typescript185// src/resolvers.ts186import { randomUUID } from 'node:crypto';187import * as db from './db';188import type { Loaders } from './loaders';189190export interface ResolverContext {191 loaders: Loaders;192}193194interface PostArgs {195 limit?: number;196 sort?: 'ASC' | 'DESC';197}198199interface SliceArgs {200 limit?: number;201}202203export const resolvers = {204 Node: {205 __resolveType(obj: db.User | db.Post | db.Comment): string {206 if ('username' in obj) return 'User';207 if ('authorId' in obj) return 'Post';208 return 'Comment';209 }210 },211212 Query: {213 node: async (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) => {214 const [user, post, comment] = await Promise.all([215 ctx.loaders.usersById.load(id),216 ctx.loaders.postById.load(id),217 ctx.loaders.commentById.load(id)218 ]);219 return user ?? post ?? comment ?? null;220 },221 users: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>222 ctx.loaders.usersById.loadMany(ids),223 posts: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>224 ctx.loaders.postById.loadMany(ids),225 user: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>226 ctx.loaders.usersById.load(id),227 post: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>228 ctx.loaders.postById.load(id),229 comment: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>230 ctx.loaders.commentById.load(id)231 },232233 User: {234 posts: async (parent: db.User, args: PostArgs, ctx: ResolverContext) => {235 const all = await ctx.loaders.postsByAuthorId.load(parent.id);236 const sorted = [...all].sort((a, b) =>237 args.sort === 'ASC' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id)238 );239 return sorted.slice(0, args.limit ?? 10);240 },241 comments: (parent: db.User, args: SliceArgs, ctx: ResolverContext) =>242 ctx.loaders.commentsByAuthorId243 .load(parent.id)244 .then((all) => all.slice(0, args.limit ?? 20))245 },246247 Post: {248 author: (parent: db.Post, _args: unknown, ctx: ResolverContext) =>249 ctx.loaders.usersById.load(parent.authorId),250 comments: (parent: db.Post, args: SliceArgs, ctx: ResolverContext) =>251 ctx.loaders.commentsByPostId252 .load(parent.id)253 .then((all) => all.slice(0, args.limit ?? 20)),254 commentCount: async (parent: db.Post, _args: unknown, ctx: ResolverContext) =>255 (await ctx.loaders.commentsByPostId.load(parent.id)).length256 },257258 Comment: {259 post: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>260 ctx.loaders.postById.load(parent.postId),261 author: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>262 ctx.loaders.usersById.load(parent.authorId)263 },264265 Mutation: {266 createPost: async (267 _parent: unknown,268 { input }: { input: { title: string; body: string } },269 ctx: ResolverContext270 ) => {271 const post: db.Post = { id: randomUUID(), authorId: 'user-1', title: input.title, body: input.body };272 db.posts.push(post);273 ctx.loaders.postsByAuthorId.clear('user-1');274 return post;275 },276 createComment: async (277 _parent: unknown,278 { input }: { input: { postId: string; body: string } },279 ctx: ResolverContext280 ) => {281 const post = await ctx.loaders.postById.load(input.postId);282 if (!post) throw new Error(`Post ${input.postId} does not exist`);283 const comment: db.Comment = {284 id: randomUUID(),285 postId: input.postId,286 authorId: 'user-2',287 body: input.body,288 createdAt: new Date().toISOString()289 };290 db.comments.push(comment);291 ctx.loaders.commentsByPostId.clear(input.postId);292 ctx.loaders.commentsByAuthorId.clear('user-2');293 return comment;294 }295 }296};297```298299```typescript300// src/schema.ts301export const typeDefs = `#graphql302 interface Node {303 id: ID!304 }305306 enum SortOrder {307 ASC308 DESC309 }310311 type User implements Node {312 id: ID!313 username: String!314 email: String!315 bio: String316 posts(limit: Int = 10, sort: SortOrder = DESC): [Post!]!317 comments(limit: Int = 20): [Comment!]!318 }319320 type Post implements Node {321 id: ID!322 author: User!323 title: String!324 body: String!325 comments(limit: Int = 20): [Comment!]!326 commentCount: Int!327 }328329 type Comment implements Node {330 id: ID!331 post: Post!332 author: User!333 body: String!334 createdAt: String!335 }336337 input PostInput {338 title: String!339 body: String!340 }341342 input CommentInput {343 postId: ID!344 body: String!345 }346347 type Query {348 node(id: ID!): Node349 users(ids: [ID!]!): [User]!350 posts(ids: [ID!]!): [Post]!351 post(id: ID!): Post352 user(id: ID!): User353 comment(id: ID!): Comment354 }355356 type Mutation {357 createPost(input: PostInput!): Post!358 createComment(input: CommentInput!): Comment!359 }360`;361```362363```typescript364// src/server.ts365import { ApolloServer } from '@apollo/server';366import { startStandaloneServer } from '@apollo/server/standalone';367import { typeDefs } from './schema';368import { resolvers } from './resolvers';369import { createLoaders } from './loaders';370371export const server = new ApolloServer({372 typeDefs,373 resolvers,374 introspection: true375});376377const { url } = await startStandaloneServer(server, {378 context: async ({ req }) => ({379 loaders: createLoaders(),380 requestId: typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : null381 }),382 listen: { port: 4000 }383});384385console.log(`GraphQL server ready at ${url}`);386```387388## 4. Execution Protocol & Step-by-Step Workflow3893901. Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json, `src/*`).3912. Install: run `npm install`.3923. Run: execute `npm start`; the endpoint is `http://localhost:4000/graphql`.3934. Verify SDL auto-documentation: open `http://localhost:4000/graphql` (Apollo Sandbox) and confirm `User`, `Post`, `Comment`, `Node`, `SortOrder`, `Query`, and `Mutation` appear in the schema reference.3945. Verify N+1 elimination: execute the query below and confirm via server logs/query profiling that all authors resolve in a single batched load and all comment lookups in a second batch (never one query per row).3956. Verify mutations: run `createPost`, then re-query `User.posts` for `user-1` and confirm the new post appears because `postsByAuthorId.clear('user-1')` invalidated the stale cache.3967. Verify interface resolution: query `node(id: "comment-2") { id ... on Comment { body } }` and confirm `__resolveType` routes to `Comment`.397398```graphql399query {400 posts(ids: ["post-1", "post-3"]) {401 title402 author { username }403 comments { body author { username } }404 }405}406```407408## 5. Edge Cases & Error Handling409410- Loaders are created per-request in `context` so user-specific caching and batching never bleed between requests or authenticated sessions.411- `loadMany` preserves the input order even when some ids are missing (`null` entries), keeping positional resolver indices aligned; nullable list items in SDL (`[User]!`) allow graceful nulls.412- Mutations invalidate the affected loader keys (`clear`) immediately after writes; skipping this step leaves stale rows cached for the remainder of the request.413- A resolver error on any nested field is propagated by Apollo into the `errors` array with an `extensions.code` per GraphQL spec; the field resolves to `null` while sibling fields still execute, so a single failed author lookup never fails the whole graph.414- `new Error` in mutations aborts the mutation atomically (no partial writes) and surfaces as a `GRAPHQL_VALIDATION_FAILED`-style client error; production deployments should map it through custom `formatError` to standardized codes such as `BAD_USER_INPUT`.415- If the repository is swapped for a real database, keep batch functions single-query (`WHERE id = ANY($1)` style) — an implementation that loops inside the batch function reintroduces N+1 inside the "batch".416- In multi-instance deployments, per-request in-memory loader caches remain correct by construction; cross-instance cache coherence is out of scope for loaders and must be handled by the repository layer, not by sharing loader instances.