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.4---56# GraphQL Schema & DataLoader Builder78## 1. System Architecture & Prerequisites910- Node.js >= 18 LTS (runtime), npm >= 9 (package manager), TypeScript >= 5.5 compiler.11- Runtime deps: `@apollo/server@^4.11.0`, `graphql@^16.9.0`, `dataloader@^2.2.2`.12- Dev deps: `typescript@^5.5.2`, `tsx@^4.16.2`, `@types/node@^20.14.9`.13- 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.14- 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.1516## 2. Input/Output Data Contracts1718### Execution inputs1920- GraphQL query/mutation documents over SDL with types `User`, `Post`, `Comment`, interface `Node`, enum `SortOrder`, and `post`/`comment` input objects.21- `context` shape injected into every resolver: `{ loaders: Loaders }` where `Loaders` exposes `usersById`, `postsByAuthorId`, `commentsByPostId`, `commentsByAuthorId`, `postById`, `commentById` (each a `DataLoader<K, V>`).2223### N+1 elimination contract2425- 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.26- 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.2728### Output artifact paths2930- `skills/graphql-schema-dataloader-builder/src/schema.ts` (SDL type definitions)31- `skills/graphql-schema-dataloader-builder/src/resolvers.ts` (typed resolvers)32- `skills/graphql-schema-dataloader-builder/src/loaders.ts` (`createLoaders` factory)33- `skills/graphql-schema-dataloader-builder/src/db.ts` (in-memory repository with seed data)34- `skills/graphql-schema-dataloader-builder/src/server.ts` (Apollo Server 4 bootstrap)35- `skills/graphql-schema-dataloader-builder/package.json`, `tsconfig.json`3637## 3. Production Reference Implementation3839```json40// package.json41{42 "name": "graphql-dataloader-api",43 "version": "1.0.0",44 "private": true,45 "type": "module",46 "scripts": {47 "dev": "tsx watch src/server.ts",48 "start": "tsx src/server.ts"49 },50 "dependencies": {51 "@apollo/server": "^4.11.0",52 "dataloader": "^2.2.2",53 "graphql": "^16.9.0"54 },55 "devDependencies": {56 "@types/node": "^20.14.9",57 "tsx": "^4.16.2",58 "typescript": "^5.5.2"59 }60}61```6263```json64// tsconfig.json65{66 "compilerOptions": {67 "target": "ES2022",68 "module": "ESNext",69 "moduleResolution": "Bundler",70 "strict": true,71 "esModuleInterop": true,72 "skipLibCheck": true,73 "forceConsistentCasingInFileNames": true,74 "types": ["node"],75 "noUnusedLocals": true,76 "noUnusedParameters": false77 },78 "include": ["src/**/*.ts"]79}80```8182```typescript83// src/db.ts84export interface User {85 id: string;86 username: string;87 email: string;88 bio: string | null;89}9091export interface Post {92 id: string;93 authorId: string;94 title: string;95 body: string;96}9798export interface Comment {99 id: string;100 postId: string;101 authorId: string;102 body: string;103 createdAt: string;104}105106export const users: User[] = [107 { id: 'user-1', username: 'ada', email: 'ada@example.com', bio: 'First programmable computer pioneer.' },108 { id: 'user-2', username: 'grace', email: 'grace@example.com', bio: null },109 { id: 'user-3', username: 'alan', email: 'alan@example.com', bio: 'Works on formal verification.' }110];111112export const posts: Post[] = [113 { id: 'post-1', authorId: 'user-1', title: 'Notes on the Analytical Engine', body: 'A sketch of the first algorithm.' },114 { id: 'post-2', authorId: 'user-1', title: 'On divisors', body: 'Contributions to the mathematical tables.' },115 { id: 'post-3', authorId: 'user-2', title: 'The future of computing', body: 'Stored-program machines and compilers.' },116 { id: 'post-4', authorId: 'user-3', title: 'Universal Turing test', body: 'Decidability and the halting problem.' }117];118119export const comments: Comment[] = [120 { id: 'comment-1', postId: 'post-1', authorId: 'user-2', body: 'Groundbreaking for 1843!', createdAt: '2024-01-01T10:00:00Z' },121 { id: 'comment-2', postId: 'post-1', authorId: 'user-3', body: 'I traced the loop table by hand.', createdAt: '2024-01-02T11:30:00Z' },122 { id: 'comment-3', postId: 'post-3', authorId: 'user-1', body: 'COBOL still runs payroll today.', createdAt: '2024-02-01T09:00:00Z' },123 { id: 'comment-4', postId: 'post-4', authorId: 'user-2', body: 'You mean the busy beaver function.', createdAt: '2024-03-05T14:45:00Z' }124];125```126127```typescript128// src/loaders.ts129import DataLoader from 'dataloader';130import * as db from './db';131132export type Loaders = {133 usersById: DataLoader<string, db.User | null>;134 postsByAuthorId: DataLoader<string, db.Post[]>;135 commentsByPostId: DataLoader<string, db.Comment[]>;136 commentsByAuthorId: DataLoader<string, db.Comment[]>;137 postById: DataLoader<string, db.Post | null>;138 commentById: DataLoader<string, db.Comment | null>;139};140141export function createLoaders(): Loaders {142 const usersById = new DataLoader<string, db.User | null>(143 async (ids: readonly string[]): Promise<Array<db.User | null>> =>144 ids.map((id) => db.users.find((u) => u.id === id) ?? null),145 { cache: true }146 );147148 const postsByAuthorId = new DataLoader<string, db.Post[]>(149 async (authorIds: readonly string[]): Promise<db.Post[][]> =>150 authorIds.map((authorId) => db.posts.filter((p) => p.authorId === authorId)),151 { cache: true }152 );153154 const commentsByPostId = new DataLoader<string, db.Comment[]>(155 async (postIds: readonly string[]): Promise<db.Comment[][]> =>156 postIds.map((postId) => db.comments.filter((c) => c.postId === postId)),157 { cache: true }158 );159160 const commentsByAuthorId = new DataLoader<string, db.Comment[]>(161 async (authorIds: readonly string[]): Promise<db.Comment[][]> =>162 authorIds.map((authorId) => db.comments.filter((c) => c.authorId === authorId)),163 { cache: true }164 );165166 const postById = new DataLoader<string, db.Post | null>(167 async (ids: readonly string[]): Promise<Array<db.Post | null>> =>168 ids.map((id) => db.posts.find((p) => p.id === id) ?? null),169 { cache: true }170 );171172 const commentById = new DataLoader<string, db.Comment | null>(173 async (ids: readonly string[]): Promise<Array<db.Comment | null>> =>174 ids.map((id) => db.comments.find((c) => c.id === id) ?? null),175 { cache: true }176 );177178 return { usersById, postsByAuthorId, commentsByPostId, commentsByAuthorId, postById, commentById };179}180```181182```typescript183// src/resolvers.ts184import { randomUUID } from 'node:crypto';185import * as db from './db';186import type { Loaders } from './loaders';187188export interface ResolverContext {189 loaders: Loaders;190}191192interface PostArgs {193 limit?: number;194 sort?: 'ASC' | 'DESC';195}196197interface SliceArgs {198 limit?: number;199}200201export const resolvers = {202 Node: {203 __resolveType(obj: db.User | db.Post | db.Comment): string {204 if ('username' in obj) return 'User';205 if ('authorId' in obj) return 'Post';206 return 'Comment';207 }208 },209210 Query: {211 node: async (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) => {212 const [user, post, comment] = await Promise.all([213 ctx.loaders.usersById.load(id),214 ctx.loaders.postById.load(id),215 ctx.loaders.commentById.load(id)216 ]);217 return user ?? post ?? comment ?? null;218 },219 users: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>220 ctx.loaders.usersById.loadMany(ids),221 posts: (_parent: unknown, { ids }: { ids: string[] }, ctx: ResolverContext) =>222 ctx.loaders.postById.loadMany(ids),223 user: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>224 ctx.loaders.usersById.load(id),225 post: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>226 ctx.loaders.postById.load(id),227 comment: (_parent: unknown, { id }: { id: string }, ctx: ResolverContext) =>228 ctx.loaders.commentById.load(id)229 },230231 User: {232 posts: async (parent: db.User, args: PostArgs, ctx: ResolverContext) => {233 const all = await ctx.loaders.postsByAuthorId.load(parent.id);234 const sorted = [...all].sort((a, b) =>235 args.sort === 'ASC' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id)236 );237 return sorted.slice(0, args.limit ?? 10);238 },239 comments: (parent: db.User, args: SliceArgs, ctx: ResolverContext) =>240 ctx.loaders.commentsByAuthorId241 .load(parent.id)242 .then((all) => all.slice(0, args.limit ?? 20))243 },244245 Post: {246 author: (parent: db.Post, _args: unknown, ctx: ResolverContext) =>247 ctx.loaders.usersById.load(parent.authorId),248 comments: (parent: db.Post, args: SliceArgs, ctx: ResolverContext) =>249 ctx.loaders.commentsByPostId250 .load(parent.id)251 .then((all) => all.slice(0, args.limit ?? 20)),252 commentCount: async (parent: db.Post, _args: unknown, ctx: ResolverContext) =>253 (await ctx.loaders.commentsByPostId.load(parent.id)).length254 },255256 Comment: {257 post: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>258 ctx.loaders.postById.load(parent.postId),259 author: (parent: db.Comment, _args: unknown, ctx: ResolverContext) =>260 ctx.loaders.usersById.load(parent.authorId)261 },262263 Mutation: {264 createPost: async (265 _parent: unknown,266 { input }: { input: { title: string; body: string } },267 ctx: ResolverContext268 ) => {269 const post: db.Post = { id: randomUUID(), authorId: 'user-1', title: input.title, body: input.body };270 db.posts.push(post);271 ctx.loaders.postsByAuthorId.clear('user-1');272 return post;273 },274 createComment: async (275 _parent: unknown,276 { input }: { input: { postId: string; body: string } },277 ctx: ResolverContext278 ) => {279 const post = await ctx.loaders.postById.load(input.postId);280 if (!post) throw new Error(`Post ${input.postId} does not exist`);281 const comment: db.Comment = {282 id: randomUUID(),283 postId: input.postId,284 authorId: 'user-2',285 body: input.body,286 createdAt: new Date().toISOString()287 };288 db.comments.push(comment);289 ctx.loaders.commentsByPostId.clear(input.postId);290 ctx.loaders.commentsByAuthorId.clear('user-2');291 return comment;292 }293 }294};295```296297```typescript298// src/schema.ts299export const typeDefs = `#graphql300 interface Node {301 id: ID!302 }303304 enum SortOrder {305 ASC306 DESC307 }308309 type User implements Node {310 id: ID!311 username: String!312 email: String!313 bio: String314 posts(limit: Int = 10, sort: SortOrder = DESC): [Post!]!315 comments(limit: Int = 20): [Comment!]!316 }317318 type Post implements Node {319 id: ID!320 author: User!321 title: String!322 body: String!323 comments(limit: Int = 20): [Comment!]!324 commentCount: Int!325 }326327 type Comment implements Node {328 id: ID!329 post: Post!330 author: User!331 body: String!332 createdAt: String!333 }334335 input PostInput {336 title: String!337 body: String!338 }339340 input CommentInput {341 postId: ID!342 body: String!343 }344345 type Query {346 node(id: ID!): Node347 users(ids: [ID!]!): [User]!348 posts(ids: [ID!]!): [Post]!349 post(id: ID!): Post350 user(id: ID!): User351 comment(id: ID!): Comment352 }353354 type Mutation {355 createPost(input: PostInput!): Post!356 createComment(input: CommentInput!): Comment!357 }358`;359```360361```typescript362// src/server.ts363import { ApolloServer } from '@apollo/server';364import { startStandaloneServer } from '@apollo/server/standalone';365import { typeDefs } from './schema';366import { resolvers } from './resolvers';367import { createLoaders } from './loaders';368369export const server = new ApolloServer({370 typeDefs,371 resolvers,372 introspection: true373});374375const { url } = await startStandaloneServer(server, {376 context: async ({ req }) => ({377 loaders: createLoaders(),378 requestId: typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : null379 }),380 listen: { port: 4000 }381});382383console.log(`GraphQL server ready at ${url}`);384```385386## 4. Execution Protocol & Step-by-Step Workflow3873881. Scaffold: create the project directory and copy all files from section 3 (package.json, tsconfig.json, `src/*`).3892. Install: run `npm install`.3903. Run: execute `npm start`; the endpoint is `http://localhost:4000/graphql`.3914. 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.3925. 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).3936. 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.3947. Verify interface resolution: query `node(id: "comment-2") { id ... on Comment { body } }` and confirm `__resolveType` routes to `Comment`.395396```graphql397query {398 posts(ids: ["post-1", "post-3"]) {399 title400 author { username }401 comments { body author { username } }402 }403}404```405406## 5. Edge Cases & Error Handling407408- Loaders are created per-request in `context` so user-specific caching and batching never bleed between requests or authenticated sessions.409- `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.410- Mutations invalidate the affected loader keys (`clear`) immediately after writes; skipping this step leaves stale rows cached for the remainder of the request.411- 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.412- `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`.413- 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".414- 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.