Week 21: GraphQL with Apollo Server

Every endpoint since Week 3 returns a fixed response shape — the server decides exactly which fields come back, and a client that only needs two of twelve fields still receives all twelve, or needs a brand-new endpoint if the built-in ones don't fit. GraphQL inverts that: clients specify exactly the shape of data they want in the query itself. This week builds a schema-first GraphQL API with Apollo Server, and confronts GraphQL's own version of the N+1 problem Week 15 solved for Prisma.

Module 18 of 22 Week 21 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design a GraphQL schema with the GraphQL Schema Definition Language
  • Implement query and field resolvers over your existing Prisma models with Apollo Server
  • Solve GraphQL's own N+1 query problem with DataLoader

1. Schema-First Design with SDL

Apollo Server is schema-first: you write the API's contract in the GraphQL Schema Definition Language (SDL) before writing any resolver code, and Apollo maps your resolver functions onto that contract — the reverse direction from zod-to-openapi in Week 16, which generates documentation from existing schemas.

src/schema.graphql
type Task {
  id: ID!
  title: String!
  status: TaskStatus!
  assignee: User          # resolved separately -- see Section 2
  tags: [String!]!
}

type User {
  id: ID!
  name: String!
  tasks: [Task!]!          # the reverse relationship -- Section 3's N+1 case
}

enum TaskStatus {
  OPEN
  IN_PROGRESS
  DONE
}

type Query {
  task(id: ID!): Task
  tasks(status: TaskStatus): [Task!]!
}

type Mutation {
  createTask(title: String!): Task!
  updateTaskStatus(id: ID!, status: TaskStatus!): Task!
}

This single schema answers a question REST always leaves implicit — exactly what shape every response can take, and exactly what every mutation accepts — as a machine-readable contract both the client and server agree on, without needing a separately maintained OpenAPI spec to stay in sync with the code.

The schema is a design decision, not a mechanical translation of your Prisma models

It's tempting to make the schema mirror your Prisma models field-for-field. Resist that — a GraphQL schema is a public API contract, exactly like the Zod DTOs from Week 3, and should expose what clients actually need, not every internal column. A schema that's a 1:1 model mirror tends to leak internal implementation details and makes future refactoring of your Prisma schema a breaking API change.

2. Query & Field Resolvers

A resolver is the function that fulfills one field or query from the schema. The Query resolver map implements top-level fields; a resolver on any other type implements a field on it — including one that isn't a direct column on the underlying Prisma model at all.

src/resolvers.ts — the top-level queries
export const resolvers = {
  Query: {
    task: async (_parent: unknown, args: { id: string }) =>
      prisma.task.findUnique({ where: { id: args.id } }),

    tasks: async (_parent: unknown, args: { status?: string }) =>
      prisma.task.findMany({ where: args.status ? { status: args.status } : undefined }),
  },

  Mutation: {
    createTask: async (_parent: unknown, args: { title: string }) =>
      prisma.task.create({ data: { title: args.title, status: "OPEN" } }),
  },
};

Apollo binds each resolver directly to its schema field by name — no manual parsing of a request body the way an Express req.body DTO would need. A field resolver fills in a value that isn't a plain column — assignee on Task, resolved from a separate User lookup rather than being a column on the task table itself:

a field resolver for a related type
export const resolvers = {
  // ... Query, Mutation as above
  Task: {
    assignee: async (parent: { assigneeId: string | null }) =>
      parent.assigneeId
        ? prisma.user.findUnique({ where: { id: parent.assigneeId } })
        : null,
  },
};

This is GraphQL's core value proposition made concrete: a client asking only for { task(id: "1") { title } } never triggers the Task.assignee resolver at all — only the fields actually present in the query get resolved, so a client that doesn't need the assignee never pays the cost of fetching it. A REST endpoint returning a full response DTO has no equivalent mechanism; it either always includes every field or needs a separate, purpose-built endpoint for every different shape a client might want.

3. Solving N+1 with DataLoader

That same per-field resolution is also exactly where GraphQL reintroduces the N+1 problem Week 15 solved for Prisma — in a new, GraphQL-specific shape. Querying a list of 20 tasks, each with its assignee field requested, calls the Task.assignee resolver from Section 2 once per task — 20 separate database queries for 20 tasks' assignees, exactly the pattern include fixed for a plain Prisma query, except there's no direct equivalent here, because GraphQL resolves each field independently by design.

the problem, made concrete
query {
  tasks {
    title
    assignee { name }   # fires the "assignee" resolver once PER task in the list
  }
}
// 1 query for the task list, then N more queries -- one per task -- for assignees

DataLoader, the library Facebook built specifically for this problem, solves it by batching: instead of each field resolver immediately querying the database, it registers the ID it needs with a shared loader and returns a not-yet-resolved promise. DataLoader collects every ID requested during the current event-loop tick, then fires a single batched query for all of them at once.

a batched DataLoader for user lookups, created per request
import DataLoader from "dataloader";

function createLoaders() {
  return {
    userLoader: new DataLoader<string, User | null>(async (ids) => {
      const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => byId.get(id) ?? null);   // must match input order
    }),
  };
}

// created fresh for every request, in the Apollo context function
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  context: async () => ({ loaders: createLoaders(), db: prisma }),
});
the resolver, rewritten to use the batched loader
Task: {
  assignee: (parent: { assigneeId: string | null }, _args: unknown, context: Context) =>
    parent.assigneeId ? context.loaders.userLoader.load(parent.assigneeId) : null,
},

With the DataLoader in place, the same 20-task query with every assignee requested fires exactly 2 queries total — one for the tasks, one batched query fetching every distinct requested user ID at once — regardless of whether the list has 20 tasks or 2,000. Creating the loaders fresh in the context function on every request is essential — a loader persisted across requests would keep returning cached results from a previous request's batch, which can serve stale data and, worse, leak one user's fetched data into a different request's response.

Any field resolver returning a related entity is a DataLoader candidate by default

It's easy to write a working resolver like Section 2's plain prisma.user.findUnique() version and only discover the N+1 cost once real client queries request that field across a list. Treat any resolver that looks up a related entity by ID as something that needs DataLoader batching from the start, the same instinct Week 15 built for Prisma relations — don't wait for a slow query log to reveal it.

4. Hands-on Exercise

Hands-on

Build a GraphQL API over the task service, then fix its N+1

Add an Apollo Server GraphQL layer alongside your existing REST API and eliminate a real batching problem.

Requirements:

  1. Write a schema covering tasks and their assignee, with at least one query, one field resolver for the relationship, and one mutation.
  2. Implement the resolvers over your existing task/user Prisma models, and confirm a query requesting only top-level fields never triggers the field resolver for the relationship.
  3. Reproduce the N+1: query a list of at least 20 tasks with assignee { name } requested on every one, and count the queries fired with Prisma's query logging enabled.
  4. Add a batched, per-request DataLoader for the assignee lookup and confirm the same query now fires exactly two database queries total, regardless of list size.
Hint

Apollo Server ships Apollo Sandbox, an interactive GraphQL playground at your server's endpoint by default in development — use it to write and run test queries interactively while you build resolvers, rather than crafting raw HTTP POST bodies by hand.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

What does it mean for Apollo Server to be "schema-first," compared to how zod-to-openapi generates its documentation?

The schema written in SDL is the source of truth that's authored first, and resolver functions are written to fulfill it — Apollo maps code onto an existing contract. zod-to-openapi works in the opposite direction: it generates documentation by inspecting already-written Zod schemas, meaning the code is the source of truth and the docs are derived from it.

Q2

Why does a client requesting only { task(id: "1") { title } } never trigger the Task.assignee resolver?

GraphQL resolves each field independently and only executes a field's resolver if that field actually appears in the client's query. A field that isn't requested simply never gets resolved — no code path for it runs at all — which is the mechanism that lets a client fetch only the exact fields it needs without a server-side flag or a separate endpoint for every possible response shape.

Q3

Why must a DataLoader instance be created fresh in the Apollo context function on every request, rather than shared globally?

A DataLoader caches the results it batches for the lifetime of the instance. A global loader shared across requests would keep returning cached results from a previous request's batch indefinitely, which can both serve stale data and, in a multi-user system, leak one user's fetched data into a different user's response. Creating a new loader per request in the context function scopes its cache correctly to that single request's lifetime.