Skip to content
intermediate Phase 11 · API Design

GraphQL Fundamentals

Build GraphQL schemas, resolvers, queries, mutations, and subscriptions with Apollo Server.

1h 30m
0 problems
Topic Progress 0%

Schema Design and Types

Schema Design and Types

SDL (Schema Definition Language)

GraphQL schemas are written in SDL, a type system that describes what queries are possible and what types they return. Every GraphQL API has a root schema with three operation types: Query, Mutation, and Subscription.

# schema.graphql
type Query {
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection!
  post(slug: String!): Post
  posts(filter: PostFilter, pagination: PaginationInput): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  login(email: String!, password: String!): AuthPayload!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

Scalar and Object Types

GraphQL has five built-in scalars (Int, Float, String, Boolean, ID) and you can define custom scalars like DateTime. Object types define the shape of data. Non-null markers (!) enforce that a field always returns a value.

type User {
  id: ID!
  name: String!
  email: String!
  role: Role!
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  slug: String!
  content: String!
  published: Boolean!
  author: User!
  tags: [String!]!
  comments(first: Int, after: String): CommentConnection!
  commentCount: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Comment {
  id: ID!
  content: String!
  author: User
  replies: [Comment!]!
  createdAt: DateTime!
}

enum Role { USER EDITOR ADMIN }
scalar DateTime

Relay-Style Connection Pattern

For pagination, GraphQL uses the Relay connection specification. A Connection type wraps a list of edges, each containing a node (the actual data) and a cursor for pagination. PageInfo tells the client whether more pages exist.

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

Input Types

Use input types for mutation arguments instead of passing multiple scalar parameters. This keeps mutations clean and makes validation easier on the server side.

input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
  published: Boolean = false
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  published: Boolean
}

input PostFilter {
  status: PostStatus
  authorId: ID
  search: String
}

input PaginationInput {
  first: Int
  after: String
}

Resolvers and Data Loaders

Resolvers and Data Loaders

Apollo Server Setup

Apollo Server is the most popular Node.js GraphQL server. It handles parsing queries, executing resolvers, and formatting errors. The context function runs per-request and is where you attach database connections, authentication info, and DataLoaders.

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { createContext } from './context.js';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  formatError: (formattedError, error) => {
    console.error('GraphQL Error:', error);
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return { ...formattedError, message: 'Internal server error' };
    }
    return formattedError;
  },
});

await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
  context: async ({ req }) => createContext({ req }),
}));

Resolver Structure

Every resolver receives four arguments: parent (the previous resolver's return value), args (arguments from the query), context (per-request shared state), and info (query metadata). Field-level resolvers let you resolve nested relationships lazily.

import { GraphQLError } from 'graphql';

export const resolvers = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return loaders.user.load(id);
    },
    users: async (_, { filter, pagination }, { db }) => {
      const where = buildUserFilter(filter);
      const limit = pagination?.first || 20;
      const cursor = pagination?.after ? decodeCursor(pagination.after) : null;
      if (cursor) where.created_at = { $lt: cursor };

      const users = await db.users.find(where)
        .sort({ created_at: -1 })
        .limit(limit + 1);

      const hasNext = users.length > limit;
      const data = hasNext ? users.slice(0, limit) : users;
      return {
        edges: data.map(u => ({ node: u, cursor: u.created_at.toISOString() })),
        pageInfo: {
          hasNextPage: hasNext,
          endCursor: data.length ? data[data.length - 1].created_at.toISOString() : null,
        },
        totalCount: await db.users.count(where),
      };
    },
  },
  User: {
    posts: async (parent, { first, after }, { loaders }) => {
      return loaders.userPosts.load({ userId: parent.id, first, after });
    },
  },
  Post: {
    author: async (parent, _, { loaders }) => {
      return loaders.user.load(parent.authorId);
    },
    comments: async (parent, { first, after }, { loaders }) => {
      return loaders.postComments.load({ postId: parent.id, first, after });
    },
  },
};

DataLoader for the N+1 Problem

The N+1 problem occurs when resolving a list of items triggers one database query per item for a related field. If you query 100 posts and each triggers a SELECT * FROM users WHERE id = ? for the author, you get 101 queries. DataLoader batches and deduplicates within a single event loop tick.

import DataLoader from 'dataloader';

function createLoaders(db) {
  return {
    user: new DataLoader(async (ids) => {
      const users = await db.users.findByIds(ids);
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id) || null);
    }),
    userPosts: new DataLoader(async (queries) => {
      const userIds = queries.map(q => q.userId);
      const posts = await db.posts.find({
        authorId: { $in: userIds },
        published: true,
      });
      const grouped = new Map(userIds.map(id => [id, []]));
      posts.forEach(p => {
        const group = grouped.get(p.authorId);
        if (group) group.push(p);
      });
      return queries.map(q => {
        const items = grouped.get(q.userId) || [];
        return items.slice(0, q.first || 20);
      });
    }),
  };
}

The key insight is that DataLoader's load(id) method does not execute immediately. It collects all calls made during the same tick and dispatches them as a single batch. The loader function receives an array of IDs in the same order they were requested, and must return results in that same order.

Client-Side GraphQL with Apollo

Client-Side GraphQL with Apollo

Apollo Client Setup

Apollo Client manages the cache, handles request deduplication, and provides reactive hooks for React components. The InMemoryCache normalizes responses so every object is stored once by its id, enabling automatic updates when mutations modify related data.

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({ uri: '/graphql' });

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('accessToken');
  return {
    headers: { ...headers, authorization: token ? `Bearer ${token}` : '' },
  };
});

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            keyArgs: ['filter'],
            merge(existing, { edges, pageInfo, totalCount }, { args }) {
              return {
                edges: [...(existing?.edges || []), ...edges],
                pageInfo,
                totalCount,
              };
            },
          },
        },
      },
    },
  }),
});

The keyArgs option tells the cache which arguments affect the identity of the field. Setting keyArgs: ['filter'] means the same filter always maps to the same cache entry, while pagination arguments are ignored for cache keys — enabling fetchMore to append results.

Queries and Mutations with Hooks

import { useQuery, useMutation, gql } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($filter: PostFilter, $pagination: PaginationInput) {
    posts(filter: $filter, pagination: $pagination) {
      edges {
        node { id title slug author { name } createdAt }
        cursor
      }
      pageInfo { hasNextPage endCursor }
      totalCount
    }
  }
`;

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id title slug
    }
  }
`;

function PostList() {
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { filter: { status: 'published' }, pagination: { first: 10 } },
  });
  const [createPost] = useMutation(CREATE_POST, {
    refetchQueries: [{ query: GET_POSTS }],
  });

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <article key={node.id}>
          <h2>{node.title}</h2>
          <p>by {node.author.name}</p>
        </article>
      ))}
      {data.posts.pageInfo.hasNextPage && (
        <button onClick={() => fetchMore({
          variables: { pagination: { after: data.posts.pageInfo.endCursor } },
        })}>Load more</button>
      )}
    </div>
  );
}

Optimistic UI and Cache Updates

Apollo Client can update the UI immediately before the server responds, making mutations feel instant. Use optimisticResponse for simple cases and update function for complex cache modifications.

const [createPost] = useMutation(CREATE_POST, {
  optimisticResponse: {
    createPost: {
      __typename: 'Post',
      id: 'temp-id',
      title: variables.input.title,
      slug: variables.input.title.toLowerCase().replace(/\s+/g, '-'),
      author: { __typename: 'User', id: 'current-user-id', name: 'You' },
      createdAt: new Date().toISOString(),
    },
  },
  update(cache, { data: { createPost } }) {
    const existing = cache.readQuery({ query: GET_POSTS });
    cache.writeQuery({
      query: GET_POSTS,
      data: {
        posts: {
          ...existing.posts,
          edges: [{ __typename: 'PostEdge', node: createPost, cursor: createPost.id },
                  ...existing.posts.edges],
        },
      },
    });
  },
});

Subscriptions and Authentication

Subscriptions and Authentication

GraphQL Subscriptions

Subscriptions provide real-time data over WebSocket connections. They are built on the publish-subscribe pattern: the server publishes events when data changes, and subscribed clients receive those events. Apollo Server uses graphql-ws for the WebSocket transport.

import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();
const COMMENT_ADDED = 'COMMENT_ADDED';

const typeDefs = makeExecutableSchema({
  typeDefs: `type Subscription {
    commentAdded(postId: ID!): Comment!
  }`,
  resolvers: {
    Subscription: {
      commentAdded: {
        subscribe: (_, { postId }, { user }) => {
          if (!user) throw new Error('Not authenticated');
          return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
        },
      },
    },
  },
});

// In a mutation resolver, publish after creating the comment
const resolvers = {
  Mutation: {
    addComment: async (_, { postId, content }, { user, db }) => {
      const comment = await db.comments.create({
        postId, content, authorId: user.id,
      });
      pubsub.publish(`COMMENT_ADDED_${postId}`, {
        commentAdded: comment,
      });
      return comment;
    },
  },
};

WebSocket Server Setup

import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { expressMiddleware } from '@apollo/server/express4';

const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });

const serverCleanup = useServer({
  schema,
  context: async (ctx) => {
    const token = ctx.connectionParams?.authToken;
    const user = token ? await verifyToken(token) : null;
    return { user };
  },
}, wsServer);

const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

Context-Based Authentication

GraphQL does not have built-in HTTP methods, so authentication is handled in the context function. Every resolver can access the authenticated user through context.

import jwt from 'jsonwebtoken';

async function createContext({ req }) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  let user = null;

  if (token) {
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      user = await db.users.findById(decoded.userId);
    } catch (err) {
      // Token invalid or expired — proceed as unauthenticated
    }
  }

  return {
    user,
    loaders: createLoaders(db),
    db,
  };
}

Authorization Directives

Use custom directives to enforce authorization at the schema level. This keeps authorization logic out of resolver code and makes permissions visible in the schema itself.

directive @auth(requires: Role = USER) on FIELD_DEFINITION

type Mutation {
  createPost(input: CreatePostInput!): Post! @auth(requires: USER)
  deletePost(id: ID!): Boolean! @auth(requires: EDITOR)
  manageUsers: [User!]! @auth(requires: ADMIN)
}
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

function authDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
      if (!authDirective) return fieldConfig;

      const { resolve = defaultFieldResolver } = fieldConfig;
      const requiredRole = authDirective.requires;

      fieldConfig.resolve = async (parent, args, context, info) => {
        if (!context.user) throw new GraphQLError('Not authenticated');
        if (!hasRole(context.user, requiredRole)) {
          throw new GraphQLError('Insufficient permissions');
        }
        return resolve(parent, args, context, info);
      };
      return fieldConfig;
    },
  });
}

Quiz

1. What problem does DataLoader solve in a GraphQL API?

Question 1 options

2. Why does GraphQL use cursor-based pagination instead of offset-based?

Question 2 options

3. What is the purpose of the `context` function in Apollo Server?

Question 3 options

Flashcards

Question

What is the N+1 problem in GraphQL?

Answer

The N+1 problem occurs when resolving a list of N items triggers N additional queries for a related field. For example, fetching 100 posts and then running a separate query for each post's author results in 101 database queries. DataLoader fixes this by batching all the author lookups into a single query.

Question

What is the Relay connection specification?

Answer

A pagination pattern where a Connection type contains edges (each with a node and cursor) and a PageInfo object (hasNextPage, endCursor). The cursor is an opaque string that clients pass as the `after` argument to fetch the next page. This standardizes cursor-based pagination across GraphQL APIs.

Question

How does Apollo Client's InMemoryCache normalization work?

Answer

Apollo Client extracts every object with an `id` field and stores it once in a flat map keyed by `__typename:id`. When a query returns `{ user: { id: '1', name: 'Alice' } }`, the cache stores it as `User:1`. Any other query returning the same object updates the same cache entry, so all components re-render with the new data automatically.

Revision Notes

Key Takeaways

  • 1. GraphQL schemas are defined in SDL with Query, Mutation, and Subscription as root operation types
  • 2. Use the Relay connection pattern (edges/node/cursor/pageInfo) for all list pagination
  • 3. Input types keep mutation signatures clean and enable server-side validation
  • 4. DataLoader batches and deduplicates database queries within a single event loop tick to solve N+1
  • 5. The context function runs per-request and provides auth, db, and loaders to all resolvers
  • 6. Apollo Client's InMemoryCache normalizes objects by __typename:id for automatic cache updates
  • 7. Subscriptions use WebSocket connections and the pub/sub pattern for real-time data
  • 8. Custom schema directives like @auth enforce authorization rules declaratively

Interview Tips

  • Explain the N+1 problem with a concrete example and how DataLoader's batch function solves it
  • Compare REST and GraphQL tradeoffs: overfetching vs overcomplicating, caching, file uploads, error handling
  • Walk through how you would design a GraphQL schema for a blog platform with users, posts, and comments
  • Describe how cursor pagination works and why it is better than offset for large datasets
  • Explain how Apollo Client normalization enables automatic UI updates after mutations
  • Discuss GraphQL security: query depth limiting, query complexity analysis, and persisted queries

Cheat Sheet

GraphQL Cheat Sheet

Schema Types:

  • Scalar: Int, Float, String, Boolean, ID + custom (DateTime)
  • Object: type User { id: ID!, name: String! }
  • Enum: enum Role { USER EDITOR ADMIN }
  • Input: input CreateUserInput { name: String! }
  • Interface & Union: for polymorphic types

Pagination Pattern (Relay):

type Connection { edges: [Edge!]!, pageInfo: PageInfo!, totalCount: Int! }
type Edge { node: Node!, cursor: String! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }

Query: users(pagination: { first: 10, after: "cursor" })

Resolver Signature:
(parent, args, context, info) => Promise<T>

  • parent: previous resolver return value
  • args: query arguments
  • context: per-request shared state (db, user, loaders)
  • info: query metadata

DataLoader Pattern:

new DataLoader(async (ids) => {
  const items = await db.findByIds(ids);
  return ids.map(id => items.find(i => i.id === id) || null);
});

Apollo Client Key Concepts:

  • InMemoryCache normalizes by __typename:id
  • keyArgs controls which arguments affect cache identity
  • fetchMore appends to existing cache entries
  • optimisticResponse updates UI before server responds
  • refetchQueries re-runs queries after mutation

Subscriptions:

  • Transport: WebSocket (graphql-ws protocol)
  • Pattern: publish/subscribe with PubSub
  • Setup: subscribe resolver returns an AsyncIterator

Security:

  • Disable introspection in production
  • Limit query depth (5-10 levels)
  • Use query complexity analysis
  • Persisted queries for production clients
  • Auth via context, not HTTP headers per-field