GraphQL Production

Comprehensive guide to production GraphQL APIs with schema design, performance optimization, security, and federation

GraphQL API Apollo TypeScript Performance Federation Security

1. GraphQL Fundamentals

GraphQL vs REST

Aspect REST GraphQL
Endpoints Multiple endpoints (/users, /posts) Single endpoint (/graphql)
Data Fetching Over-fetching or under-fetching Request exactly what you need
Versioning URL versioning (/api/v1/users) Schema evolution, no versioning
Type System Optional (OpenAPI/Swagger) Strong typing built-in
Documentation Manual or generated Self-documenting (introspection)
Caching HTTP caching (easy) Requires custom logic

Core Concepts

# Schema Definition Language (SDL)

# Object Types
type User {
  id: ID!              # Non-null ID
  name: String!        # Non-null String
  email: String!
  age: Int
  posts: [Post!]!      # Non-null array of non-null Posts
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  published: Boolean!
}

# Input Types (for mutations)
input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

input UpdateUserInput {
  name: String
  email: String
  age: Int
}

# Enums
enum UserRole {
  ADMIN
  USER
  GUEST
}

# Interfaces
interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
}

# Unions
union SearchResult = User | Post | Comment

# Root Types
type Query {
  # Fetch single user
  user(id: ID!): User

  # Fetch all users with pagination
  users(
    first: Int = 10
    after: String
    orderBy: UserOrderBy
  ): UserConnection!

  # Search across types
  search(query: String!): [SearchResult!]!
}

type Mutation {
  # Create user
  createUser(input: CreateUserInput!): User!

  # Update user
  updateUser(id: ID!, input: UpdateUserInput!): User!

  # Delete user
  deleteUser(id: ID!): Boolean!
}

type Subscription {
  # Subscribe to new posts
  postCreated: Post!

  # Subscribe to user updates
  userUpdated(id: ID!): User!
}

# Custom Scalars
scalar DateTime
scalar Email
scalar URL

# Directives
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION

GraphQL Operations

# Query
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    posts {
      id
      title
    }
  }
}

# Query with Fragments
fragment UserFields on User {
  id
  name
  email
}

query GetUsers {
  users {
    ...UserFields
    posts {
      id
      title
    }
  }
}

# Mutation
mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    email
  }
}

# Variables
{
  "id": "1",
  "input": {
    "name": "John Doe",
    "email": "[email protected]"
  }
}

# Aliases
query {
  user1: user(id: "1") {
    name
  }
  user2: user(id: "2") {
    name
  }
}

# Inline Fragments (for unions)
query Search($query: String!) {
  search(query: $query) {
    ... on User {
      name
      email
    }
    ... on Post {
      title
      content
    }
    ... on Comment {
      text
    }
  }
}

GraphQL Request Flow

┌─────────────────────────────────────────────────────────────────┐
│                         CLIENT REQUEST                          │
│  query { user(id: "1") { name, posts { title } } }             │
└────────────────────────────────┬────────────────────────────────┘
                                 │
                                 ▼
                    ┌────────────────────────┐
                    │     GraphQL Parser     │
                    │  - Parse query string  │
                    │  - Build AST           │
                    └───────────┬────────────┘
                                │
                                ▼
                    ┌────────────────────────┐
                    │    Query Validation    │
                    │  - Check syntax        │
                    │  - Validate types      │
                    │  - Check auth          │
                    └───────────┬────────────┘
                                │
                                ▼
                    ┌────────────────────────┐
                    │   Query Execution      │
                    │  - Resolve fields      │
                    │  - Call resolvers      │
                    │  - Parallel execution  │
                    └───────────┬────────────┘
                                │
                ┌───────────────┼───────────────┐
                │               │               │
                ▼               ▼               ▼
        ┌──────────────┐ ┌──────────┐ ┌──────────────┐
        │   user()     │ │ posts()  │ │  DataLoader  │
        │   resolver   │ │ resolver │ │  (batching)  │
        └──────┬───────┘ └────┬─────┘ └──────┬───────┘
               │              │                │
               └──────────────┴────────────────┘
                              │
                              ▼
                ┌──────────────────────────┐
                │    Data Sources          │
                │  - Database              │
                │  - REST APIs             │
                │  - Microservices         │
                └─────────────┬────────────┘
                              │
                              ▼
                ┌──────────────────────────┐
                │    Format Response       │
                │  { "data": { ... } }     │
                └──────────────────────────┘
                    
Pro Tip: Use fragments to avoid repeating field selections and make queries more maintainable. Fragment colocation keeps component data requirements close to the component.

2. Schema Design Best Practices

Naming Conventions

# Good Naming Conventions

# Types: PascalCase
type User {}
type UserProfile {}

# Fields: camelCase
type User {
  firstName: String!
  lastName: String!
  emailAddress: String!
}

# Enums: UPPER_SNAKE_CASE
enum UserStatus {
  ACTIVE
  INACTIVE
  SUSPENDED
}

# Input Types: suffix with "Input"
input CreateUserInput {
  name: String!
  email: String!
}

# Connections: suffix with "Connection"
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

# Edges: suffix with "Edge"
type UserEdge {
  node: User!
  cursor: String!
}

# Mutations: verb + noun
type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
}

# Mutation Payloads: verb + noun + "Payload"
type CreateUserPayload {
  user: User
  errors: [Error!]
}

type UpdateUserPayload {
  user: User
  errors: [Error!]
}

type DeleteUserPayload {
  deletedId: ID
  success: Boolean!
}

Pagination Patterns

# Cursor-Based Pagination (Relay-style, Recommended)

type Query {
  users(
    first: Int         # Forward pagination
    after: String      # Cursor
    last: Int          # Backward pagination
    before: String     # Cursor
  ): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# Usage
query {
  users(first: 10, after: "cursor123") {
    edges {
      node {
        id
        name
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

# Offset-Based Pagination (Simpler, but less efficient)

type Query {
  users(
    limit: Int = 10
    offset: Int = 0
  ): UserList!
}

type UserList {
  items: [User!]!
  total: Int!
  hasMore: Boolean!
}

# Usage
query {
  users(limit: 10, offset: 0) {
    items {
      id
      name
    }
    total
    hasMore
  }
}

Error Design

# Union Error Pattern (Recommended)

type Mutation {
  createUser(input: CreateUserInput!): CreateUserResult!
}

union CreateUserResult = CreateUserSuccess | CreateUserError

type CreateUserSuccess {
  user: User!
}

type CreateUserError {
  message: String!
  code: ErrorCode!
  field: String
}

enum ErrorCode {
  VALIDATION_ERROR
  DUPLICATE_EMAIL
  UNAUTHORIZED
  NOT_FOUND
}

# Usage
mutation {
  createUser(input: { name: "John", email: "[email protected]" }) {
    ... on CreateUserSuccess {
      user {
        id
        name
      }
    }
    ... on CreateUserError {
      message
      code
      field
    }
  }
}

# Errors Field Pattern

type CreateUserPayload {
  user: User
  errors: [UserError!]
}

type UserError {
  message: String!
  path: [String!]
  extensions: ErrorExtensions
}

type ErrorExtensions {
  code: String!
  field: String
}

Nullability Best Practices

# Use Non-Null (!) Carefully

# Good: Field always exists
type User {
  id: ID!           # User always has an ID
  name: String!     # User always has a name
  email: String!    # User always has an email
}

# Good: Optional fields are nullable
type User {
  id: ID!
  name: String!
  bio: String       # Bio is optional
  website: String   # Website is optional
}

# Good: Non-null array with non-null items
type User {
  id: ID!
  posts: [Post!]!   # Array always exists, items never null
}

# Bad: Makes schema brittle
type User {
  id: ID!
  posts: [Post!]!
  comments: [Comment!]!
  followers: [User!]!
}

# Better: Allow graceful degradation
type User {
  id: ID!
  posts: [Post!]      # Can return null if error
  comments: [Comment!]
  followers: [User!]
}

# Best: Use connections for lists
type User {
  id: ID!
  posts(first: Int, after: String): PostConnection
}
Common Pitfall: Using too many non-null fields makes your schema brittle. If a single field fails, the entire parent becomes null. Use non-null only for truly required fields.

3. Resolver Patterns

Basic Resolvers

/**
 * Resolver Signature
 *
 * (parent, args, context, info) => result
 *
 * - parent: Result from parent resolver
 * - args: Arguments passed to field
 * - context: Shared across all resolvers (user, db, etc.)
 * - info: Query AST and schema info
 */

import { GraphQLResolveInfo } from 'graphql';

interface Context {
  user: User | null;
  db: Database;
  dataSources: DataSources;
}

const resolvers = {
  Query: {
    // Fetch single user
    user: async (
      _parent: unknown,
      args: { id: string },
      context: Context,
      _info: GraphQLResolveInfo
    ) => {
      return context.db.user.findUnique({
        where: { id: args.id },
      });
    },

    // Fetch all users
    users: async (_parent, _args, context: Context) => {
      return context.db.user.findMany();
    },

    // Authenticated query
    me: async (_parent, _args, context: Context) => {
      if (!context.user) {
        throw new Error('Not authenticated');
      }

      return context.user;
    },
  },

  Mutation: {
    createUser: async (
      _parent: unknown,
      args: { input: CreateUserInput },
      context: Context
    ) => {
      const { name, email } = args.input;

      // Validate
      if (!email.includes('@')) {
        throw new Error('Invalid email');
      }

      // Create user
      const user = await context.db.user.create({
        data: { name, email },
      });

      return user;
    },

    updateUser: async (
      _parent: unknown,
      args: { id: string; input: UpdateUserInput },
      context: Context
    ) => {
      return context.db.user.update({
        where: { id: args.id },
        data: args.input,
      });
    },

    deleteUser: async (
      _parent: unknown,
      args: { id: string },
      context: Context
    ) => {
      await context.db.user.delete({
        where: { id: args.id },
      });

      return true;
    },
  },

  User: {
    // Field resolver for computed fields
    fullName: (parent: User) => {
      return `${parent.firstName} ${parent.lastName}`;
    },

    // Field resolver with database query
    posts: async (parent: User, _args, context: Context) => {
      return context.db.post.findMany({
        where: { authorId: parent.id },
      });
    },

    // Field resolver with DataLoader (N+1 prevention)
    posts: async (parent: User, _args, context: Context) => {
      return context.dataSources.postLoader.load(parent.id);
    },
  },

  Post: {
    // Resolver for related type
    author: async (parent: Post, _args, context: Context) => {
      return context.db.user.findUnique({
        where: { id: parent.authorId },
      });
    },

    // With DataLoader
    author: async (parent: Post, _args, context: Context) => {
      return context.dataSources.userLoader.load(parent.authorId);
    },
  },
};

Resolver Composition

/**
 * Resolver Middleware Pattern
 */

type ResolverFn = (parent: any, args: any, context: Context, info: any) => any;

// Auth middleware
const withAuth = (resolver: ResolverFn) => {
  return (parent: any, args: any, context: Context, info: any) => {
    if (!context.user) {
      throw new Error('Not authenticated');
    }
    return resolver(parent, args, context, info);
  };
};

// Admin middleware
const withAdmin = (resolver: ResolverFn) => {
  return (parent: any, args: any, context: Context, info: any) => {
    if (!context.user || context.user.role !== 'ADMIN') {
      throw new Error('Not authorized');
    }
    return resolver(parent, args, context, info);
  };
};

// Compose middleware
const compose = (...middlewares: any[]) => {
  return (resolver: ResolverFn) => {
    return middlewares.reduceRight(
      (acc, middleware) => middleware(acc),
      resolver
    );
  };
};

// Usage
const resolvers = {
  Query: {
    me: withAuth(async (_parent, _args, context: Context) => {
      return context.user;
    }),

    users: compose(withAuth, withAdmin)(
      async (_parent, _args, context: Context) => {
        return context.db.user.findMany();
      }
    ),
  },
};

/**
 * Resolver Directives
 */

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';

function authDirective(directiveName: string) {
  return (schema: GraphQLSchema) => {
    return mapSchema(schema, {
      [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
        const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];

        if (authDirective) {
          const { resolve = defaultFieldResolver } = fieldConfig;
          const { requires } = authDirective;

          fieldConfig.resolve = async (source, args, context, info) => {
            if (!context.user) {
              throw new Error('Not authenticated');
            }

            if (requires && context.user.role !== requires) {
              throw new Error('Not authorized');
            }

            return resolve(source, args, context, info);
          };
        }

        return fieldConfig;
      },
    });
  };
}

// Schema with directive
const typeDefs = `
  directive @auth(requires: UserRole = USER) on FIELD_DEFINITION

  type Query {
    me: User @auth
    users: [User!]! @auth(requires: ADMIN)
  }
`;

// Apply directive
const schema = authDirective('auth')(makeExecutableSchema({ typeDefs, resolvers }));

Batch Resolvers

/**
 * Batch Multiple Queries
 */

const resolvers = {
  Query: {
    // Instead of multiple separate queries
    node: async (_parent, args: { id: string }, context: Context) => {
      // Resolve any type by ID
      return context.dataSources.nodeLoader.load(args.id);
    },

    nodes: async (_parent, args: { ids: string[] }, context: Context) => {
      // Batch load multiple nodes
      return context.dataSources.nodeLoader.loadMany(args.ids);
    },
  },
};

// Schema
const typeDefs = `
  interface Node {
    id: ID!
  }

  type User implements Node {
    id: ID!
    name: String!
  }

  type Post implements Node {
    id: ID!
    title: String!
  }

  type Query {
    node(id: ID!): Node
    nodes(ids: [ID!]!): [Node]!
  }
`;

// Usage - single query instead of multiple
query {
  nodes(ids: ["user:1", "post:2", "user:3"]) {
    id
    ... on User {
      name
    }
    ... on Post {
      title
    }
  }
}
Pro Tip: Use resolver composition to keep resolvers DRY. Extract common logic (auth, logging, error handling) into reusable middleware functions.

4. DataLoader & N+1 Problem

The N+1 Problem

/**
 * N+1 Problem Example
 */

// Query
query {
  users {        # 1 query
    id
    name
    posts {      # N queries (one per user)
      title
    }
  }
}

// BAD: N+1 queries
const resolvers = {
  Query: {
    users: async (_parent, _args, context: Context) => {
      // 1 query to get all users
      return context.db.user.findMany();
    },
  },
  User: {
    posts: async (parent: User, _args, context: Context) => {
      // N queries (one for each user)
      return context.db.post.findMany({
        where: { authorId: parent.id },
      });
    },
  },
};

/**
 * SOLUTION: DataLoader
 *
 * DataLoader provides:
 * - Batching: Combine multiple requests into one
 * - Caching: Per-request memoization
 */

DataLoader Implementation

/**
 * Basic DataLoader
 */

import DataLoader from 'dataloader';

// Create user loader
const createUserLoader = (db: Database) => {
  return new DataLoader(async (ids: readonly string[]) => {
    // Single batch query for all requested IDs
    const users = await db.user.findMany({
      where: { id: { in: [...ids] } },
    });

    // Return users in same order as requested IDs
    const userMap = new Map(users.map(user => [user.id, user]));
    return ids.map(id => userMap.get(id) || null);
  });
};

// Create posts-by-author loader
const createPostsByAuthorLoader = (db: Database) => {
  return new DataLoader(async (authorIds: readonly string[]) => {
    // Single batch query
    const posts = await db.post.findMany({
      where: { authorId: { in: [...authorIds] } },
    });

    // Group posts by author ID
    const postsByAuthor = new Map();
    for (const post of posts) {
      const existing = postsByAuthor.get(post.authorId) || [];
      postsByAuthor.set(post.authorId, [...existing, post]);
    }

    // Return in same order as requested IDs
    return authorIds.map(id => postsByAuthor.get(id) || []);
  });
};

/**
 * Context Setup
 */

interface Context {
  user: User | null;
  db: Database;
  loaders: {
    user: DataLoader;
    postsByAuthor: DataLoader;
    commentsByPost: DataLoader;
  };
}

// Create new loaders per request
const createContext = async ({ req }: { req: Request }): Promise => {
  const user = await authenticate(req);

  return {
    user,
    db,
    loaders: {
      user: createUserLoader(db),
      postsByAuthor: createPostsByAuthorLoader(db),
      commentsByPost: createCommentsByPostLoader(db),
    },
  };
};

/**
 * Using DataLoaders in Resolvers
 */

const resolvers = {
  Query: {
    users: async (_parent, _args, context: Context) => {
      return context.db.user.findMany();
    },
  },

  User: {
    posts: async (parent: User, _args, context: Context) => {
      // DataLoader batches and caches
      return context.loaders.postsByAuthor.load(parent.id);
    },
  },

  Post: {
    author: async (parent: Post, _args, context: Context) => {
      // DataLoader batches and caches
      return context.loaders.user.load(parent.authorId);
    },

    comments: async (parent: Post, _args, context: Context) => {
      return context.loaders.commentsByPost.load(parent.id);
    },
  },

  Comment: {
    author: async (parent: Comment, _args, context: Context) => {
      return context.loaders.user.load(parent.authorId);
    },
  },
};

/**
 * Advanced: DataLoader with Options
 */

const createUserLoader = (db: Database) => {
  return new DataLoader(
    async (ids) => {
      const users = await db.user.findMany({
        where: { id: { in: [...ids] } },
      });

      const userMap = new Map(users.map(user => [user.id, user]));
      return ids.map(id => userMap.get(id) || null);
    },
    {
      // Options
      batch: true,              // Enable batching (default: true)
      cache: true,              // Enable caching (default: true)
      maxBatchSize: 100,        // Max items per batch
      batchScheduleFn: (cb) => setTimeout(cb, 10), // Delay batching
    }
  );
};

/**
 * Custom Cache Key
 */

const createPostLoader = (db: Database) => {
  return new DataLoader<{ id: string; includeComments: boolean }, Post>(
    async (keys) => {
      // Complex batch query
      const ids = keys.map(k => k.id);
      const posts = await db.post.findMany({
        where: { id: { in: ids } },
        include: {
          comments: keys[0].includeComments,
        },
      });

      const postMap = new Map(posts.map(post => [post.id, post]));
      return keys.map(key => postMap.get(key.id) || null);
    },
    {
      // Custom cache key function
      cacheKeyFn: (key) => `${key.id}:${key.includeComments}`,
    }
  );
};

DataLoader Best Practices

/**
 * 1. Always create new loaders per request
 */

// BAD: Shared loader across requests (leaks data)
const userLoader = createUserLoader(db);

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => ({
    loaders: { user: userLoader }, // WRONG!
  }),
});

// GOOD: New loader per request
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => ({
    loaders: {
      user: createUserLoader(db), // Correct!
    },
  }),
});

/**
 * 2. Prime the cache
 */

const resolvers = {
  Query: {
    users: async (_parent, _args, context: Context) => {
      const users = await context.db.user.findMany();

      // Prime the loader cache
      users.forEach(user => {
        context.loaders.user.prime(user.id, user);
      });

      return users;
    },
  },
};

/**
 * 3. Clear cache when mutating
 */

const resolvers = {
  Mutation: {
    updateUser: async (_parent, args, context: Context) => {
      const user = await context.db.user.update({
        where: { id: args.id },
        data: args.input,
      });

      // Clear cache for this user
      context.loaders.user.clear(user.id);

      // Or prime with new data
      context.loaders.user.prime(user.id, user);

      return user;
    },
  },
};

/**
 * 4. Handle errors in batch function
 */

const createUserLoader = (db: Database) => {
  return new DataLoader(async (ids) => {
    try {
      const users = await db.user.findMany({
        where: { id: { in: [...ids] } },
      });

      const userMap = new Map(users.map(user => [user.id, user]));

      return ids.map(id => {
        const user = userMap.get(id);
        if (!user) {
          return new Error(`User ${id} not found`);
        }
        return user;
      });
    } catch (error) {
      // Return same error for all IDs
      return ids.map(() => error);
    }
  });
};
Common Pitfall: Reusing DataLoaders across requests causes data leakage between users. Always create new loaders in the context function for each request.

5. Apollo Server Setup

Apollo Server 4

/**
 * Apollo Server 4 with Express
 */

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { json } from 'body-parser';

// Type definitions
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
  }
`;

// Resolvers
const resolvers = {
  Query: {
    users: async (_parent, _args, context) => {
      return context.db.user.findMany();
    },
    user: async (_parent, args, context) => {
      return context.db.user.findUnique({
        where: { id: args.id },
      });
    },
  },
  Mutation: {
    createUser: async (_parent, args, context) => {
      return context.db.user.create({
        data: {
          name: args.name,
          email: args.email,
        },
      });
    },
  },
};

// Context interface
interface Context {
  user: User | null;
  db: Database;
  loaders: Loaders;
}

// Create context
const createContext = async ({ req }: { req: Request }): Promise => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;

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

// Create Express app
const app = express();
const httpServer = http.createServer(app);

// Create Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    // Graceful shutdown
    ApolloServerPluginDrainHttpServer({ httpServer }),

    // Disable introspection and playground in production
    process.env.NODE_ENV === 'production' && {
      async serverWillStart() {
        return {
          async drainServer() {},
          async renderLandingPage() {
            return { html: '

GraphQL API

' }; }, }; }, }, ].filter(Boolean), // Limit query depth validationRules: [depthLimit(10)], // Format errors formatError: (error) => { console.error(error); if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') { return new GraphQLError('Internal server error', { extensions: { code: 'INTERNAL_SERVER_ERROR' }, }); } return error; }, }); // Start server await server.start(); // Apply middleware app.use( '/graphql', cors(), json(), expressMiddleware(server, { context: createContext, }) ); // Start HTTP server await new Promise((resolve) => { httpServer.listen({ port: 4000 }, resolve); }); console.log(`🚀 Server ready at http://localhost:4000/graphql`);

Apollo Server Plugins

/**
 * Custom Plugins
 */

import { ApolloServerPlugin } from '@apollo/server';

// Logging plugin
const loggingPlugin: ApolloServerPlugin = {
  async requestDidStart(requestContext) {
    const start = Date.now();

    return {
      async willSendResponse(requestContext) {
        const elapsed = Date.now() - start;
        console.log(`Operation: ${requestContext.operationName} (${elapsed}ms)`);
      },

      async didEncounterErrors(requestContext) {
        console.error('Errors:', requestContext.errors);
      },
    };
  },
};

// Complexity plugin
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';

const complexityPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document, schema }) {
        const complexity = getComplexity({
          schema,
          query: document,
          variables: request.variables,
          estimators: [simpleEstimator({ defaultComplexity: 1 })],
        });

        const maxComplexity = 1000;
        if (complexity > maxComplexity) {
          throw new GraphQLError(
            `Query too complex: ${complexity}. Maximum allowed: ${maxComplexity}`,
            { extensions: { code: 'QUERY_TOO_COMPLEX' } }
          );
        }
      },
    };
  },
};

// Response caching plugin
import responseCachePlugin from '@apollo/server-plugin-response-cache';

const cachePlugin = responseCachePlugin({
  sessionId: (requestContext) => {
    return requestContext.request.http?.headers.get('sessionid') || null;
  },
  shouldReadFromCache: (requestContext) => {
    // Only cache queries
    return requestContext.request.query?.includes('query');
  },
  shouldWriteToCache: (requestContext) => {
    return !requestContext.contextValue.user; // Only cache public data
  },
});

// Apply plugins
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    loggingPlugin,
    complexityPlugin,
    cachePlugin,
  ],
});

Schema Building

/**
 * Code-First Approach with TypeGraphQL
 */

import { ObjectType, Field, ID, Resolver, Query, Arg, Mutation } from 'type-graphql';

@ObjectType()
class User {
  @Field(() => ID)
  id: string;

  @Field()
  name: string;

  @Field()
  email: string;

  @Field(() => [Post])
  posts: Post[];
}

@ObjectType()
class Post {
  @Field(() => ID)
  id: string;

  @Field()
  title: string;

  @Field()
  content: string;

  @Field(() => User)
  author: User;
}

@Resolver(User)
class UserResolver {
  @Query(() => [User])
  async users(@Ctx() context: Context) {
    return context.db.user.findMany();
  }

  @Query(() => User, { nullable: true })
  async user(@Arg('id') id: string, @Ctx() context: Context) {
    return context.db.user.findUnique({
      where: { id },
    });
  }

  @Mutation(() => User)
  async createUser(
    @Arg('name') name: string,
    @Arg('email') email: string,
    @Ctx() context: Context
  ) {
    return context.db.user.create({
      data: { name, email },
    });
  }
}

// Build schema
import { buildSchema } from 'type-graphql';

const schema = await buildSchema({
  resolvers: [UserResolver],
  validate: true,
});

const server = new ApolloServer({
  schema,
});
Pro Tip: Use plugins for cross-cutting concerns (logging, caching, authentication). Keep plugins simple and composable.

6. Authentication & Authorization

JWT Authentication

/**
 * JWT Authentication
 */

import { sign, verify } from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;

// Create token
function createToken(user: User): string {
  return sign(
    { userId: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '7d' }
  );
}

// Verify token
function verifyToken(token: string): TokenPayload | null {
  try {
    return verify(token, JWT_SECRET) as TokenPayload;
  } catch (error) {
    return null;
  }
}

// Context with auth
const createContext = async ({ req }: { req: Request }): Promise => {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return { user: null, db, loaders: createLoaders(db) };
  }

  const payload = verifyToken(token);

  if (!payload) {
    return { user: null, db, loaders: createLoaders(db) };
  }

  const user = await db.user.findUnique({
    where: { id: payload.userId },
  });

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

/**
 * Login Mutation
 */

const resolvers = {
  Mutation: {
    login: async (_parent, args: { email: string; password: string }) => {
      // Find user
      const user = await db.user.findUnique({
        where: { email: args.email },
      });

      if (!user) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      // Verify password
      const valid = await verifyPassword(args.password, user.password);

      if (!valid) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      // Create token
      const token = createToken(user);

      return {
        token,
        user,
      };
    },

    register: async (_parent, args: { name: string; email: string; password: string }) => {
      // Check if user exists
      const existing = await db.user.findUnique({
        where: { email: args.email },
      });

      if (existing) {
        throw new GraphQLError('Email already in use', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      // Hash password
      const hashedPassword = await hashPassword(args.password);

      // Create user
      const user = await db.user.create({
        data: {
          name: args.name,
          email: args.email,
          password: hashedPassword,
        },
      });

      // Create token
      const token = createToken(user);

      return {
        token,
        user,
      };
    },
  },
};

// Schema
const typeDefs = `
  type AuthPayload {
    token: String!
    user: User!
  }

  type Mutation {
    login(email: String!, password: String!): AuthPayload!
    register(name: String!, email: String!, password: String!): AuthPayload!
  }
`;

Authorization

/**
 * Authorization Helpers
 */

import { GraphQLError } from 'graphql';

// Require authentication
function requireAuth(context: Context): User {
  if (!context.user) {
    throw new GraphQLError('Not authenticated', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
  return context.user;
}

// Require specific role
function requireRole(context: Context, role: UserRole): User {
  const user = requireAuth(context);

  if (user.role !== role) {
    throw new GraphQLError('Not authorized', {
      extensions: { code: 'FORBIDDEN' },
    });
  }

  return user;
}

// Check ownership
function requireOwnership(context: Context, resourceOwnerId: string): User {
  const user = requireAuth(context);

  if (user.id !== resourceOwnerId && user.role !== 'ADMIN') {
    throw new GraphQLError('Not authorized', {
      extensions: { code: 'FORBIDDEN' },
    });
  }

  return user;
}

/**
 * Using Authorization
 */

const resolvers = {
  Query: {
    // Public query
    users: async () => {
      return db.user.findMany();
    },

    // Requires authentication
    me: async (_parent, _args, context: Context) => {
      const user = requireAuth(context);
      return user;
    },

    // Requires admin role
    allUsers: async (_parent, _args, context: Context) => {
      requireRole(context, 'ADMIN');
      return db.user.findMany();
    },
  },

  Mutation: {
    // Requires ownership
    updateUser: async (_parent, args: { id: string; input: UpdateUserInput }, context: Context) => {
      requireOwnership(context, args.id);

      return db.user.update({
        where: { id: args.id },
        data: args.input,
      });
    },

    // Admin only
    deleteUser: async (_parent, args: { id: string }, context: Context) => {
      requireRole(context, 'ADMIN');

      await db.user.delete({
        where: { id: args.id },
      });

      return true;
    },
  },

  User: {
    // Field-level authorization
    email: (parent: User, _args, context: Context) => {
      // Only show email to owner or admin
      if (context.user?.id === parent.id || context.user?.role === 'ADMIN') {
        return parent.email;
      }
      return null;
    },
  },
};

/**
 * Directive-Based Authorization
 */

const typeDefs = `
  directive @auth(requires: UserRole = USER) on FIELD_DEFINITION

  enum UserRole {
    USER
    ADMIN
  }

  type Query {
    users: [User!]!
    me: User @auth
    allUsers: [User!]! @auth(requires: ADMIN)
  }

  type User {
    id: ID!
    name: String!
    email: String @auth  # Only authenticated users can see
  }
`;

Session-Based Auth

/**
 * Session-Based Authentication
 */

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

// Redis client
const redisClient = createClient({
  url: process.env.REDIS_URL,
});
await redisClient.connect();

// Session middleware
app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
    },
  })
);

// Context with session
const createContext = async ({ req }: { req: Request }): Promise => {
  const userId = req.session.userId;

  const user = userId
    ? await db.user.findUnique({ where: { id: userId } })
    : null;

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

// Login mutation
const resolvers = {
  Mutation: {
    login: async (
      _parent,
      args: { email: string; password: string },
      context: Context
    ) => {
      const user = await db.user.findUnique({
        where: { email: args.email },
      });

      if (!user || !(await verifyPassword(args.password, user.password))) {
        throw new GraphQLError('Invalid credentials');
      }

      // Set session
      context.session.userId = user.id;

      return user;
    },

    logout: async (_parent, _args, context: Context) => {
      // Destroy session
      await new Promise((resolve) => context.session.destroy(resolve));
      return true;
    },
  },
};
Pro Tip: Use JWT for stateless authentication (microservices, mobile apps) and sessions for web applications where you need server-side control (instant revocation).

7. Error Handling

GraphQL Error Codes

/**
 * Standard Error Codes
 */

import { GraphQLError } from 'graphql';

// Authentication error
throw new GraphQLError('Not authenticated', {
  extensions: {
    code: 'UNAUTHENTICATED',
    http: { status: 401 },
  },
});

// Authorization error
throw new GraphQLError('Not authorized', {
  extensions: {
    code: 'FORBIDDEN',
    http: { status: 403 },
  },
});

// Bad user input
throw new GraphQLError('Invalid email format', {
  extensions: {
    code: 'BAD_USER_INPUT',
    argumentName: 'email',
    http: { status: 400 },
  },
});

// Not found
throw new GraphQLError('User not found', {
  extensions: {
    code: 'NOT_FOUND',
    resourceType: 'User',
    resourceId: '123',
    http: { status: 404 },
  },
});

// Internal error
throw new GraphQLError('Internal server error', {
  extensions: {
    code: 'INTERNAL_SERVER_ERROR',
    http: { status: 500 },
  },
});

/**
 * Custom Error Classes
 */

class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'UNAUTHENTICATED',
        http: { status: 401 },
      },
    });
  }
}

class AuthorizationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'FORBIDDEN',
        http: { status: 403 },
      },
    });
  }
}

class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: {
        code: 'BAD_USER_INPUT',
        field,
        http: { status: 400 },
      },
    });
  }
}

class NotFoundError extends GraphQLError {
  constructor(resourceType: string, resourceId: string) {
    super(`${resourceType} not found`, {
      extensions: {
        code: 'NOT_FOUND',
        resourceType,
        resourceId,
        http: { status: 404 },
      },
    });
  }
}

// Usage
const resolvers = {
  Query: {
    user: async (_parent, args: { id: string }, context: Context) => {
      if (!context.user) {
        throw new AuthenticationError('Must be logged in');
      }

      const user = await context.db.user.findUnique({
        where: { id: args.id },
      });

      if (!user) {
        throw new NotFoundError('User', args.id);
      }

      if (user.id !== context.user.id && context.user.role !== 'ADMIN') {
        throw new AuthorizationError('Cannot view other users');
      }

      return user;
    },
  },
};

Error Formatting

/**
 * Format Errors
 */

import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Unwrap original error
    const originalError = unwrapResolverError(error);

    // Log internal errors
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      console.error('Internal error:', originalError);

      // Don't expose internal details in production
      if (process.env.NODE_ENV === 'production') {
        return {
          message: 'Internal server error',
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
          },
        };
      }
    }

    // Log to error tracking service
    if (shouldLogError(formattedError)) {
      logToSentry(originalError);
    }

    return formattedError;
  },
});

function shouldLogError(error: any): boolean {
  const ignoreCodes = ['UNAUTHENTICATED', 'BAD_USER_INPUT', 'NOT_FOUND'];
  return !ignoreCodes.includes(error.extensions?.code);
}

/**
 * Validation Errors
 */

import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  age: z.number().int().min(18, 'Must be at least 18 years old').optional(),
});

const resolvers = {
  Mutation: {
    createUser: async (_parent, args: { input: CreateUserInput }) => {
      try {
        // Validate input
        const validated = createUserSchema.parse(args.input);

        // Create user
        return db.user.create({ data: validated });
      } catch (error) {
        if (error instanceof z.ZodError) {
          // Format validation errors
          const errors = error.errors.map(err => ({
            field: err.path.join('.'),
            message: err.message,
          }));

          throw new GraphQLError('Validation failed', {
            extensions: {
              code: 'BAD_USER_INPUT',
              validationErrors: errors,
            },
          });
        }

        throw error;
      }
    },
  },
};

Partial Errors

/**
 * Handle Partial Errors
 */

const resolvers = {
  Query: {
    users: async () => {
      try {
        return await db.user.findMany();
      } catch (error) {
        console.error('Failed to fetch users:', error);
        // Return empty array instead of failing entire query
        return [];
      }
    },
  },

  User: {
    posts: async (parent: User, _args, context: Context) => {
      try {
        return await context.loaders.postsByAuthor.load(parent.id);
      } catch (error) {
        console.error(`Failed to fetch posts for user ${parent.id}:`, error);
        // Return empty array for this field
        return [];
      }
    },
  },
};

/**
 * GraphQL Response with Errors
 */

// Response can include both data and errors
{
  "data": {
    "users": [
      {
        "id": "1",
        "name": "John",
        "posts": null  // This field failed
      },
      {
        "id": "2",
        "name": "Jane",
        "posts": [{ "title": "Post 1" }]
      }
    ]
  },
  "errors": [
    {
      "message": "Failed to fetch posts",
      "path": ["users", 0, "posts"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR"
      }
    }
  ]
}
Common Pitfall: Don't expose internal error details in production. Always sanitize error messages and stack traces before sending to clients.

8. Input Validation

Zod Validation

/**
 * Input Validation with Zod
 */

import { z } from 'zod';

// Define schemas
const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).optional(),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain uppercase letter')
    .regex(/[a-z]/, 'Password must contain lowercase letter')
    .regex(/[0-9]/, 'Password must contain number'),
});

const updateUserSchema = z.object({
  name: z.string().min(2).max(100).optional(),
  email: z.string().email().optional(),
  age: z.number().int().min(18).optional(),
});

// Validation helper
function validate(schema: z.Schema, data: unknown): T {
  try {
    return schema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      const errors = error.errors.map(err => ({
        field: err.path.join('.'),
        message: err.message,
      }));

      throw new GraphQLError('Validation failed', {
        extensions: {
          code: 'BAD_USER_INPUT',
          validationErrors: errors,
        },
      });
    }

    throw error;
  }
}

// Use in resolvers
const resolvers = {
  Mutation: {
    createUser: async (_parent, args: { input: unknown }) => {
      const input = validate(createUserSchema, args.input);

      return db.user.create({
        data: {
          name: input.name,
          email: input.email,
          age: input.age,
          password: await hashPassword(input.password),
        },
      });
    },

    updateUser: async (_parent, args: { id: string; input: unknown }) => {
      const input = validate(updateUserSchema, args.input);

      return db.user.update({
        where: { id: args.id },
        data: input,
      });
    },
  },
};

Custom Scalars

/**
 * Custom Scalar Types
 */

import { GraphQLScalarType, Kind } from 'graphql';

// Email scalar
const EmailScalar = new GraphQLScalarType({
  name: 'Email',
  description: 'Valid email address',

  serialize(value: unknown): string {
    if (typeof value !== 'string') {
      throw new GraphQLError('Email must be a string');
    }
    if (!isValidEmail(value)) {
      throw new GraphQLError('Invalid email format');
    }
    return value;
  },

  parseValue(value: unknown): string {
    if (typeof value !== 'string') {
      throw new GraphQLError('Email must be a string');
    }
    if (!isValidEmail(value)) {
      throw new GraphQLError('Invalid email format');
    }
    return value;
  },

  parseLiteral(ast): string {
    if (ast.kind !== Kind.STRING) {
      throw new GraphQLError('Email must be a string');
    }
    if (!isValidEmail(ast.value)) {
      throw new GraphQLError('Invalid email format');
    }
    return ast.value;
  },
});

function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// DateTime scalar
const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601 date time string',

  serialize(value: unknown): string {
    if (value instanceof Date) {
      return value.toISOString();
    }
    throw new GraphQLError('DateTime must be a Date object');
  },

  parseValue(value: unknown): Date {
    if (typeof value === 'string') {
      const date = new Date(value);
      if (isNaN(date.getTime())) {
        throw new GraphQLError('Invalid DateTime format');
      }
      return date;
    }
    throw new GraphQLError('DateTime must be a string');
  },

  parseLiteral(ast): Date {
    if (ast.kind === Kind.STRING) {
      const date = new Date(ast.value);
      if (isNaN(date.getTime())) {
        throw new GraphQLError('Invalid DateTime format');
      }
      return date;
    }
    throw new GraphQLError('DateTime must be a string');
  },
});

// URL scalar
const URLScalar = new GraphQLScalarType({
  name: 'URL',
  description: 'Valid URL string',

  serialize(value: unknown): string {
    if (typeof value !== 'string') {
      throw new GraphQLError('URL must be a string');
    }
    try {
      new URL(value);
      return value;
    } catch {
      throw new GraphQLError('Invalid URL format');
    }
  },

  parseValue(value: unknown): string {
    if (typeof value !== 'string') {
      throw new GraphQLError('URL must be a string');
    }
    try {
      new URL(value);
      return value;
    } catch {
      throw new GraphQLError('Invalid URL format');
    }
  },

  parseLiteral(ast): string {
    if (ast.kind !== Kind.STRING) {
      throw new GraphQLError('URL must be a string');
    }
    try {
      new URL(ast.value);
      return ast.value;
    } catch {
      throw new GraphQLError('Invalid URL format');
    }
  },
});

// Use in resolvers
const resolvers = {
  Email: EmailScalar,
  DateTime: DateTimeScalar,
  URL: URLScalar,
};

// Schema
const typeDefs = `
  scalar Email
  scalar DateTime
  scalar URL

  type User {
    id: ID!
    email: Email!
    website: URL
    createdAt: DateTime!
  }
`;
Pro Tip: Use custom scalars for common patterns (Email, URL, DateTime) to move validation logic out of resolvers and into the type system.

9. Performance Optimization

Query Complexity

/**
 * Limit Query Complexity
 */

import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
import { separateOperations } from 'graphql';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request, document, schema }) {
            const complexity = getComplexity({
              schema,
              query: document,
              variables: request.variables,
              estimators: [
                fieldExtensionsEstimator(),
                simpleEstimator({ defaultComplexity: 1 }),
              ],
            });

            const maxComplexity = 1000;
            if (complexity > maxComplexity) {
              throw new GraphQLError(
                `Query too complex: ${complexity}. Maximum: ${maxComplexity}`,
                { extensions: { code: 'QUERY_TOO_COMPLEX' } }
              );
            }
          },
        };
      },
    },
  ],
});

/**
 * Custom Complexity Estimation
 */

const typeDefs = `
  type Query {
    users(first: Int!): [User!]! @complexity(multipliers: ["first"], value: 1)
    user(id: ID!): User @complexity(value: 10)
  }

  type User {
    id: ID!
    posts(first: Int!): [Post!]! @complexity(multipliers: ["first"], value: 2)
  }

  directive @complexity(
    value: Int!
    multipliers: [String!]
  ) on FIELD_DEFINITION
`;

// Example complexity calculation:
// users(first: 100) { posts(first: 10) }
// = 1 * 100 + 2 * 10 * 100 = 2100

Query Depth Limiting

/**
 * Limit Query Depth
 */

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(10)],
});

// This query is rejected (depth > 10):
query {
  user {           # depth 1
    posts {        # depth 2
      author {     # depth 3
        posts {    # depth 4
          author { # depth 5
            # ... continues
          }
        }
      }
    }
  }
}

Persistent Queries

/**
 * Automatic Persisted Queries (APQ)
 *
 * Benefits:
 * - Reduce bandwidth
 * - Improve cache hit rate
 * - Enable query allowlisting
 */

import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageGraphQLPlayground } from '@apollo/server-plugin-landing-page-graphql-playground';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: new Map(), // Or use Redis
  },
});

/**
 * Client sends query hash first
 */

// 1. Client sends SHA-256 hash of query
POST /graphql
{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "abc123..."
    }
  }
}

// 2. If not in cache, server returns PersistedQueryNotFound
{
  "errors": [{
    "message": "PersistedQueryNotFound",
    "extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }
  }]
}

// 3. Client sends full query with hash
POST /graphql
{
  "query": "query { users { id name } }",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "abc123..."
    }
  }
}

// 4. Server caches query and returns result
// 5. Future requests only need hash

Field-Level Caching

/**
 * Cache Specific Fields
 */

import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    responseCachePlugin({
      sessionId: (requestContext) => {
        return requestContext.request.http?.headers.get('session-id') || null;
      },
    }),
  ],
});

// Schema with cache hints
const typeDefs = `
  type Query {
    users: [User!]! @cacheControl(maxAge: 60)
    posts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
  }

  type User {
    id: ID!
    name: String! @cacheControl(maxAge: 3600)
    email: String! @cacheControl(maxAge: 0, scope: PRIVATE)
  }
`;

/**
 * Programmatic Cache Control
 */

const resolvers = {
  Query: {
    users: async (_parent, _args, context, info) => {
      info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PUBLIC' });
      return context.db.user.findMany();
    },
  },

  User: {
    email: (parent: User, _args, context, info) => {
      // Don't cache private fields
      info.cacheControl.setCacheHint({ maxAge: 0, scope: 'PRIVATE' });
      return parent.email;
    },
  },
};
Pro Tip: Combine multiple optimization techniques: depth limiting (prevent deep queries), complexity analysis (prevent expensive queries), and APQ (reduce bandwidth). Monitor query performance to identify slow queries.

10. Caching Strategies

Redis Caching

/**
 * Redis-Based Caching
 */

import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL,
});

await redis.connect();

/**
 * Cache Wrapper
 */

async function cached(
  key: string,
  ttl: number,
  fn: () => Promise
): Promise {
  // Try to get from cache
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  // Execute function
  const result = await fn();

  // Store in cache
  await redis.setEx(key, ttl, JSON.stringify(result));

  return result;
}

/**
 * Use in Resolvers
 */

const resolvers = {
  Query: {
    users: async () => {
      return cached('users', 60, async () => {
        return db.user.findMany();
      });
    },

    user: async (_parent, args: { id: string }) => {
      return cached(`user:${args.id}`, 300, async () => {
        return db.user.findUnique({
          where: { id: args.id },
        });
      });
    },
  },

  Mutation: {
    updateUser: async (_parent, args) => {
      const user = await db.user.update({
        where: { id: args.id },
        data: args.input,
      });

      // Invalidate cache
      await redis.del(`user:${user.id}`);
      await redis.del('users');

      return user;
    },
  },
};

DataLoader Caching

/**
 * Multi-Layer Caching with DataLoader + Redis
 */

import DataLoader from 'dataloader';

const createUserLoader = (redis: Redis, db: Database) => {
  return new DataLoader(
    async (ids: readonly string[]) => {
      // Try Redis first
      const cacheKeys = ids.map(id => `user:${id}`);
      const cached = await redis.mGet(cacheKeys);

      const missing: string[] = [];
      const results = new Map();

      // Check which IDs are in cache
      cached.forEach((value, index) => {
        if (value) {
          results.set(ids[index], JSON.parse(value));
        } else {
          missing.push(ids[index]);
        }
      });

      // Fetch missing from database
      if (missing.length > 0) {
        const users = await db.user.findMany({
          where: { id: { in: missing } },
        });

        // Store in Redis
        const pipeline = redis.pipeline();
        for (const user of users) {
          results.set(user.id, user);
          pipeline.setEx(`user:${user.id}`, 300, JSON.stringify(user));
        }
        await pipeline.exec();
      }

      // Return in correct order
      return ids.map(id => results.get(id) || null);
    },
    {
      cache: true, // DataLoader in-memory cache
    }
  );
};

/**
 * Cache Invalidation
 */

const resolvers = {
  Mutation: {
    updateUser: async (_parent, args, context: Context) => {
      const user = await context.db.user.update({
        where: { id: args.id },
        data: args.input,
      });

      // Clear DataLoader cache
      context.loaders.user.clear(user.id);

      // Clear Redis cache
      await redis.del(`user:${user.id}`);

      // Prime loader with new data
      context.loaders.user.prime(user.id, user);

      return user;
    },
  },
};

HTTP Caching

/**
 * HTTP Caching Headers
 */

import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    responseCachePlugin({
      // Calculate cache key
      sessionId: (requestContext) => {
        const userId = requestContext.contextValue.user?.id;
        return userId || null;
      },

      // Set cache TTL
      generateCacheKey: (requestContext) => {
        const { query, variables } = requestContext.request;
        return `${query}:${JSON.stringify(variables)}`;
      },

      // Should read from cache
      shouldReadFromCache: (requestContext) => {
        return requestContext.request.http?.method === 'GET';
      },

      // Should write to cache
      shouldWriteToCache: (requestContext) => {
        // Don't cache if errors
        return !requestContext.errors;
      },
    }),
  ],
});

/**
 * CDN Caching
 */

// Set Cache-Control headers based on query
const resolvers = {
  Query: {
    publicPosts: async (_parent, _args, context, info) => {
      // Public data - cache for 5 minutes
      info.cacheControl.setCacheHint({
        maxAge: 300,
        scope: 'PUBLIC',
      });

      return db.post.findMany({
        where: { published: true },
      });
    },

    myPosts: async (_parent, _args, context, info) => {
      // Private data - don't cache
      info.cacheControl.setCacheHint({
        maxAge: 0,
        scope: 'PRIVATE',
      });

      return db.post.findMany({
        where: { authorId: context.user.id },
      });
    },
  },
};
Common Pitfall: Don't cache personalized data in shared caches (CDN, Redis). Use cache scopes (PUBLIC vs PRIVATE) and session-based cache keys to prevent data leakage.

11. Real-time with Subscriptions

WebSocket Subscriptions

/**
 * WebSocket Subscriptions with graphql-ws
 */

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import { PubSub } from 'graphql-subscriptions';

// PubSub for local development (use Redis in production)
const pubsub = new PubSub();

// Type definitions
const typeDefs = `
  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Subscription {
    postCreated: Post!
    postUpdated(id: ID!): Post!
    postDeleted: ID!
  }

  type Mutation {
    createPost(title: String!, content: String!): Post!
    updatePost(id: ID!, title: String, content: String): Post!
    deletePost(id: ID!): Boolean!
  }
`;

// Resolvers
const resolvers = {
  Mutation: {
    createPost: async (_parent, args, context: Context) => {
      const post = await context.db.post.create({
        data: {
          title: args.title,
          content: args.content,
          authorId: context.user.id,
        },
        include: { author: true },
      });

      // Publish event
      await pubsub.publish('POST_CREATED', { postCreated: post });

      return post;
    },

    updatePost: async (_parent, args, context: Context) => {
      const post = await context.db.post.update({
        where: { id: args.id },
        data: {
          title: args.title,
          content: args.content,
        },
        include: { author: true },
      });

      // Publish event
      await pubsub.publish(`POST_UPDATED_${post.id}`, { postUpdated: post });

      return post;
    },

    deletePost: async (_parent, args, context: Context) => {
      await context.db.post.delete({
        where: { id: args.id },
      });

      // Publish event
      await pubsub.publish('POST_DELETED', { postDeleted: args.id });

      return true;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },

    postUpdated: {
      subscribe: (_parent, args: { id: string }) => {
        return pubsub.asyncIterator([`POST_UPDATED_${args.id}`]);
      },
    },

    postDeleted: {
      subscribe: () => pubsub.asyncIterator(['POST_DELETED']),
    },
  },
};

// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });

// Create Express app
const app = express();
const httpServer = http.createServer(app);

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

// Setup subscription handler
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // Get auth token from connection params
      const token = ctx.connectionParams?.authorization;
      const user = token ? await verifyToken(token) : null;

      return {
        user,
        db,
        loaders: createLoaders(db),
      };
    },
    onConnect: async (ctx) => {
      console.log('Client connected');
    },
    onDisconnect: async (ctx) => {
      console.log('Client disconnected');
    },
  },
  wsServer
);

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

await server.start();

app.use(
  '/graphql',
  cors(),
  json(),
  expressMiddleware(server, { context: createContext })
);

await new Promise((resolve) => {
  httpServer.listen({ port: 4000 }, resolve);
});

console.log('🚀 Server ready at http://localhost:4000/graphql');

Redis PubSub

/**
 * Redis PubSub for Distributed Systems
 */

import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

// Redis PubSub
const pubsub = new RedisPubSub({
  publisher: new Redis({
    host: process.env.REDIS_HOST,
    port: Number(process.env.REDIS_PORT),
    retryStrategy: (times) => Math.min(times * 50, 2000),
  }),
  subscriber: new Redis({
    host: process.env.REDIS_HOST,
    port: Number(process.env.REDIS_PORT),
    retryStrategy: (times) => Math.min(times * 50, 2000),
  }),
});

// Use same resolvers as before
const resolvers = {
  Mutation: {
    createPost: async (_parent, args, context: Context) => {
      const post = await context.db.post.create({
        data: { title: args.title, content: args.content },
        include: { author: true },
      });

      // Publish to Redis (works across multiple servers)
      await pubsub.publish('POST_CREATED', { postCreated: post });

      return post;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },
  },
};

Subscription Filters

/**
 * Filter Subscription Events
 */

const resolvers = {
  Subscription: {
    // Filter by category
    postCreated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['POST_CREATED']),
        (payload, variables) => {
          // Only send posts matching category
          return payload.postCreated.category === variables.category;
        }
      ),
    },

    // Filter by user
    commentCreated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['COMMENT_CREATED']),
        (payload, variables, context) => {
          // Only send to post author
          return payload.commentCreated.post.authorId === context.user.id;
        }
      ),
    },
  },
};

// Schema
const typeDefs = `
  type Subscription {
    postCreated(category: String!): Post!
    commentCreated: Comment!
  }
`;

// Client
subscription {
  postCreated(category: "Technology") {
    id
    title
  }
}
Pro Tip: Use Redis PubSub in production for horizontal scaling. Local PubSub only works with a single server instance. Always authenticate subscription connections.

12. GraphQL Federation

Federation Basics

/**
 * GraphQL Federation (Apollo Federation 2)
 *
 * Benefits:
 * - Split schema across multiple services
 * - Independent deployment
 * - Team autonomy
 * - Shared types across services
 */

// User Service
import { ApolloServer } from '@apollo/server';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';

const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
  }
`;

const resolvers = {
  Query: {
    user: (_parent, args: { id: string }) => {
      return db.user.findUnique({ where: { id: args.id } });
    },
    users: () => {
      return db.user.findMany();
    },
  },

  User: {
    __resolveReference: (reference: { id: string }) => {
      return db.user.findUnique({ where: { id: reference.id } });
    },
  },
};

const userServer = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),
});

// Post Service
const postTypeDefs = gql`
  type Post @key(fields: "id") {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  extend type User @key(fields: "id") {
    id: ID! @external
    posts: [Post!]!
  }

  type Query {
    post(id: ID!): Post
    posts: [Post!]!
  }
`;

const postResolvers = {
  Query: {
    post: (_parent, args: { id: string }) => {
      return db.post.findUnique({ where: { id: args.id } });
    },
    posts: () => {
      return db.post.findMany();
    },
  },

  Post: {
    __resolveReference: (reference: { id: string }) => {
      return db.post.findUnique({ where: { id: reference.id } });
    },

    author: (post: Post) => {
      // Return reference to User type
      return { __typename: 'User', id: post.authorId };
    },
  },

  User: {
    posts: (user: { id: string }) => {
      return db.post.findMany({ where: { authorId: user.id } });
    },
  },
};

const postServer = new ApolloServer({
  schema: buildSubgraphSchema({
    typeDefs: postTypeDefs,
    resolvers: postResolvers,
  }),
});

Apollo Gateway

/**
 * Apollo Gateway (Federated Router)
 */

import { ApolloServer } from '@apollo/server';
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';

const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: 'users', url: 'http://localhost:4001/graphql' },
      { name: 'posts', url: 'http://localhost:4002/graphql' },
      { name: 'comments', url: 'http://localhost:4003/graphql' },
    ],
  }),
});

const server = new ApolloServer({
  gateway,
});

await server.start();

// Now you can query across services:
query {
  user(id: "1") {       # From users service
    name
    posts {             # From posts service
      title
      comments {        # From comments service
        text
      }
    }
  }
}

Federation Patterns

/**
 * Value Types (Shared Types)
 */

// Shared in multiple services
type Address {
  street: String!
  city: String!
  country: String!
}

type User @key(fields: "id") {
  id: ID!
  name: String!
  address: Address!
}

/**
 * Entity Extension
 */

// User service owns User
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

// Order service extends User
extend type User @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!
}

/**
 * Computed Fields
 */

// User service
type User @key(fields: "id") {
  id: ID!
  firstName: String!
  lastName: String!
}

// Profile service adds computed field
extend type User @key(fields: "id") {
  id: ID! @external
  firstName: String! @external
  lastName: String! @external
  fullName: String! @requires(fields: "firstName lastName")
}

const resolvers = {
  User: {
    fullName: (user: User) => {
      return `${user.firstName} ${user.lastName}`;
    },
  },
};

/**
 * Cross-Service References
 */

// Post service
type Post @key(fields: "id") {
  id: ID!
  title: String!
  authorId: ID! @shareable
  author: User! @provides(fields: "id")
}

const resolvers = {
  Post: {
    author: (post: Post) => {
      // Return stub that gateway will resolve
      return { __typename: 'User', id: post.authorId };
    },
  },
};
Common Pitfall: Don't create circular dependencies between subgraphs. Design clear ownership boundaries - each entity should have one owning service that manages its core fields.

13. Security Best Practices

Rate Limiting

/**
 * Rate Limiting
 */

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL,
});

await redis.connect();

// Rate limit by IP
const limiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:',
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      errors: [{
        message: 'Too many requests',
        extensions: { code: 'RATE_LIMIT_EXCEEDED' },
      }],
    });
  },
});

app.use('/graphql', limiter);

/**
 * Per-User Rate Limiting
 */

const createUserLimiter = () => {
  const limiters = new Map();

  return async (userId: string, maxRequests: number, windowMs: number) => {
    const now = Date.now();
    const limiter = limiters.get(userId);

    if (!limiter || now > limiter.resetAt) {
      // Reset window
      limiters.set(userId, {
        count: 1,
        resetAt: now + windowMs,
      });
      return true;
    }

    if (limiter.count >= maxRequests) {
      return false;
    }

    limiter.count++;
    return true;
  };
};

const userLimiter = createUserLimiter();

// Use in resolvers
const resolvers = {
  Mutation: {
    createPost: async (_parent, _args, context: Context) => {
      // 10 posts per hour
      const allowed = await userLimiter(context.user.id, 10, 60 * 60 * 1000);

      if (!allowed) {
        throw new GraphQLError('Rate limit exceeded', {
          extensions: { code: 'RATE_LIMIT_EXCEEDED' },
        });
      }

      return context.db.post.create({ data: _args.input });
    },
  },
};

Query Allowlisting

/**
 * Query Allowlisting (Persisted Queries)
 */

// Store of allowed queries
const allowedQueries = new Map([
  ['GetUser', 'query GetUser($id: ID!) { user(id: $id) { id name } }'],
  ['CreatePost', 'mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title } }'],
]);

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      async requestDidStart({ request }) {
        // In production, only allow queries from allowlist
        if (process.env.NODE_ENV === 'production') {
          const { query, operationName } = request;

          if (!operationName || !allowedQueries.has(operationName)) {
            throw new GraphQLError('Query not allowed', {
              extensions: { code: 'QUERY_NOT_ALLOWED' },
            });
          }

          // Verify query matches
          if (query !== allowedQueries.get(operationName)) {
            throw new GraphQLError('Query mismatch', {
              extensions: { code: 'QUERY_MISMATCH' },
            });
          }
        }

        return {};
      },
    },
  ],
});

Input Sanitization

/**
 * Input Sanitization
 */

import DOMPurify from 'isomorphic-dompurify';

function sanitizeString(input: string): string {
  // Remove HTML tags
  return DOMPurify.sanitize(input, {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: [],
  });
}

function sanitizeInput>(input: T): T {
  const sanitized: any = {};

  for (const [key, value] of Object.entries(input)) {
    if (typeof value === 'string') {
      sanitized[key] = sanitizeString(value);
    } else if (typeof value === 'object' && value !== null) {
      sanitized[key] = sanitizeInput(value);
    } else {
      sanitized[key] = value;
    }
  }

  return sanitized;
}

// Use in resolvers
const resolvers = {
  Mutation: {
    createPost: async (_parent, args: { input: CreatePostInput }) => {
      // Sanitize input
      const sanitized = sanitizeInput(args.input);

      return db.post.create({
        data: sanitized,
      });
    },
  },
};

/**
 * SQL Injection Prevention
 */

// BAD: String concatenation (vulnerable)
const users = await db.$queryRaw(
  `SELECT * FROM users WHERE email = '${email}'`
);

// GOOD: Parameterized queries
const users = await db.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE email = ${email}`
);

CSRF Protection

/**
 * CSRF Protection
 */

const server = new ApolloServer({
  typeDefs,
  resolvers,
  csrfPrevention: true, // Enable CSRF protection
});

// Client must send one of:
// - Apollo-Require-Preflight: true header
// - X-Apollo-Operation-Name header
// - Content-Type: application/json

/**
 * CORS Configuration
 */

import cors from 'cors';

app.use(
  '/graphql',
  cors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
    credentials: true,
  }),
  expressMiddleware(server, { context: createContext })
);
Pro Tip: Layer security measures: rate limiting (prevent abuse), query complexity (prevent expensive queries), allowlisting (prevent malicious queries), and input sanitization (prevent injection).

14. Monitoring & Observability

Apollo Studio

/**
 * Apollo Studio Integration
 */

import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginUsageReporting({
      sendVariableValues: { all: true },
      sendHeaders: { all: true },
      sendErrors: { masked: true },
    }),
  ],
});

// Set APOLLO_KEY and APOLLO_GRAPH_REF environment variables

Custom Metrics

/**
 * Prometheus Metrics
 */

import { Counter, Histogram, register } from 'prom-client';

// Metrics
const queryCounter = new Counter({
  name: 'graphql_queries_total',
  help: 'Total number of GraphQL queries',
  labelNames: ['operation', 'status'],
});

const queryDuration = new Histogram({
  name: 'graphql_query_duration_seconds',
  help: 'GraphQL query duration',
  labelNames: ['operation'],
  buckets: [0.001, 0.01, 0.1, 0.5, 1, 5],
});

const resolverDuration = new Histogram({
  name: 'graphql_resolver_duration_seconds',
  help: 'GraphQL resolver duration',
  labelNames: ['type', 'field'],
  buckets: [0.001, 0.01, 0.1, 0.5, 1],
});

// Plugin
const metricsPlugin: ApolloServerPlugin = {
  async requestDidStart(requestContext) {
    const start = Date.now();
    const operation = requestContext.request.operationName || 'anonymous';

    return {
      async willSendResponse() {
        const duration = (Date.now() - start) / 1000;
        const status = requestContext.errors ? 'error' : 'success';

        queryCounter.inc({ operation, status });
        queryDuration.observe({ operation }, duration);
      },

      async executionDidStart() {
        return {
          willResolveField({ info }) {
            const start = Date.now();

            return () => {
              const duration = (Date.now() - start) / 1000;
              resolverDuration.observe(
                { type: info.parentType.name, field: info.fieldName },
                duration
              );
            };
          },
        };
      },
    };
  },
};

// Metrics endpoint
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

Tracing

/**
 * OpenTelemetry Tracing
 */

import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('graphql-server');

const tracingPlugin: ApolloServerPlugin = {
  async requestDidStart({ request }) {
    const span = tracer.startSpan('graphql.request', {
      attributes: {
        'graphql.operation.name': request.operationName,
        'graphql.operation.type': request.query?.includes('mutation')
          ? 'mutation'
          : 'query',
      },
    });

    return {
      async willSendResponse() {
        span.end();
      },

      async executionDidStart() {
        return {
          willResolveField({ info }) {
            const fieldSpan = tracer.startSpan('graphql.resolve', {
              attributes: {
                'graphql.field.name': info.fieldName,
                'graphql.field.type': info.parentType.name,
              },
            });

            return () => {
              fieldSpan.end();
            };
          },
        };
      },
    };
  },
};
Pro Tip: Track resolver performance to identify slow fields. Use distributed tracing to debug performance issues across microservices in federated architectures.

15. Testing GraphQL APIs

Integration Tests

/**
 * Integration Testing with Apollo Server
 */

import { ApolloServer } from '@apollo/server';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

describe('GraphQL API', () => {
  let server: ApolloServer;
  let db: Database;

  beforeAll(async () => {
    // Setup test database
    db = await setupTestDatabase();

    server = new ApolloServer({
      typeDefs,
      resolvers,
    });

    await server.start();
  });

  afterAll(async () => {
    await server.stop();
    await db.$disconnect();
  });

  describe('Query.user', () => {
    it('fetches user by ID', async () => {
      // Create test user
      const user = await db.user.create({
        data: {
          name: 'John Doe',
          email: '[email protected]',
        },
      });

      // Execute query
      const result = await server.executeOperation({
        query: `
          query GetUser($id: ID!) {
            user(id: $id) {
              id
              name
              email
            }
          }
        `,
        variables: { id: user.id },
      }, {
        contextValue: {
          user: null,
          db,
          loaders: createLoaders(db),
        },
      });

      // Assert
      expect(result.body.kind).toBe('single');
      if (result.body.kind === 'single') {
        expect(result.body.singleResult.errors).toBeUndefined();
        expect(result.body.singleResult.data?.user).toEqual({
          id: user.id,
          name: 'John Doe',
          email: '[email protected]',
        });
      }
    });

    it('returns null for non-existent user', async () => {
      const result = await server.executeOperation({
        query: `
          query GetUser($id: ID!) {
            user(id: $id) {
              id
            }
          }
        `,
        variables: { id: 'non-existent' },
      }, {
        contextValue: {
          user: null,
          db,
          loaders: createLoaders(db),
        },
      });

      expect(result.body.kind).toBe('single');
      if (result.body.kind === 'single') {
        expect(result.body.singleResult.data?.user).toBeNull();
      }
    });
  });

  describe('Mutation.createUser', () => {
    it('creates new user', async () => {
      const result = await server.executeOperation({
        query: `
          mutation CreateUser($input: CreateUserInput!) {
            createUser(input: $input) {
              id
              name
              email
            }
          }
        `,
        variables: {
          input: {
            name: 'Jane Doe',
            email: '[email protected]',
          },
        },
      }, {
        contextValue: {
          user: null,
          db,
          loaders: createLoaders(db),
        },
      });

      expect(result.body.kind).toBe('single');
      if (result.body.kind === 'single') {
        expect(result.body.singleResult.errors).toBeUndefined();
        expect(result.body.singleResult.data?.createUser).toMatchObject({
          name: 'Jane Doe',
          email: '[email protected]',
        });
      }
    });

    it('rejects invalid email', async () => {
      const result = await server.executeOperation({
        query: `
          mutation CreateUser($input: CreateUserInput!) {
            createUser(input: $input) {
              id
            }
          }
        `,
        variables: {
          input: {
            name: 'Test',
            email: 'invalid-email',
          },
        },
      }, {
        contextValue: {
          user: null,
          db,
          loaders: createLoaders(db),
        },
      });

      expect(result.body.kind).toBe('single');
      if (result.body.kind === 'single') {
        expect(result.body.singleResult.errors).toBeDefined();
        expect(result.body.singleResult.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT');
      }
    });
  });
});

Resolver Unit Tests

/**
 * Unit Testing Resolvers
 */

import { describe, it, expect, vi } from 'vitest';

describe('User resolvers', () => {
  it('fetches user posts', async () => {
    const mockDb = {
      post: {
        findMany: vi.fn().mockResolvedValue([
          { id: '1', title: 'Post 1' },
          { id: '2', title: 'Post 2' },
        ]),
      },
    };

    const parent = { id: 'user-1' };
    const context = { db: mockDb, loaders: {} };

    const result = await resolvers.User.posts(parent, {}, context, {} as any);

    expect(mockDb.post.findMany).toHaveBeenCalledWith({
      where: { authorId: 'user-1' },
    });
    expect(result).toHaveLength(2);
  });
});

Schema Testing

/**
 * Schema Validation Tests
 */

import { assertValidSchema, buildSchema } from 'graphql';
import { describe, it } from 'vitest';

describe('GraphQL Schema', () => {
  it('is valid', () => {
    const schema = buildSchema(typeDefs);
    assertValidSchema(schema);
  });

  it('has Query type', () => {
    const schema = buildSchema(typeDefs);
    expect(schema.getQueryType()).toBeDefined();
  });

  it('has Mutation type', () => {
    const schema = buildSchema(typeDefs);
    expect(schema.getMutationType()).toBeDefined();
  });
});
Common Pitfall: Don't test GraphQL queries as HTTP requests in unit tests. Use server.executeOperation() to test business logic without HTTP overhead.

Learning Path

Recommended Order:

  1. GraphQL Fundamentals - Understand queries, mutations, types
  2. Schema Design - Learn best practices for schema design
  3. Resolvers - Implement resolver logic
  4. DataLoader - Solve N+1 problem with batching
  5. Apollo Server - Set up production server
  6. Authentication - Implement auth and authorization
  7. Performance - Optimize with caching and complexity limits
  8. Subscriptions - Add real-time features
  9. Federation - Scale with microservices
  10. Security - Harden production API

Related Sheets: