GraphQL Queries and Mutations

Advanced Techniques and Optimization Strategies

Introduction

In our previous lectures, we explored the differences between REST and GraphQL, and set up Apollo Server to build a GraphQL API. Now we'll dive deeper into GraphQL queries and mutations, examining advanced techniques, patterns, and optimization strategies.

As your GraphQL API grows in complexity and usage, understanding these advanced concepts becomes essential for maintaining performance, scalability, and developer experience. Today's lecture will equip you with the knowledge to build sophisticated GraphQL APIs that can handle complex operations efficiently.

Advanced Query Techniques

Query Variables

Variables allow clients to pass dynamic values to queries, making them reusable and more maintainable:

Using Query Variables

// Query definition with variables
query GetBook($id: ID!, $includeAuthor: Boolean!, $includeReviews: Boolean!) {
  book(id: $id) {
    id
    title
    year
    # Conditional field inclusion
    author @include(if: $includeAuthor) {
      name
      bio
    }
    # Conditional field inclusion
    reviews @include(if: $includeReviews) {
      rating
      text
    }
  }
}

// Variables provided separately (typically as JSON)
{
  "id": "123",
  "includeAuthor": true,
  "includeReviews": false
}
                

Variables offer several advantages:

Directives

Directives provide a way to dynamically change query execution based on variables:

Using Directives

query GetUserData($includePrivate: Boolean!, $skipInternal: Boolean!) {
  user(id: "123") {
    id
    name
    email
    
    # Only include if variable is true
    phoneNumber @include(if: $includePrivate)
    address @include(if: $includePrivate) {
      street
      city
      country
    }
    
    # Skip if variable is true
    internalNotes @skip(if: $skipInternal)
    
    # Field is marked as deprecated
    username @deprecated(reason: "Use 'name' instead")
  }
}
                

Aliases

Aliases allow you to query the same field multiple times with different arguments:

Using Aliases

query CompareTwoBooks {
  firstBook: book(id: "123") {
    title
    author {
      name
    }
    rating
  }
  
  secondBook: book(id: "456") {
    title
    author {
      name
    }
    rating
  }
}

// Response:
{
  "data": {
    "firstBook": {
      "title": "The Great Gatsby",
      "author": { "name": "F. Scott Fitzgerald" },
      "rating": 4.2
    },
    "secondBook": {
      "title": "To Kill a Mockingbird",
      "author": { "name": "Harper Lee" },
      "rating": 4.3
    }
  }
}
                

Fragments

Fragments are reusable units of a query that help reduce redundancy and improve maintainability:

Using Fragments

# Define a fragment on the Book type
fragment BookDetails on Book {
  id
  title
  year
  genre
  summary
}

fragment AuthorDetails on Author {
  id
  name
  bio
  birthDate
}

# Use fragments in queries
query GetBooksWithAuthors {
  books {
    ...BookDetails
    author {
      ...AuthorDetails
    }
  }
  
  featuredBook {
    ...BookDetails
    coverImage
    author {
      ...AuthorDetails
      profileImage
    }
  }
}
                

Fragments are particularly useful for:

Inline Fragments and Type Conditions

When querying interfaces or unions, inline fragments with type conditions allow you to request type-specific fields:

Using Inline Fragments

# Schema with interface
interface SearchResult {
  id: ID!
  title: String!
}

type Book implements SearchResult {
  id: ID!
  title: String!
  author: Author!
  pages: Int
}

type Movie implements SearchResult {
  id: ID!
  title: String!
  director: String!
  duration: Int
}

type Query {
  search(term: String!): [SearchResult!]!
}

# Query with inline fragments
query Search($term: String!) {
  search(term: $term) {
    id
    title
    # Type-specific fields
    ... on Book {
      author {
        name
      }
      pages
    }
    ... on Movie {
      director
      duration
    }
  }
}
                
                    graph TD
                    A[Query] --> B[SearchResult Interface]
                    B --> C[Common Fields: id, title]
                    B --> D[Book Type]
                    B --> E[Movie Type]
                    D --> F[Book-specific fields: author, pages]
                    E --> G[Movie-specific fields: director, duration]
                

Advanced Mutation Techniques

Mutation Design Patterns

Well-designed mutations follow these patterns:

Well-Designed Mutation

# Input type for create user
input CreateUserInput {
  firstName: String!
  lastName: String!
  email: String!
  password: String!
  role: UserRole
}

# Return type with payload pattern
type CreateUserPayload {
  user: User               # The created entity
  token: String            # Authentication token
  errors: [UserError]      # Operation-specific errors
}

type UserError {
  path: String             # Path to the input field with the error
  message: String          # Error message
}

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

Optimistic Updates with Optimistic Response

Optimistic updates allow client applications to update their UI immediately before the server responds, improving perceived performance:

Optimistic Update with Apollo Client

// Client-side code with Apollo Client
const [addTodo] = useMutation(ADD_TODO_MUTATION);

function handleAddTodo() {
  addTodo({
    variables: {
      input: {
        text: "New Todo",
        completed: false
      }
    },
    // Optimistic response
    optimisticResponse: {
      addTodo: {
        __typename: "AddTodoPayload",
        todo: {
          __typename: "Todo",
          id: "temp-id-" + Date.now(),
          text: "New Todo",
          completed: false
        },
        errors: []
      }
    },
    // Update cache immediately
    update: (cache, { data }) => {
      // Update cache logic here
    }
  });
}
                

File Uploads

GraphQL has a multipart request specification for file uploads:

File Upload Mutation

# First, add the Upload scalar to your schema
scalar Upload

type Mutation {
  uploadProfileImage(userId: ID!, file: Upload!): User
  uploadBookCover(bookId: ID!, file: Upload!): Book
}

# Server implementation with Apollo Server
const { ApolloServer, gql } = require('apollo-server-express');
const { GraphQLUpload } = require('graphql-upload');

const typeDefs = gql`
  scalar Upload
  
  type Mutation {
    uploadProfileImage(userId: ID!, file: Upload!): User
  }
`;

const resolvers = {
  Upload: GraphQLUpload,
  
  Mutation: {
    uploadProfileImage: async (_, { userId, file }) => {
      // file is a promise that resolves to an object
      const { createReadStream, filename, mimetype } = await file;
      
      // Create a stream to read the file
      const stream = createReadStream();
      
      // Process the file (e.g., store it, resize it, etc.)
      const user = await processUploadedFile(userId, stream, filename, mimetype);
      
      return user;
    }
  }
};

// Configure Apollo Server with Express and file upload middleware
const express = require('express');
const { graphqlUploadExpress } = require('graphql-upload');

async function startServer() {
  const app = express();
  
  // Apply file upload middleware
  app.use(graphqlUploadExpress({
    maxFileSize: 10000000, // 10 MB
    maxFiles: 10
  }));
  
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    context: ({ req }) => ({ req })
  });
  
  await server.start();
  server.applyMiddleware({ app });
  
  app.listen({ port: 4000 }, () =>
    console.log(`Server ready at http://localhost:4000${server.graphqlPath}`)
  );
}

startServer();
                

Batch Mutations

For operations that need to modify multiple entities, batch mutations are more efficient than multiple single mutations:

Batch Mutation Example

# Instead of this:
mutation {
  addUser1: addUser(input: { name: "User 1" }) { id }
  addUser2: addUser(input: { name: "User 2" }) { id }
  addUser3: addUser(input: { name: "User 3" }) { id }
}

# Use a batch mutation:
type Mutation {
  # Single user mutation
  addUser(input: AddUserInput!): AddUserPayload
  
  # Batch mutation
  addUsers(input: [AddUserInput!]!): [AddUserPayload!]!
}

# Implementation
const resolvers = {
  Mutation: {
    addUsers: async (_, { input }) => {
      // Process all inputs in a single transaction
      const results = await db.transaction(async (tx) => {
        return Promise.all(input.map(userData => 
          tx.user.create({ data: userData })
        ));
      });
      
      return results.map(user => ({ user, errors: [] }));
    }
  }
};
                

Nested Mutations

Complex operations often require creating or updating multiple related entities at once:

Nested Mutation Example

input CreatePostInput {
  title: String!
  content: String!
  
  # Nested input for creating tags
  tags: [CreateTagInput!]
  
  # Nested input for creating comments
  comments: [CreateCommentInput!]
}

input CreateTagInput {
  name: String!
}

input CreateCommentInput {
  content: String!
  authorId: ID!
}

type Mutation {
  createPost(input: CreatePostInput!): Post
}

# Implementation
const resolvers = {
  Mutation: {
    createPost: async (_, { input }) => {
      const { title, content, tags, comments } = input;
      
      // Create post and related entities in a transaction
      return db.transaction(async (tx) => {
        // Create post
        const post = await tx.post.create({
          data: {
            title,
            content
          }
        });
        
        // Create tags if provided
        if (tags && tags.length > 0) {
          await Promise.all(tags.map(tag => 
            tx.tag.create({
              data: {
                ...tag,
                postId: post.id
              }
            })
          ));
        }
        
        // Create comments if provided
        if (comments && comments.length > 0) {
          await Promise.all(comments.map(comment => 
            tx.comment.create({
              data: {
                ...comment,
                postId: post.id
              }
            })
          ));
        }
        
        // Return post with relationships
        return tx.post.findUnique({
          where: { id: post.id },
          include: {
            tags: true,
            comments: true
          }
        });
      });
    }
  }
};
                

Pagination Strategies

As your data grows, proper pagination becomes essential for performance and user experience.

Offset-Based Pagination

The simplest approach uses offset and limit parameters:

Offset Pagination

type Query {
  books(offset: Int = 0, limit: Int = 10): BooksConnection
}

type BooksConnection {
  books: [Book!]!
  totalCount: Int!
  hasMore: Boolean!
}

# Implementation
const resolvers = {
  Query: {
    books: async (_, { offset, limit }) => {
      // Get total count
      const totalCount = await db.book.count();
      
      // Get books with pagination
      const books = await db.book.findMany({
        skip: offset,
        take: limit,
        orderBy: { title: 'asc' }
      });
      
      return {
        books,
        totalCount,
        hasMore: offset + books.length < totalCount
      };
    }
  }
};
                

Pros:

Cons:

Cursor-Based Pagination

A more efficient approach that uses a cursor to mark where to continue from:

Cursor Pagination

type Query {
  books(first: Int = 10, after: String): BooksConnection
}

type BooksConnection {
  edges: [BookEdge!]!
  pageInfo: PageInfo!
}

type BookEdge {
  node: Book!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

# Implementation
const resolvers = {
  Query: {
    books: async (_, { first, after }) => {
      // Decode the cursor (or use null if not provided)
      let afterBook = null;
      if (after) {
        const decodedCursor = Buffer.from(after, 'base64').toString('utf-8');
        afterBook = JSON.parse(decodedCursor);
      }
      
      // Build query
      const query = {
        take: first + 1, // Take one extra to check if there's more
        orderBy: { createdAt: 'desc' }
      };
      
      // Add filter for cursor
      if (afterBook) {
        query.cursor = {
          id: afterBook.id
        };
        query.skip = 1; // Skip the cursor
      }
      
      // Get books
      const books = await db.book.findMany(query);
      
      // Check if there's a next page
      const hasNextPage = books.length > first;
      if (hasNextPage) {
        books.pop(); // Remove the extra book
      }
      
      // Create edges with cursors
      const edges = books.map(book => ({
        node: book,
        cursor: Buffer.from(JSON.stringify({ id: book.id })).toString('base64')
      }));
      
      return {
        edges,
        pageInfo: {
          hasNextPage,
          endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null
        }
      };
    }
  }
};
                

Pros:

Cons:

Relay Cursor Connections

A standardized approach to cursor-based pagination used by Relay and adopted by many GraphQL APIs:

Relay Cursor Connections

type Query {
  books(
    first: Int,
    after: String,
    last: Int,
    before: String
  ): BookConnection
}

type BookConnection {
  edges: [BookEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type BookEdge {
  node: Book!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
                
                    graph LR
                    A[Query] --> B[Connection]
                    B --> C[Edges]
                    B --> D[PageInfo]
                    B --> E[TotalCount]
                    C --> F[Edge 1]
                    C --> G[Edge 2]
                    C --> H[Edge 3]
                    F --> I[Node]
                    F --> J[Cursor]
                    D --> K[HasNextPage]
                    D --> L[HasPreviousPage]
                    D --> M[StartCursor]
                    D --> N[EndCursor]
                

Pagination Analogy: Bookshelf

Different pagination strategies are like different ways to browse books on a bookshelf:

  • Offset-Based Pagination is like saying "Show me 10 books, starting from the 20th book on the shelf" - it works well for smaller collections but gets unwieldy for large libraries.
  • Cursor-Based Pagination is like placing a bookmark and saying "Show me the next 10 books after this bookmark" - more efficient for browsing through large collections.
  • Relay Cursor Connections is like a sophisticated library system with a catalog that tracks not just your current position, but how many books are in each section, whether there are more books in either direction, and provides special markers for finding your place again.

Performance Optimization

The N+1 Query Problem

One of the most common performance issues in GraphQL is the N+1 query problem:

                    graph TD
                    A[GraphQL Query: List of Books with Authors] --> B[Database Query 1: Get Books]
                    B --> C[Book 1]
                    B --> D[Book 2]
                    B --> E[Book 3]
                    C --> F[Database Query 2: Get Author for Book 1]
                    D --> G[Database Query 3: Get Author for Book 2]
                    E --> H[Database Query 4: Get Author for Book 3]
                

When a query fetches a list of objects and then needs to fetch related data for each object, it can result in a large number of database queries.

DataLoader for Batching and Caching

DataLoader solves the N+1 problem by batching and caching database requests:

DataLoader Implementation

const DataLoader = require('dataloader');

// Create a loader instance
const createLoaders = () => {
  return {
    // Author loader
    authorLoader: new DataLoader(async (authorIds) => {
      console.log(`Batch loading ${authorIds.length} authors`);
      
      // Get all authors in a single query
      const authors = await db.author.findMany({
        where: {
          id: {
            in: authorIds
          }
        }
      });
      
      // Return authors in the same order as the IDs
      return authorIds.map(id => 
        authors.find(author => author.id === id)
      );
    }),
    
    // Book loader
    bookLoader: new DataLoader(async (bookIds) => {
      console.log(`Batch loading ${bookIds.length} books`);
      
      const books = await db.book.findMany({
        where: {
          id: {
            in: bookIds
          }
        }
      });
      
      return bookIds.map(id => 
        books.find(book => book.id === id)
      );
    }),
    
    // Books by author loader
    booksByAuthorLoader: new DataLoader(async (authorIds) => {
      console.log(`Batch loading books for ${authorIds.length} authors`);
      
      const books = await db.book.findMany({
        where: {
          authorId: {
            in: authorIds
          }
        }
      });
      
      // Group books by author ID
      const booksByAuthor = authorIds.map(authorId => 
        books.filter(book => book.authorId === authorId)
      );
      
      return booksByAuthor;
    })
  };
};

// Set up Apollo Server with DataLoader
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => {
    return {
      loaders: createLoaders()
    };
  }
});

// Use loaders in resolvers
const resolvers = {
  Book: {
    author: (book, _, { loaders }) => {
      return loaders.authorLoader.load(book.authorId);
    }
  },
  Author: {
    books: (author, _, { loaders }) => {
      return loaders.booksByAuthorLoader.load(author.id);
    }
  },
  Query: {
    book: (_, { id }, { loaders }) => {
      return loaders.bookLoader.load(id);
    },
    books: () => db.book.findMany()
  }
};
                

DataLoader provides two key benefits:

Field-Level Cost Analysis

Calculating the "cost" of a query can help prevent resource-intensive operations:

Query Cost Analysis

const { ApolloServer } = require('apollo-server');
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');

// Create Apollo Server with complexity analysis
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    // Set up complexity analysis
    (context) => {
      return getComplexity({
        // Our schema
        schema: context.getSchema(),
        // Query document AST
        query: context.getDocument(),
        // Variables for our query
        variables: context.request.variables,
        // Add complexity based on field
        estimators: [
          // Default complexity for fields
          simpleEstimator({ defaultComplexity: 1 }),
          
          // Custom field complexity
          fieldExtensionsEstimator()
        ],
        // Maximum allowed complexity (reject queries above this)
        maximumComplexity: 1000,
        // Error handler
        onComplete: (complexity) => {
          console.log(`Query Complexity: ${complexity}`);
        }
      });
    }
  ]
});

// Custom estimator that reads complexity from field extensions
function fieldExtensionsEstimator() {
  return {
    // This function returns a complexity number for a given field
    getComplexity: (args) => {
      const { field } = args;
      
      // Get complexity from field extension
      const complexity = field.extensions?.complexity;
      
      if (typeof complexity === 'number') {
        return complexity;
      }
      
      // For paginated fields, factor in the limit
      if (field.name === 'books' && args.args.limit) {
        return args.childComplexity * args.args.limit;
      }
      
      return null; // Use default estimator
    }
  };
}

// Define complexity in schema using directives or extensions
const typeDefs = gql`
  type Book @complexity(value: 2) {
    id: ID!
    title: String!
    author: Author! @complexity(value: 3)
    reviews: [Review!]! @complexity(value: 5)
  }
  
  type Query {
    books(limit: Int = 10): [Book!]!
    searchBooks(term: String!): [Book!]! @complexity(value: 10)
    complexAnalysis: AnalysisResult! @complexity(value: 25)
  }
  
  directive @complexity(value: Int!) on FIELD_DEFINITION | OBJECT
`;
                

Persisted Queries

Persisted queries can significantly reduce network overhead and improve security:

Setting Up Persisted Queries

// Server setup with persisted queries
const { ApolloServer } = require('apollo-server');
const { createPersistedQueryLink } = require('@apollo/client/link/persisted-queries');
const { createHash } = require('crypto');

// Create Apollo Server with persisted queries
const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    // Use SHA-256 by default
    cache: new Map(),
    // Custom TTL (time to live) in ms
    ttl: 900000 // 15 minutes
  }
});

// Client setup with persisted queries
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { createHttpLink } from '@apollo/client/link/http';
import { sha256 } from 'crypto-hash';

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

const persistedQueryLink = createPersistedQueryLink({
  useGETForHashedQueries: true,
  sha256: async (query) => await sha256(query)
});

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: persistedQueryLink.concat(httpLink)
});
                

Benefits of persisted queries:

Response Caching

Proper caching can dramatically improve performance for frequently requested data:

Setting Up Response Caching

const { ApolloServer } = require('apollo-server');
const responseCachePlugin = require('apollo-server-plugin-response-cache');

// Server with response caching
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [responseCachePlugin({
    // Don't cache authenticated responses
    shouldReadFromCache: requestContext => !requestContext.request.http.headers.has('authorization'),
    // Don't write authenticated responses to cache
    shouldWriteToCache: requestContext => !requestContext.request.http.headers.has('authorization')
  })],
  cacheControl: {
    defaultMaxAge: 30, // Default 30 seconds
    calculateHttpHeaders: true
  }
});

// Add cache hints in schema
const typeDefs = gql`
  type Book @cacheControl(maxAge: 3600) {
    id: ID!
    title: String!
    # Author data changes less frequently
    author: Author! @cacheControl(maxAge: 86400)
    # Reviews change more frequently
    reviews: [Review!]! @cacheControl(maxAge: 300)
  }
  
  type Query {
    books: [Book!]! @cacheControl(maxAge: 600)
    book(id: ID!): Book @cacheControl(maxAge: 3600)
    # This should not be cached
    currentUserFavorites: [Book!]! @cacheControl(maxAge: 0)
  }
  
  directive @cacheControl(
    maxAge: Int = 300,
    scope: CacheControlScope = PUBLIC
  ) on FIELD_DEFINITION | OBJECT | INTERFACE
  
  enum CacheControlScope {
    PUBLIC
    PRIVATE
  }
`;
                

Advanced Error Handling

Structured Error Responses

Instead of relying solely on GraphQL's errors array, include structured errors in your response types:

Payload Pattern with Errors

# Define common error type
type UserError {
  message: String!
  code: ErrorCode!
  path: [String!]
}

enum ErrorCode {
  NOT_FOUND
  UNAUTHORIZED
  VALIDATION_FAILED
  ALREADY_EXISTS
  INTERNAL_ERROR
}

# Use in mutation payloads
type CreateBookPayload {
  book: Book
  errors: [UserError!]!
}

type Mutation {
  createBook(input: CreateBookInput!): CreateBookPayload!
}

# Resolver implementation
const resolvers = {
  Mutation: {
    createBook: async (_, { input }) => {
      const errors = [];
      
      // Validate input
      if (!input.title) {
        errors.push({
          message: "Title is required",
          code: "VALIDATION_FAILED",
          path: ["input", "title"]
        });
      }
      
      // Check for duplicate
      const existing = await db.book.findFirst({
        where: { title: input.title }
      });
      
      if (existing) {
        errors.push({
          message: "Book with this title already exists",
          code: "ALREADY_EXISTS",
          path: ["input", "title"]
        });
      }
      
      // Return errors if any
      if (errors.length > 0) {
        return { book: null, errors };
      }
      
      // Process mutation
      try {
        const book = await db.book.create({
          data: input
        });
        
        return { book, errors: [] };
      } catch (error) {
        console.error("Failed to create book:", error);
        
        return {
          book: null,
          errors: [{
            message: "Failed to create book",
            code: "INTERNAL_ERROR",
            path: []
          }]
        };
      }
    }
  }
};
                

Benefits of structured errors:

Union Types for Complex Results

For operations with multiple potential outcomes, union types provide clarity:

Union Result Types

# Success type
type BookResult {
  book: Book!
}

# Different error types
type NotFoundError {
  message: String!
  id: ID!
}

type ValidationError {
  message: String!
  fieldErrors: [FieldError!]!
}

type FieldError {
  field: String!
  message: String!
}

type PermissionError {
  message: String!
  requiredPermission: String!
}

# Union of possible results
union BookOperationResult = 
  BookResult | 
  NotFoundError | 
  ValidationError | 
  PermissionError

# Query with union result
type Query {
  book(id: ID!): BookOperationResult!
}

# Resolver for union type
const resolvers = {
  Query: {
    book: async (_, { id }) => {
      // Check if book exists
      const book = await db.book.findUnique({
        where: { id }
      });
      
      if (!book) {
        return {
          __typename: "NotFoundError",
          message: `Book with ID ${id} not found`,
          id
        };
      }
      
      return {
        __typename: "BookResult",
        book
      };
    }
  },
  
  // Resolver for the union type
  BookOperationResult: {
    __resolveType(obj) {
      if (obj.book) return "BookResult";
      if (obj.id) return "NotFoundError";
      if (obj.fieldErrors) return "ValidationError";
      if (obj.requiredPermission) return "PermissionError";
      return null;
    }
  }
};
                
                    graph TD
                    A[BookOperationResult Union] --> B[BookResult]
                    A --> C[NotFoundError]
                    A --> D[ValidationError]
                    A --> E[PermissionError]
                    B --> F[book: Book!]
                    C --> G[message: String!]
                    C --> H[id: ID!]
                    D --> I[message: String!]
                    D --> J[fieldErrors: [FieldError!]!]
                    E --> K[message: String!]
                    E --> L[requiredPermission: String!]
                

GraphQL Development Tools

Apollo Studio

A powerful development environment for GraphQL APIs:

GraphQL Codegen

Generate typings and more from your GraphQL schema:

GraphQL Codegen Example

// Install dependencies
npm install -D @graphql-codegen/cli @graphql-codegen/typescript

// Create config file: codegen.yml
schema: http://localhost:4000/graphql
documents: ./src/**/*.graphql
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo

// Run codegen
npx graphql-codegen

// Generated output example
export type Book = {
  __typename?: 'Book';
  id: Scalars['ID'];
  title: Scalars['String'];
  author?: Maybe;
};

export type GetBooksQuery = {
  __typename?: 'Query';
  books: Array<{
    __typename?: 'Book';
    id: string;
    title: string;
    author?: {
      __typename?: 'Author';
      name: string;
    } | null;
  }>;
};

// Generated React hook
export function useGetBooksQuery(
  options?: Omit, 'query'>
) {
  return useQuery(
    GetBooksDocument,
    options
  );
}
                

GraphQL Inspector

Track and validate changes to your GraphQL schema:

GraphQL Inspector Example

// Install GraphQL Inspector
npm install -g @graphql-inspector/cli

// Compare schemas
graphql-inspector diff old-schema.graphql new-schema.graphql

// Output example
✖ Field 'createUser' was removed from object type 'Mutation'
✖ Argument 'role' was added to field 'Mutation.updateUser'
✖ 'Book.price' changed type from 'Float' to 'Int'
⚠ Enum value 'ADMIN' was added to enum 'UserRole'
                

Apollo Federation

Build a unified graph from multiple services:

Federation Example

// Users service
const { ApolloServer } = require('apollo-server');
const { buildFederatedSchema } = require('@apollo/federation');

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

// Books service
const typeDefs = gql`
  type Book @key(fields: "id") {
    id: ID!
    title: String!
    author: User @provides(fields: "id")
  }
  
  extend type User @key(fields: "id") {
    id: ID! @external
    books: [Book!]
  }
  
  extend type Query {
    books: [Book!]!
    book(id: ID!): Book
  }
`;

// Gateway
const { ApolloGateway } = require('@apollo/gateway');

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'users', url: 'http://localhost:4001/graphql' },
    { name: 'books', url: 'http://localhost:4002/graphql' }
  ]
});

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

Real-World Examples

GitHub GraphQL API

GitHub's API is a great example of a well-designed GraphQL API:

GitHub API Query Example

query {
  repository(owner: "facebook", name: "react") {
    name
    description
    stargazers {
      totalCount
    }
    issues(first: 10, states: [OPEN]) {
      totalCount
      edges {
        node {
          title
          author {
            login
          }
          labels(first: 5) {
            edges {
              node {
                name
                color
              }
            }
          }
        }
      }
    }
  }
}
                

Shopify StoreFront API

Shopify's API demonstrates complex e-commerce relationships:

Shopify API Example

# Create a cart and add items
mutation {
  cartCreate {
    cart {
      id
      createdAt
    }
    userErrors {
      field
      message
    }
  }
}

mutation($cartId: ID!, $lines: [CartLineInput!]!) {
  cartLinesAdd(cartId: $cartId, lines: $lines) {
    cart {
      id
      lines(first: 10) {
        edges {
          node {
            id
            quantity
            merchandise {
              ... on ProductVariant {
                id
                title
                product {
                  title
                  featuredImage {
                    url
                  }
                }
              }
            }
          }
        }
      }
      estimatedCost {
        subtotalAmount {
          amount
          currencyCode
        }
        totalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}
                

Practice Activities

Activity 1: Advanced Query Features

Enhance your existing GraphQL API with these features:

Activity 2: Implement Connection-Based Pagination

Add Relay-style cursor pagination to your API:

Activity 3: Optimize Performance with DataLoader

Identify and fix N+1 query problems in your API:

Activity 4: Structured Error Handling

Implement a robust error handling system:

Summary

In this lecture, we've explored advanced GraphQL concepts that take your API to the next level:

By implementing these advanced techniques, you can build GraphQL APIs that are not only functional but also performant, maintainable, and a joy for developers to use.

Coming Up Next

In our next lecture, we'll explore Progressive Web Apps (PWAs), learning how to create web applications that offer native-like experiences with features like offline functionality, push notifications, and home screen installation.

Additional Resources