ProvenPin Install

Apollo Server 5 resolvers with per-request DataLoader batching: working versions (tested 2026-10-15)

An Apollo Server 5 schema whose nested resolvers use per-request DataLoader instances to batch and dedupe lookups (no N+1), with GraphQLError extension codes and cache invalidation after a mutation, tested in-process with executeOperation.

passed5 passed · 0 failed2026-10-15 14:56 UTC
Status
passed
Last passed
2026-10-15 14:56 UTC
Tests
5 / 5
Language
TypeScript
Runtime
node v22.23.3
pinned versions5
@apollo/server@5.5.1graphql@16.14.2dataloader@2.2.3tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38755782594

The code

Exactly the files that passed. Your agent can pull them with get_recipe("apollo-server-dataloader-batching") (install), or see them on GitHub.

package.json
{
  "name": "apollo-server-dataloader-batching",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "@apollo/server": "5.5.1",
    "graphql": "16.14.2",
    "dataloader": "2.2.3"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "26.6.5"
  }
}
src/server.ts
import { ApolloServer } from "@apollo/server";
import { GraphQLError } from "graphql";
import DataLoader from "dataloader";

type Author = { id: string; name: string };
type Book = { id: string; title: string; authorId: string };

export const typeDefs = `#graphql
  type Author { id: ID!, name: String!, books: [Book!]! }
  type Book { id: ID!, title: String!, author: Author! }
  type Query { books: [Book!]!, author(id: ID!): Author }
  type Mutation { addBook(title: String!, authorId: ID!): Book! }
`;

/** Fake data source that records every batch call so tests can prove N+1 is gone. */
export function createDb() {
  const authors: Author[] = [{ id: "a1", name: "Ursula" }, { id: "a2", name: "Octavia" }, { id: "a3", name: "Iain" }];
  const books: Book[] = [
    { id: "b1", title: "The Dispossessed", authorId: "a1" },
    { id: "b2", title: "Kindred", authorId: "a2" },
    { id: "b3", title: "The Left Hand of Darkness", authorId: "a1" },
    { id: "b4", title: "Consider Phlebas", authorId: "a3" },
  ];
  const calls: string[][] = [];
  return {
    books, calls,
    authorsByIds: async (ids: readonly string[]) => { calls.push([...ids]); return ids.map((id) => authors.find((a) => a.id === id) ?? new Error(`author ${id} not found`)); },
    booksByAuthorIds: async (ids: readonly string[]) => ids.map((id) => books.filter((b) => b.authorId === id)),
  };
}
export type Db = ReturnType<typeof createDb>;

/** Loaders are created per request, so the cache never leaks between users. */
export function createContext(db: Db, user?: string) {
  return {
    db, user,
    loaders: {
      author: new DataLoader<string, Author>((ids) => db.authorsByIds(ids)),
      booksByAuthor: new DataLoader<string, Book[]>((ids) => db.booksByAuthorIds(ids)),
    },
  };
}
export type Ctx = ReturnType<typeof createContext>;

export const resolvers = {
  Query: {
    books: (_: unknown, __: unknown, ctx: Ctx) => ctx.db.books,
    author: (_: unknown, { id }: { id: string }, ctx: Ctx) => ctx.loaders.author.load(id).catch(() => null),
  },
  Book: { author: (b: Book, _: unknown, ctx: Ctx) => ctx.loaders.author.load(b.authorId) },
  Author: { books: (a: Author, _: unknown, ctx: Ctx) => ctx.loaders.booksByAuthor.load(a.id) },
  Mutation: {
    addBook: async (_: unknown, args: { title: string; authorId: string }, ctx: Ctx) => {
      if (!ctx.user) throw new GraphQLError("sign in first", { extensions: { code: "UNAUTHENTICATED" } });
      await ctx.loaders.author.load(args.authorId).catch(() => {
        throw new GraphQLError("unknown author", { extensions: { code: "BAD_USER_INPUT", field: "authorId" } });
      });
      const book = { id: `b${ctx.db.books.length + 1}`, ...args };
      ctx.db.books.push(book);
      ctx.loaders.booksByAuthor.clear(args.authorId); // invalidate the per-request cache after a write
      return book;
    },
  },
};

export const createServer = () => new ApolloServer<Ctx>({ typeDefs, resolvers });
test/server.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer, createDb, createContext } from "../src/server.ts";

/** executeOperation runs the full request pipeline in-process, no HTTP server needed. */
async function exec(query: string, variables?: Record<string, unknown>, user?: string, db = createDb()) {
  const server = createServer();
  const res = await server.executeOperation({ query, variables }, { contextValue: createContext(db, user) });
  await server.stop();
  assert.equal(res.body.kind, "single");
  return { result: JSON.parse(JSON.stringify((res.body as { singleResult: unknown }).singleResult)) as { data?: any; errors?: any[] }, db };
}

test("resolves nested authors with one batched lookup instead of N+1", async () => {
  const { result, db } = await exec("{ books { title author { name } } }");
  assert.equal(result.errors, undefined);
  assert.equal(result.data.books.length, 4);
  assert.equal(result.data.books[1].author.name, "Octavia");
  assert.deepEqual(db.calls, [["a1", "a2", "a3"]]); // deduped and batched in one tick
});

test("author with books, and null for an unknown id", async () => {
  const { result } = await exec(`query($id: ID!) { a: author(id: $id) { name books { title } } b: author(id: "zz") { name } }`, { id: "a1" });
  assert.deepEqual(result.data.a.books.map((b: { title: string }) => b.title), ["The Dispossessed", "The Left Hand of Darkness"]);
  assert.equal(result.data.b, null);
});

test("mutation requires a user and reports error codes in extensions", async () => {
  const anon = await exec(`mutation { addBook(title: "X", authorId: "a1") { id } }`);
  assert.equal(anon.result.errors?.[0].extensions.code, "UNAUTHENTICATED");
  const bad = await exec(`mutation { addBook(title: "X", authorId: "nope") { id } }`, undefined, "ada");
  assert.equal(bad.result.errors?.[0].extensions.code, "BAD_USER_INPUT");
  assert.equal(bad.result.errors?.[0].extensions.field, "authorId");
});

test("mutation writes and clears the loader cache so reads see the new book", async () => {
  const db = createDb();
  const server = createServer();
  const contextValue = createContext(db, "ada");
  const read = `{ author(id: "a3") { books { title } } }`;
  const before = await server.executeOperation({ query: read }, { contextValue });
  const added = await server.executeOperation({ query: `mutation { addBook(title: "Excession", authorId: "a3") { id author { name } } }` }, { contextValue });
  const after = await server.executeOperation({ query: read }, { contextValue });
  await server.stop();
  // graphql-js builds results with null prototypes; a JSON round trip makes them plain objects
  const data = (r: typeof before) => JSON.parse(JSON.stringify((r.body as any).singleResult.data));
  assert.equal(data(before).author.books.length, 1);
  assert.deepEqual(data(added).addBook, { id: "b5", author: { name: "Iain" } });
  assert.deepEqual(data(after).author.books.map((b: { title: string }) => b.title), ["Consider Phlebas", "Excession"]);
});

test("validation errors come back as GRAPHQL_VALIDATION_FAILED", async () => {
  const { result } = await exec("{ books { isbn } }");
  assert.equal(result.errors?.[0].extensions.code, "GRAPHQL_VALIDATION_FAILED");
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.