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:
- Separate query logic from values
- Avoid string concatenation and injection issues
- Improve query reusability
- Enable caching and persisted queries
Directives
Directives provide a way to dynamically change query execution based on variables:
- @include(if: Boolean): Include field only if argument is true
- @skip(if: Boolean): Skip field if argument is true
- @deprecated(reason: String): Mark field as deprecated
- Custom directives: Implement your own directives for special behaviors
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:
- Avoiding field duplication
- Maintaining consistency across queries
- Making complex queries more readable
- Enabling component-based data fetching in clients like Relay
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:
- Naming: Use verb prefixes (create, update, delete) for clarity
- Input Types: Group related input fields into input types
- Return Types: Return the modified entity and related objects
- Error Handling: Return errors in the response, not just at the top level
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:
- Simple to implement and understand
- Works well for static data
- Easy to jump to specific pages
Cons:
- Inefficient for large datasets (database has to skip N rows)
- Inconsistent results if items are added/removed during pagination
- No natural cursor for resuming
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:
- More efficient for large datasets
- Consistent results even when data changes
- Natural support for infinite scrolling
Cons:
- More complex to implement
- Difficult to jump to specific pages
- Requires stable sorting criteria
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:
- Batching: Combines multiple individual loads into a single batch
- Caching: Caches results per request to avoid duplicate loads
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:
- Smaller request size (only hash sent instead of full query)
- Can use GET requests with caching
- Improved security (can reject unknown queries)
- Better monitoring and analytics
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:
- Clients can handle errors programmatically
- Partial success is possible (some operations succeed, others fail)
- Errors are tied directly to the fields they relate to
- Error codes enable standardized handling
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:
- Schema Explorer: Interactive documentation
- Operation Explorer: Build and test queries
- Metrics and Analytics: Monitor performance
- Schema Registry: Track schema changes
- Field Usage: See which fields are used most
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:
- Detect breaking changes
- Find similar types
- Validate documents against schema
- Get schema coverage
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:
- Uses the Relay Connection specification for pagination
- Implements global object identification
- Provides detailed error information
- Uses custom scalars for specialized types
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:
- Complex filtering and sorting options
- Extensive use of connection pattern
- Nested mutations for cart operations
- Custom directives for feature flags
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:
- Implement fragments for reusable query components
- Add directives for conditional field inclusion
- Create an interface and implement inline fragments
- Test with complex nested queries
Activity 2: Implement Connection-Based Pagination
Add Relay-style cursor pagination to your API:
- Create connection types with edges and nodes
- Implement cursor encoding and decoding
- Add pageInfo with hasNextPage and endCursor
- Test with various page sizes and cursor positions
Activity 3: Optimize Performance with DataLoader
Identify and fix N+1 query problems in your API:
- Set up DataLoader for related entities
- Implement batch loading functions
- Add DataLoaders to your context
- Measure and compare performance before and after
Activity 4: Structured Error Handling
Implement a robust error handling system:
- Create common error types with codes
- Use the payload pattern with error arrays
- Implement a union type for complex operations
- Test error scenarios and response formats
Summary
In this lecture, we've explored advanced GraphQL concepts that take your API to the next level:
- Advanced Query Techniques: Variables, directives, aliases, and fragments make queries more flexible and reusable
- Advanced Mutations: Well-designed mutation patterns with structured inputs and outputs improve developer experience
- Pagination Strategies: Different approaches to pagination address various performance and usability needs
- Performance Optimization: DataLoader, persisted queries, and caching strategies help your API scale efficiently
- Error Handling: Structured errors and result types provide clear feedback to clients
- Development Tools: Various tools enhance the development experience and help maintain quality
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.