Introduction to GraphQL

GraphQL is a query language for APIs that allows clients to request exactly the data they need. Unlike REST, where endpoints return fixed data structures, GraphQL provides a flexible approach to data fetching.

GraphQL vs REST

// REST: Multiple endpoints, over-fetching
GET /api/users/1          → { id, name, email, avatar, bio, ... }
GET /api/users/1/posts    → [{ id, title, content, ... }]
GET /api/users/1/friends  → [{ id, name, ... }]

// GraphQL: Single endpoint, exact data
POST /graphql
query {
  user(id: 1) {
    name
    posts { title }
    friends { name }
  }
}

Setting Up GraphQL Server

Apollo Server with Express

// server.js
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const cors = require('cors');

// Type Definitions (Schema)
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
    createdAt: String!
  }

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

  type Comment {
    id: ID!
    text: String!
    author: User!
    post: Post!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts(published: Boolean): [Post!]!
    post(id: ID!): Post
  }

  type Mutation {
    createUser(input: CreateUserInput!): User!
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post
    deletePost(id: ID!): Boolean!
  }

  input CreateUserInput {
    name: String!
    email: String!
  }

  input CreatePostInput {
    title: String!
    content: String!
    authorId: ID!
  }

  input UpdatePostInput {
    title: String
    content: String
    published: Boolean
  }
`;

// Resolvers
const resolvers = {
  Query: {
    users: () => User.findAll(),
    user: (_, { id }) => User.findById(id),
    posts: (_, { published }) => {
      if (published !== undefined) {
        return Post.findAll({ where: { published } });
      }
      return Post.findAll();
    },
    post: (_, { id }) => Post.findById(id),
  },

  Mutation: {
    createUser: async (_, { input }) => {
      return User.create(input);
    },
    createPost: async (_, { input }) => {
      return Post.create(input);
    },
    updatePost: async (_, { id, input }) => {
      await Post.update(id, input);
      return Post.findById(id);
    },
    deletePost: async (_, { id }) => {
      return Post.delete(id);
    },
  },

  // Field Resolvers (for relationships)
  User: {
    posts: (user) => Post.findByAuthorId(user.id),
  },
  Post: {
    author: (post) => User.findById(post.authorId),
    comments: (post) => Comment.findByPostId(post.id),
  },
  Comment: {
    author: (comment) => User.findById(comment.authorId),
    post: (comment) => Post.findById(comment.postId),
  },
};

async function startServer() {
  const app = express();

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

  await server.start();

  app.use(
    '/graphql',
    cors(),
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => ({
        user: req.user, // From auth middleware
        token: req.headers.authorization,
      }),
    })
  );

  app.listen(4000, () => {
    console.log('Server running at http://localhost:4000/graphql');
  });
}

startServer();

Advanced Schema Design

Enums, Interfaces, and Unions

const typeDefs = `#graphql
  # Enums
  enum Role {
    USER
    ADMIN
    MODERATOR
  }

  enum PostStatus {
    DRAFT
    PUBLISHED
    ARCHIVED
  }

  # Interface
  interface Node {
    id: ID!
    createdAt: String!
    updatedAt: String!
  }

  type User implements Node {
    id: ID!
    createdAt: String!
    updatedAt: String!
    name: String!
    role: Role!
  }

  type Post implements Node {
    id: ID!
    createdAt: String!
    updatedAt: String!
    title: String!
    status: PostStatus!
  }

  # Union Types
  union SearchResult = User | Post | Comment

  type Query {
    search(term: String!): [SearchResult!]!
    node(id: ID!): Node
  }
`;

const resolvers = {
  // Union type resolver
  SearchResult: {
    __resolveType(obj) {
      if (obj.email) return 'User';
      if (obj.title) return 'Post';
      if (obj.text) return 'Comment';
      return null;
    },
  },

  // Interface resolver
  Node: {
    __resolveType(obj) {
      if (obj.email) return 'User';
      if (obj.title) return 'Post';
      return null;
    },
  },

  Query: {
    search: async (_, { term }) => {
      const users = await User.search(term);
      const posts = await Post.search(term);
      const comments = await Comment.search(term);
      return [...users, ...posts, ...comments];
    },
  },
};

Pagination

const typeDefs = `#graphql
  # Cursor-based pagination (recommended)
  type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

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

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

  type Query {
    posts(
      first: Int
      after: String
      last: Int
      before: String
    ): PostConnection!
  }
`;

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after, last, before }) => {
      const decodedCursor = after
        ? Buffer.from(after, 'base64').toString()
        : null;

      const posts = await Post.findMany({
        take: first + 1, // Fetch one extra to check hasNextPage
        cursor: decodedCursor ? { id: decodedCursor } : undefined,
        skip: decodedCursor ? 1 : 0,
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = posts.length > first;
      const edges = posts.slice(0, first).map(post => ({
        cursor: Buffer.from(post.id).toString('base64'),
        node: post,
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: await Post.count(),
      };
    },
  },
};

Apollo Client Setup

Basic Configuration

// src/apollo/client.js
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  from,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';

// HTTP Link
const httpLink = createHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_URL || 'http://localhost:4000/graphql',
});

// Auth Link - Add token to headers
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

// Error Link - Global error handling
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, locations, path, extensions }) => {
      console.error(
        `[GraphQL error]: Message: ${message}, Path: ${path}`
      );

      // Handle specific error codes
      if (extensions?.code === 'UNAUTHENTICATED') {
        localStorage.removeItem('token');
        window.location.href = '/login';
      }
    });
  }

  if (networkError) {
    console.error(`[Network error]: ${networkError}`);
  }
});

// Cache Configuration
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          // Merge paginated results
          keyArgs: ['published'],
          merge(existing = { edges: [] }, incoming) {
            return {
              ...incoming,
              edges: [...existing.edges, ...incoming.edges],
            };
          },
        },
      },
    },
    User: {
      // Custom cache key
      keyFields: ['id'],
    },
  },
});

// Create Client
export const client = new ApolloClient({
  link: from([errorLink, authLink, httpLink]),
  cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});

// App.jsx
import { ApolloProvider } from '@apollo/client';
import { client } from './apollo/client';

function App() {
  return (
    <ApolloProvider client={client}>
      <Router>
        <Routes>{/* ... */}</Routes>
      </Router>
    </ApolloProvider>
  );
}

Queries with Apollo Client

Using useQuery Hook

// src/graphql/queries.js
import { gql } from '@apollo/client';

export const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
    }
  }
`;

export const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
        published
      }
    }
  }
`;

export const GET_POSTS = gql`
  query GetPosts($first: Int, $after: String, $published: Boolean) {
    posts(first: $first, after: $after, published: $published) {
      edges {
        cursor
        node {
          id
          title
          content
          author {
            id
            name
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;

// Components/UsersList.jsx
import { useQuery } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';

function UsersList() {
  const { loading, error, data, refetch } = useQuery(GET_USERS);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {data.users.map(user => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

// Components/UserProfile.jsx
import { useQuery } from '@apollo/client';
import { useParams } from 'react-router-dom';
import { GET_USER } from '../graphql/queries';

function UserProfile() {
  const { id } = useParams();

  const { loading, error, data } = useQuery(GET_USER, {
    variables: { id },
    // Skip query if no id
    skip: !id,
    // Polling for real-time updates
    pollInterval: 30000, // 30 seconds
  });

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!data?.user) return <NotFound />;

  const { user } = data;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <h2>Posts</h2>
      <ul>
        {user.posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Lazy Queries

import { useLazyQuery } from '@apollo/client';
import { useState } from 'react';

const SEARCH_USERS = gql`
  query SearchUsers($term: String!) {
    searchUsers(term: $term) {
      id
      name
      email
    }
  }
`;

function UserSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [searchUsers, { loading, data }] = useLazyQuery(SEARCH_USERS);

  const handleSearch = (e) => {
    e.preventDefault();
    searchUsers({ variables: { term: searchTerm } });
  };

  return (
    <div>
      <form onSubmit={handleSearch}>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Search users..."
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Searching...' : 'Search'}
        </button>
      </form>

      {data?.searchUsers && (
        <ul>
          {data.searchUsers.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Mutations with Apollo Client

Using useMutation Hook

// src/graphql/mutations.js
import { gql } from '@apollo/client';

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

export const UPDATE_POST = gql`
  mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
    updatePost(id: $id, input: $input) {
      id
      title
      content
      published
    }
  }
`;

export const DELETE_POST = gql`
  mutation DeletePost($id: ID!) {
    deletePost(id: $id)
  }
`;

// Components/CreatePostForm.jsx
import { useMutation } from '@apollo/client';
import { CREATE_POST, GET_POSTS } from '../graphql';

function CreatePostForm({ authorId }) {
  const [formData, setFormData] = useState({
    title: '',
    content: '',
  });

  const [createPost, { loading, error }] = useMutation(CREATE_POST, {
    // Update cache after mutation
    update(cache, { data: { createPost } }) {
      // Read current posts from cache
      const existingPosts = cache.readQuery({ query: GET_POSTS });

      // Write new posts to cache
      if (existingPosts) {
        cache.writeQuery({
          query: GET_POSTS,
          data: {
            posts: {
              ...existingPosts.posts,
              edges: [
                { cursor: createPost.id, node: createPost },
                ...existingPosts.posts.edges,
              ],
            },
          },
        });
      }
    },
    // Or simply refetch queries
    refetchQueries: [{ query: GET_POSTS }],

    onCompleted: (data) => {
      console.log('Post created:', data.createPost);
      setFormData({ title: '', content: '' });
    },
    onError: (error) => {
      console.error('Failed to create post:', error);
    },
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    await createPost({
      variables: {
        input: {
          ...formData,
          authorId,
        },
      },
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error.message}</div>}

      <input
        type="text"
        value={formData.title}
        onChange={(e) => setFormData({ ...formData, title: e.target.value })}
        placeholder="Post title"
        required
      />

      <textarea
        value={formData.content}
        onChange={(e) => setFormData({ ...formData, content: e.target.value })}
        placeholder="Write your post..."
        required
      />

      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Optimistic Updates

function TogglePublishButton({ post }) {
  const [updatePost] = useMutation(UPDATE_POST, {
    // Optimistic response - update UI immediately
    optimisticResponse: {
      updatePost: {
        __typename: 'Post',
        id: post.id,
        title: post.title,
        content: post.content,
        published: !post.published,
      },
    },
  });

  return (
    <button
      onClick={() =>
        updatePost({
          variables: {
            id: post.id,
            input: { published: !post.published },
          },
        })
      }
    >
      {post.published ? 'Unpublish' : 'Publish'}
    </button>
  );
}

// Delete with optimistic update
function DeletePostButton({ postId }) {
  const [deletePost] = useMutation(DELETE_POST, {
    variables: { id: postId },

    optimisticResponse: {
      deletePost: true,
    },

    update(cache) {
      // Remove from cache
      cache.modify({
        fields: {
          posts(existingPosts = { edges: [] }, { readField }) {
            return {
              ...existingPosts,
              edges: existingPosts.edges.filter(
                edge => readField('id', edge.node) !== postId
              ),
            };
          },
        },
      });

      // Evict the deleted post
      cache.evict({ id: `Post:${postId}` });
      cache.gc(); // Garbage collect
    },
  });

  return (
    <button onClick={() => deletePost()}>Delete</button>
  );
}

Subscriptions (Real-Time)

Server Setup

// server.js
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PubSub } = require('graphql-subscriptions');

const pubsub = new PubSub();

const typeDefs = `#graphql
  type Subscription {
    postCreated: Post!
    postUpdated(id: ID!): Post!
    commentAdded(postId: ID!): Comment!
  }
`;

const resolvers = {
  Mutation: {
    createPost: async (_, { input }) => {
      const post = await Post.create(input);

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

      return post;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },
    postUpdated: {
      subscribe: (_, { id }) => {
        return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
      },
    },
    commentAdded: {
      subscribe: (_, { postId }) => {
        return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
      },
    },
  },
};

// Setup WebSocket server
const schema = makeExecutableSchema({ typeDefs, resolvers });
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

useServer({ schema }, wsServer);

httpServer.listen(4000);

Client Subscription Setup

// src/apollo/client.js
import { split } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    connectionParams: {
      authToken: localStorage.getItem('token'),
    },
  })
);

// Split link - use WS for subscriptions, HTTP for queries/mutations
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  from([errorLink, authLink, httpLink])
);

export const client = new ApolloClient({
  link: splitLink,
  cache,
});

// Using useSubscription hook
const POST_SUBSCRIPTION = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      author {
        name
      }
    }
  }
`;

function PostFeed() {
  const { data: queryData } = useQuery(GET_POSTS);

  const { data: subData } = useSubscription(POST_SUBSCRIPTION, {
    onSubscriptionData: ({ subscriptionData }) => {
      // Show notification
      toast(`New post: ${subscriptionData.data.postCreated.title}`);
    },
  });

  // Combine query and subscription data
  const posts = useMemo(() => {
    const queryPosts = queryData?.posts?.edges || [];
    const newPost = subData?.postCreated;

    if (newPost && !queryPosts.find(p => p.node.id === newPost.id)) {
      return [{ node: newPost }, ...queryPosts];
    }
    return queryPosts;
  }, [queryData, subData]);

  return (
    <ul>
      {posts.map(({ node }) => (
        <li key={node.id}>{node.title}</li>
      ))}
    </ul>
  );
}

Fragments for Reusability

// src/graphql/fragments.js
import { gql } from '@apollo/client';

export const USER_FIELDS = gql`
  fragment UserFields on User {
    id
    name
    email
    avatar
  }
`;

export const POST_FIELDS = gql`
  fragment PostFields on Post {
    id
    title
    content
    published
    createdAt
  }
`;

export const POST_WITH_AUTHOR = gql`
  ${USER_FIELDS}
  ${POST_FIELDS}

  fragment PostWithAuthor on Post {
    ...PostFields
    author {
      ...UserFields
    }
  }
`;

// Using fragments in queries
export const GET_POST_DETAIL = gql`
  ${POST_WITH_AUTHOR}

  query GetPostDetail($id: ID!) {
    post(id: $id) {
      ...PostWithAuthor
      comments {
        id
        text
        author {
          ...UserFields
        }
      }
    }
  }
`;

Custom Hooks for GraphQL

// src/hooks/usePosts.js
import { useQuery, useMutation, useSubscription } from '@apollo/client';
import { GET_POSTS, CREATE_POST, POST_SUBSCRIPTION } from '../graphql';

export function usePosts(options = {}) {
  const { published, first = 10 } = options;

  const {
    data,
    loading,
    error,
    fetchMore,
    refetch,
  } = useQuery(GET_POSTS, {
    variables: { first, published },
    notifyOnNetworkStatusChange: true,
  });

  const [createPost, { loading: creating }] = useMutation(CREATE_POST, {
    refetchQueries: [{ query: GET_POSTS, variables: { first, published } }],
  });

  // Subscribe to new posts
  useSubscription(POST_SUBSCRIPTION, {
    onSubscriptionData: () => {
      refetch();
    },
  });

  const loadMore = () => {
    if (data?.posts?.pageInfo?.hasNextPage) {
      fetchMore({
        variables: {
          after: data.posts.pageInfo.endCursor,
        },
      });
    }
  };

  return {
    posts: data?.posts?.edges?.map(e => e.node) || [],
    loading,
    error,
    creating,
    hasMore: data?.posts?.pageInfo?.hasNextPage,
    totalCount: data?.posts?.totalCount,
    createPost: (input) => createPost({ variables: { input } }),
    loadMore,
    refetch,
  };
}

// Usage
function PostsList() {
  const { posts, loading, hasMore, loadMore, createPost } = usePosts({
    published: true,
  });

  if (loading && !posts.length) return <Spinner />;

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Error Handling

// Server-side error handling
const { GraphQLError } = require('graphql');

const resolvers = {
  Mutation: {
    createPost: async (_, { input }, context) => {
      // Authentication check
      if (!context.user) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

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

      // Validation
      if (input.title.length < 3) {
        throw new GraphQLError('Title must be at least 3 characters', {
          extensions: {
            code: 'BAD_USER_INPUT',
            field: 'title',
          },
        });
      }

      try {
        return await Post.create(input);
      } catch (error) {
        throw new GraphQLError('Failed to create post', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

// Client-side error handling
function CreatePostForm() {
  const [createPost, { error }] = useMutation(CREATE_POST);

  // Parse GraphQL errors
  const getFieldError = (fieldName) => {
    return error?.graphQLErrors?.find(
      err => err.extensions?.field === fieldName
    )?.message;
  };

  return (
    <form>
      <input name="title" />
      {getFieldError('title') && (
        <span className="error">{getFieldError('title')}</span>
      )}
    </form>
  );
}

Best Practices

Performance Optimization

// 1. DataLoader for N+1 problem
const DataLoader = require('dataloader');

const createLoaders = () => ({
  userLoader: new DataLoader(async (ids) => {
    const users = await User.findByIds(ids);
    return ids.map(id => users.find(u => u.id === id));
  }),
  postsByAuthorLoader: new DataLoader(async (authorIds) => {
    const posts = await Post.findByAuthorIds(authorIds);
    return authorIds.map(id => posts.filter(p => p.authorId === id));
  }),
});

// In context
context: async ({ req }) => ({
  user: req.user,
  loaders: createLoaders(),
}),

// In resolvers
User: {
  posts: (user, _, { loaders }) => {
    return loaders.postsByAuthorLoader.load(user.id);
  },
},

// 2. Query complexity analysis
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 10,
      listFactor: 20,
    }),
  ],
});

// 3. Persisted queries for security
const { ApolloServerPluginLandingPageGraphQLPlayground } = require('apollo-server-core');

const server = new ApolloServer({
  persistedQueries: {
    cache: new InMemoryCache(),
  },
});

Key Takeaways

  • GraphQL allows clients to request exactly the data they need
  • Apollo Server provides a robust GraphQL server implementation
  • Apollo Client handles caching, state management, and UI updates
  • Use fragments for reusable query parts
  • Implement cursor-based pagination for large datasets
  • Subscriptions enable real-time data updates
  • DataLoader solves the N+1 query problem
  • Optimistic updates provide instant UI feedback