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.
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.
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.
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:
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.
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.
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 }),
});
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.
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
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:
- Write a schema covering tasks and their assignee, with at least one query, one field resolver for the relationship, and one mutation.
- 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.
- 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. - 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.
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?
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?
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?
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.