Schema definition language, queries/mutations/subscriptions, resolvers, the N+1 problem and DataLoader, auth in GraphQL — and when to choose GraphQL over REST.
Module P-11 — GraphQL with Apollo Server
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
Consider a mobile screen showing a user's profile with their five most recent posts and follower count.
REST approach — three requests:
GET /users/42
GET /users/42/posts?limit=5
GET /users/42/followers/count
Problems:
- Three round-trips (three network waterfalls on mobile)
/users/42returns 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:
graphqlquery ProfileScreen { user(id: 42) { name avatarUrl followerCount recentPosts(limit: 5) { id title createdAt } } }
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
bashnpm install @apollo/server graphql @as-integrations/express4
typescript// src/graphql/schema.ts import { gql } from 'graphql-tag'; export const typeDefs = gql` type Query { user(id: ID!): User users(limit: Int, cursor: String): UserConnection! post(id: ID!): Post posts(authorId: ID, limit: Int, cursor: String): PostConnection! me: User } type Mutation { createUser(input: CreateUserInput!): AuthPayload! login(email: String!, password: String!): AuthPayload! createPost(input: CreatePostInput!): Post! updatePost(id: ID!, input: UpdatePostInput!): Post! deletePost(id: ID!): Boolean! publishPost(id: ID!): Post! } type Subscription { postPublished: Post! newMessage(roomId: ID!): Message! } type User { id: ID! name: String! email: String! role: UserRole! createdAt: String! posts(limit: Int): [Post!]! followerCount: Int! } type Post { id: ID! title: String! content: String! published: Boolean! author: User! createdAt: String! updatedAt: String! } type AuthPayload { accessToken: String! user: User! } type UserConnection { nodes: [User!]! nextCursor: String hasMore: Boolean! } type PostConnection { nodes: [Post!]! nextCursor: String hasMore: Boolean! } type Message { id: ID! text: String! author: User! roomId: ID! createdAt: String! } input CreateUserInput { name: String! email: String! password: String! } input CreatePostInput { title: String! content: String! } input UpdatePostInput { title: String content: String } enum UserRole { USER ADMIN MODERATOR } `;
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// src/graphql/context.ts import { Request } from 'express'; import { verifyAccessToken } from '../utils/jwt.js'; import * as usersRepo from '../repositories/users.repository.js'; export interface GraphQLContext { userId?: number; userRole?: string; req: Request; } export async function buildContext({ req }: { req: Request }): Promise<GraphQLContext> { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { return { req }; // no auth — resolvers check this themselves } try { const token = authHeader.slice(7); const payload = verifyAccessToken(token); return { userId: payload.sub as number, userRole: payload.role as string, req }; } catch { return { req }; // invalid token — treat as unauthenticated } }
Resolvers
Resolvers are functions that return the data for each field. They receive (parent, args, context, info):
typescript// src/graphql/resolvers/query.resolvers.ts import { GraphQLContext } from '../context.js'; import * as usersService from '../../services/users.service.js'; import * as postsService from '../../services/posts.service.js'; import { GraphQLError } from 'graphql'; export const Query = { me: async (_: unknown, __: unknown, context: GraphQLContext) => { if (!context.userId) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } return usersService.findById(context.userId); }, user: async (_: unknown, args: { id: string }, context: GraphQLContext) => { return usersService.findById(parseInt(args.id)); }, posts: async ( _: unknown, args: { authorId?: string; limit?: number; cursor?: string }, context: GraphQLContext, ) => { return postsService.listPosts({ authorId: args.authorId ? parseInt(args.authorId) : undefined, limit: args.limit ?? 20, cursor: args.cursor, }); }, };
typescript// src/graphql/resolvers/mutation.resolvers.ts import * as authService from '../../services/auth.service.js'; import * as postsService from '../../services/posts.service.js'; export const 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!); return true; }, publishPost: async (_: unknown, args: { id: string }, ctx: GraphQLContext) => { requireAuth(ctx); return postsService.publish(parseInt(args.id), ctx.userId!); }, }; function requireAuth(ctx: GraphQLContext) { if (!ctx.userId) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } }
Type resolvers — resolve fields that need additional data fetching:
typescript// src/graphql/resolvers/user.resolvers.ts export const User = { // Called when a query requests user.posts posts: 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.ts export const Post = { // This is where the N+1 problem lives — see DataLoader below author: async (parent: { authorId: number }) => { return usersRepo.findById(parent.authorId); }, };
The N+1 Problem and DataLoader
The most common GraphQL performance bug. A query for 20 posts with their authors:
graphqlquery { posts(limit: 20) { nodes { title author { # this fires a separate DB query for every post name } } } }
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:
bashnpm install dataloader
typescript// src/graphql/dataloaders.ts import 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 order async function batchUsers(ids: readonly number[]) { const users = await usersRepo.findByIds([...ids]); // SELECT WHERE id IN (...) const userMap = new Map(users.map(u => [u.id, u])); return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`)); } async function batchPosts(authorIds: readonly number[]) { 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) export function createLoaders() { return { users: new DataLoader<number, User>(batchUsers), postsByAuthor: new DataLoader<number, Post[]>(batchPosts), }; } export type Loaders = ReturnType<typeof createLoaders>;
Add loaders to context:
typescript// src/graphql/context.ts import { createLoaders } from './dataloaders.js'; export async function buildContext({ req }) { const loaders = createLoaders(); // fresh loaders per request — cache is per-request return { userId, userRole, req, loaders }; }
Use loaders in resolvers:
typescript// src/graphql/resolvers/post.resolvers.ts export const Post = { author: async (parent: { authorId: number }, _: unknown, ctx: GraphQLContext) => { // Instead of a direct DB call, schedule a batched load return ctx.loaders.users.load(parent.authorId); }, };
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.
Subscriptions with WebSocket
GraphQL subscriptions deliver real-time updates over WebSocket:
bashnpm install graphql-ws ws
typescript// src/graphql/resolvers/subscription.resolvers.ts import { PubSub } from 'graphql-subscriptions'; export const pubsub = new PubSub(); export const Subscription = { postPublished: { subscribe: () => pubsub.asyncIterator(['POST_PUBLISHED']), resolve: (payload: { postPublished: Post }) => payload.postPublished, }, newMessage: { subscribe: (_: unknown, args: { roomId: string }) => { return pubsub.asyncIterator([`NEW_MESSAGE_${args.roomId}`]); }, resolve: (payload: { newMessage: Message }) => payload.newMessage, }, }; // When a post is published, trigger the subscription export const Mutation = { publishPost: async (_: unknown, args, ctx: GraphQLContext) => { const post = await postsService.publish(parseInt(args.id), ctx.userId!); pubsub.publish('POST_PUBLISHED', { postPublished: post }); return post; }, };
For production, replace graphql-subscriptions PubSub (in-memory, single server) with graphql-redis-subscriptions (Redis-backed, multi-server).
Wiring Apollo Server to Express
typescript// src/graphql/server.ts import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express4'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { useServer } from 'graphql-ws/lib/use/ws'; import { WebSocketServer } from 'ws'; import http from 'http'; import { typeDefs } from './schema.js'; import { Query } from './resolvers/query.resolvers.js'; import { Mutation, Subscription } from './resolvers/mutation.resolvers.js'; import { User, Post } from './resolvers/type.resolvers.js'; import { buildContext } from './context.js'; const schema = makeExecutableSchema({ typeDefs, resolvers: { Query, Mutation, Subscription, User, Post }, }); export async function startGraphQL(app: Express, httpServer: http.Server) { // WebSocket server for subscriptions const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' }); const serverCleanup = useServer({ schema }, wsServer); const apolloServer = new ApolloServer({ schema, plugins: [ // Proper shutdown { async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); }, }; }, }, ], formatError: (formattedError) => { // Don't expose internal errors in production if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production') { return { message: 'An unexpected error occurred' }; } return formattedError; }, }); await apolloServer.start(); // Mount at /graphql — can coexist with REST routes app.use('/graphql', expressMiddleware(apolloServer, { context: buildContext })); }
typescript// src/index.ts const httpServer = http.createServer(app); await startGraphQL(app, httpServer); httpServer.listen(env.PORT);
Open http://localhost:3000/graphql — Apollo Studio Sandbox provides an in-browser IDE for exploring the schema and sending queries.
GraphQL Error Handling
typescriptimport { GraphQLError } from 'graphql'; // Not authenticated throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); // Not authorised throw new GraphQLError('You can only edit your own posts', { extensions: { code: 'FORBIDDEN' }, }); // Not found throw new GraphQLError('Post not found', { extensions: { code: 'NOT_FOUND' }, }); // Validation throw new GraphQLError('Title must be at least 3 characters', { extensions: { code: 'BAD_USER_INPUT', field: 'title' }, });
GraphQL always returns 200 OK — errors are in the errors array of the response body. The extensions.code is the convention for programmatic error handling by clients.
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.authorresolver that queries the DB directly will produce N+1 queries. Batch all related-entity lookups. - Subscriptions use WebSocket under the hood.
graphql-wsis the current standard. Replace in-memory PubSub with Redis-backed PubSub for multi-server deployments. - GraphQL errors return 200 with an
errorsarray. Useextensions.codefor 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.