Module P-11·21 min read

Schema definition language, queries/mutations/subscriptions, resolvers, the N+1 problem and DataLoader, auth in GraphQL — and when to choose GraphQL over REST.

Written by Jatin Jain Saraf · Architect of SupraScan

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

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:

GET /users/42
GET /users/42/posts?limit=5
GET /users/42/followers/count

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:

query 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

npm install @apollo/server graphql @as-integrations/express4
// 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:

// 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):

// 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,
    });
  },
};
// src/graphql/resolvers/mutation.resolvers.ts
import * as authService from '../../services/auth.service.js';
import * as postsService from '../../services/posts.service.js';
import { pubsub } from './subscription.resolvers.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);
    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.
 
function requireAuth(ctx: GraphQLContext) {
  if (!ctx.userId) {
    throw new GraphQLError('Authentication required', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.