Schema definition language, queries/mutations/subscriptions, resolvers, the N+1 problem and DataLoader, auth in GraphQL — and when to choose GraphQL over REST.
What this module covers: REST and GraphQL solve the same problem — exposing data over HTTP — with different trade-offs. GraphQL gives clients precise control over what data they receive, eliminates over-fetching and under-fetching, and makes schema changes visible and versioned. This module covers the Schema Definition Language, building a GraphQL API with Apollo Server and Express, writing resolvers for queries and mutations, implementing subscriptions for real-time data, solving the N+1 problem with DataLoader, adding authentication, and knowing when to reach for GraphQL instead of REST.
REST vs GraphQL: The Core Trade-Off
A GraphQL query is a restaurant order where you list precisely which items you want off the menu; REST is a fixed set-menu — you get everything on the plate whether you wanted the pickled onions or not.
Consider a mobile screen showing a user's profile with their five most recent posts and follower count.
REST approach — three requests:
text
Problems:
Three round-trips (three network waterfalls on mobile)
/users/42 returns 20 fields; the screen needs 4
You're either over-fetching (wasting bandwidth) or under-fetching (needing more calls)
Adding a new screen requirement changes which endpoints you need to call
GraphQL approach — one request, exactly the data needed:
graphql
One request, four fields from user, three fields per post. The client defines the shape. Adding a new screen just changes the query.
GraphQL shines when: you have multiple clients with different data needs (web, mobile, partner integrations), your data is deeply relational, or your API is consumed by teams you don't control.
REST shines when: you have a simple CRUD API, need HTTP caching, are building a public API that tools should be able to discover automatically, or your team knows REST well.
Setup: Apollo Server with Express
bash
typescript
Context: Authentication in GraphQL
GraphQL doesn't have middleware like Express. Authentication goes into the context function — called once per request and passed to every resolver:
typescript
Resolvers
Resolvers are functions that return the data for each field. They receive (parent, args, context, info):
typescript
typescript
Type resolvers — resolve fields that need additional data fetching:
typescript
Field-Level Auth with Schema Directives
Repeating requireAuth(ctx) as the first line of every resolver that needs it works, but it doesn't scale cleanly: it's easy to forget on a new mutation, and the auth rule lives in resolver code, far from the schema field it's protecting. Schema directives push the check into the type definition itself, so the requirement is visible right next to the field:
graphql
Implement it once with @graphql-tools/utils's schema mapper, then apply it to the built schema:
typescript
typescript
createPost's resolver body no longer needs to know anything about auth — it just creates the post. The rule is declarative, shows up when a client introspects the schema, and can't be silently skipped by a new resolver that forgets to call requireAuth.
The N+1 Problem and DataLoader
The most common GraphQL performance bug. A query for 20 posts with their authors:
graphql
The Post.author resolver runs 20 times, each doing SELECT * FROM users WHERE id = ?. That's 21 queries total (1 for posts + 20 for authors).
DataLoader batches and caches these lookups:
bash
typescript
Add loaders to context:
typescript
Use loaders in resolvers:
typescript
Now the same query for 20 posts produces just 2 queries: one for posts, one SELECT WHERE id IN (1, 5, 7, ...) for all authors. DataLoader collected all the load() calls in a tick, batched them, and resolved them together.
DataLoader batches the queries a well-formed request makes — it doesn't stop a malformed one from being absurdly expensive in the first place. A public GraphQL endpoint with introspection left on once let an automated scanner walk the schema, notice that posts returns author and author returns posts again, and submit a query nesting posts { author { posts { author { ... } } } } fifteen levels deep. Each level fanned out faster than DataLoader could batch it — by level 10 the resolver tree was issuing tens of thousands of author lookups — and the API went down mid-demo.
Query Depth and Complexity Limiting
Two GraphQL requests can look equally simple and cost wildly different amounts to execute — a deeply nested selection set turns into an exponential number of resolver calls even with DataLoader batching each level. This is one of GraphQL's best-known DoS vectors, and it's why production schemas need a hard limit independent of what DataLoader can optimise away.
Depth limiting rejects queries nested beyond a fixed number of levels:
bash
typescript
A query like the 15-level posts { author { posts { ... } } } attack above never reaches a resolver — it's rejected at validation time, before any database call is made.
Cost/complexity analysis goes further than depth alone: a query can be shallow but still expensive if it requests large lists at every level (posts(limit: 1000) { comments(limit: 1000) { ... } } is only two levels deep but touches a million rows). Assign each field a cost and reject queries whose total exceeds a budget:
bash
typescript
Depth limiting is cheap insurance to add to every schema regardless of size; cost analysis is worth the extra tuning once a schema has fields whose expense varies a lot by argument (large limits, expensive aggregations).
Subscriptions with WebSocket
GraphQL subscriptions deliver real-time updates over WebSocket:
bash
typescript
pubsub is exported from this file so the Mutation.publishPost resolver (in mutation.resolvers.ts) can import it and call pubsub.publish('POST_PUBLISHED', ...) after a post is published — that's the trigger that wakes up any client subscribed to postPublished. Keeping a single publishPost resolver, wired to the same pubsub instance the Subscription reads from, avoids the easy mistake of defining a second Mutation.publishPost that never publishes anything, leaving postPublished subscriptions silently dead.
For production, replace graphql-subscriptions PubSub (in-memory, single server) with graphql-redis-subscriptions (Redis-backed, multi-server).
Wiring Apollo Server to Express
typescript
typescript
Open http://localhost:3000/graphql — Apollo Studio Sandbox provides an in-browser IDE for exploring the schema and sending queries.
This sandbox landing page is a development-only convenience: Apollo Server 4 disables it automatically when NODE_ENV=production, returning a plain "GraphQL server ready" response instead. If you need the Sandbox (or a similar explorer) in production, you have to opt back in explicitly via the ApolloServerPluginLandingPageLocalDefault (or a remote/embedded variant) plugin.
Disabling Introspection in Production
The Sandbox landing page and introspection are two different switches. Apollo Server 4 auto-disables the landing page in production, but introspection itself — the ability for any client to query __schema and __type and get back every type, field, and mutation your API exposes — stays enabled unless you turn it off explicitly:
typescript
With introspection left on, a public endpoint hands its entire schema to anything that asks — including fields and mutations you never wired a UI to, and the nested relationships (like posts → author → posts) that make deep-nesting attacks possible in the first place (see the depth-limiting story above). Turning it off in production doesn't break your own frontend — it already has the schema baked in at build time, from a local dev server or a schema registry, not from querying production at runtime.
Persisted Queries
Disabling introspection stops schema discovery, but a client can still send arbitrary ad-hoc queries against fields it already knows about. Persisted queries close that off too: the server only executes a fixed, pre-registered set of queries, and rejects anything else outright.
Apollo Server ships Automatic Persisted Queries (APQ) support — a client sends a hash of a query on first use, the server registers it, and subsequent requests replay it by hash instead of resending the full query text:
typescript
APQ by itself still executes any query the first time it sees it — it optimises bandwidth, not access control. Locking a server down to only a pre-approved list of operations, so an unregistered query is rejected even on its first attempt, requires safelisting (Apollo GraphOS's registered-operations feature, or a hand-rolled allowlist keyed by query hash and checked in the context function before execution). For most internal APIs, introspection off plus depth limiting closes the practical DoS surface; full safelisting is worth the extra operational overhead mainly for public-facing endpoints under active attack.
GraphQL Error Handling
typescript
Execution errors — thrown inside a resolver, like this one — return HTTP 200 with the error in the errors array; the request reached and ran your resolver, it just failed partway through. This is different from parse/validation errors, which are rejected before any resolver runs and return a 4xx instead — for example, a malformed query, or a query that fails a custom validation rule like the depth-limit rule from the earlier section, never reaches a resolver at all. The extensions.code is the convention for programmatic error handling by clients on the 200-with-errors path.
When to Choose GraphQL vs REST
Choose GraphQL when:
Multiple clients (web, mobile, partner) need different data shapes from the same API
Data is highly relational (users → posts → comments → likes → users…)
You want self-documenting APIs — the schema IS the documentation
Your frontend team wants to move fast without waiting for backend changes
Rapid iteration on new screens/features
Choose REST when:
Simple CRUD operations with predictable resource shapes
You need HTTP-level caching (CDN caching of GET responses)
Building a public API that must be discoverable by generic tools
File uploads are a primary concern (GraphQL handles these awkwardly)
Your team knows REST deeply and the API is not complex
Mixing both is valid. Many large companies run REST for public APIs (stable, cacheable, simple) and GraphQL for internal front-end APIs (flexible, fast to iterate). Your Node.js app can serve both on the same server.
Summary
GraphQL solves over-fetching and under-fetching — clients request exactly the fields they need in a single round-trip. REST solves discoverability, caching, and simplicity.
Schema Definition Language is the contract. Define types, queries, mutations, and subscriptions before writing a single resolver.
Context function runs once per request — the right place to verify tokens and attach user data for all resolvers.
DataLoader is non-negotiable. Any Post.author resolver that queries the DB directly will produce N+1 queries. Batch all related-entity lookups.
Subscriptions use WebSocket under the hood. graphql-ws is the current standard. Replace in-memory PubSub with Redis-backed PubSub for multi-server deployments.
GraphQL errors return 200 with an errors array. Use extensions.code for programmatic handling. Never expose internal error details in production.
Next: background jobs with BullMQ — deferring slow work off the request path, retries with exponential backoff, scheduled jobs, job priorities, and concurrency control.
Knowledge Check
In the context of GraphQL, what problem does DataLoader primarily solve?
In an Apollo Server Express setup, where is the most appropriate place to perform authentication (e.g., verifying a JWT) so that the user's information is available to all resolvers?
How does error handling in a standard GraphQL API typically differ from a REST API?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// src/graphql/resolvers/mutation.resolvers.tsimport*as authService from'../../services/auth.service.js';import*as postsService from'../../services/posts.service.js';import{ pubsub }from'./subscription.resolvers.js';exportconst Mutation ={createUser:async(_:unknown, args:{ input: CreateUserInput })=>{return authService.register(args.input);},login:async(_:unknown, args:{ email:string; password:string})=>{return authService.login(args);},createPost:async(_:unknown, args:{ input: CreatePostInput }, ctx: GraphQLContext)=>{requireAuth(ctx);return postsService.create({...args.input, authorId: ctx.userId!});},deletePost:async(_:unknown, args:{ id:string}, ctx: GraphQLContext)=>{requireAuth(ctx);await postsService.delete(parseInt(args.id), ctx.userId!);returntrue;},publishPost:async(_:unknown, args:{ id:string}, ctx: GraphQLContext)=>{requireAuth(ctx);const post =await postsService.publish(parseInt(args.id), ctx.userId!); pubsub.publish('POST_PUBLISHED',{ postPublished: post });return post;},};// After the @auth directive is wired in below, these resolvers no longer need// their own requireAuth(ctx) call — the directive rejects unauthenticated// requests before the resolver ever runs. Once the directive is in place://// createPost: async (_, args, ctx) => {// return postsService.create({ ...args.input, authorId: ctx.userId! });// },// deletePost: async (_, args, ctx) => {// await postsService.delete(parseInt(args.id), ctx.userId!);// return true;// },// publishPost: async (_, args, ctx) => {// const post = await postsService.publish(parseInt(args.id), ctx.userId!);// pubsub.publish('POST_PUBLISHED', { postPublished: post });// return post;// },//// Leaving both requireAuth(ctx) and @auth in place isn't wrong, just redundant// — pick one. The resolver body no longer needs to know anything about auth.functionrequireAuth(ctx: GraphQLContext){if(!ctx.userId){thrownewGraphQLError('Authentication required',{ extensions:{ code:'UNAUTHENTICATED'},});}}
// src/graphql/resolvers/user.resolvers.tsexportconst User ={// Called when a query requests user.postsposts:async(parent:{ id:number}, args:{ limit?:number})=>{return postsRepo.findByAuthor(parent.id,{ limit: args.limit ??10});},followerCount:async(parent:{ id:number})=>{return followsRepo.countByUserId(parent.id);},};// src/graphql/resolvers/post.resolvers.tsexportconst Post ={// This is where the N+1 problem lives — see DataLoader belowauthor:async(parent:{ authorId:number})=>{return usersRepo.findById(parent.authorId);},};
directive@auth(requires:UserRole=USER)onFIELD_DEFINITIONtypeQuery{# user/users return email addresses — apply @auth here too, not just to# mutations, or any unauthenticated caller can enumerate every user's emailuser(id:ID!):User@authusers(limit:Int,cursor:String):UserConnection!@auth}typeMutation{createPost(input:CreatePostInput!):Post!@authdeletePost(id:ID!):Boolean!@auth(requires:ADMIN)}
query{posts(limit:20){nodes{titleauthor{# this fires a separate DB query for every postname}}}}
npminstall dataloader
// src/graphql/dataloaders.tsimport DataLoader from'dataloader';import*as usersRepo from'../repositories/users.repository.js';import*as postsRepo from'../repositories/posts.repository.js';// Batch function: receives an array of IDs, returns an array of results in the same orderasyncfunctionbatchUsers(ids:readonlynumber[]){const users =await usersRepo.findByIds([...ids]);// SELECT WHERE id IN (...)const userMap =newMap(users.map(u =>[u.id, u]));return ids.map(id => userMap.get(id)??newError(`User ${id} not found`));}asyncfunctionbatchPosts(authorIds:readonlynumber[]){const posts =await postsRepo.findByAuthorIds([...authorIds]);return authorIds.map(id => posts.filter(p => p.authorId === id));}// Create loaders — one per request (never reuse across requests)exportfunctioncreateLoaders(){return{ users:newDataLoader<number, User>(batchUsers), postsByAuthor:newDataLoader<number, Post[]>(batchPosts),};}exporttypeLoaders= ReturnType<typeof createLoaders>;
// src/graphql/resolvers/post.resolvers.tsexportconst Post ={author:async(parent:{ authorId:number}, _:unknown, ctx: GraphQLContext)=>{// Instead of a direct DB call, schedule a batched loadreturn ctx.loaders.users.load(parent.authorId);},};
npminstall graphql-depth-limit
// src/graphql/server.tsimport depthLimit from'graphql-depth-limit';const apolloServer =newApolloServer({ schema, validationRules:[depthLimit(6)],// reject any query nested more than 6 levels deep});
npminstall graphql-query-complexity
import{ createComplexityLimitRule }from'graphql-query-complexity';const apolloServer =newApolloServer({ schema, validationRules:[createComplexityLimitRule(1000,{// list fields cost more per requested item — a `limit: 100` field// is charged 100x the cost of a scalar field listFactor:10,onCost:(cost)=> logger.debug({ cost },'Query complexity'),}),],});
import{ GraphQLError }from'graphql';// Not authenticatedthrownewGraphQLError('Authentication required',{ extensions:{ code:'UNAUTHENTICATED'},});// Not authorisedthrownewGraphQLError('You can only edit your own posts',{ extensions:{ code:'FORBIDDEN'},});// Not foundthrownewGraphQLError('Post not found',{ extensions:{ code:'NOT_FOUND'},});// ValidationthrownewGraphQLError('Title must be at least 3 characters',{ extensions:{ code:'BAD_USER_INPUT', field:'title'},});