GraphQL Skill
Summary
GraphQL is a query language and runtime for APIs that enables clients to request exactly the data they need. It provides a strongly-typed schema, single endpoint architecture, and eliminates over-fetching/under-fetching problems common in REST APIs.
When to Use
-
Building flexible APIs for multiple client types (web, mobile, IoT)
-
Complex data requirements with nested relationships
-
Mobile-first applications needing bandwidth efficiency
-
Reducing API versioning complexity
-
Real-time data with subscriptions
-
Microservices aggregation and federation
-
Developer experience with strong typing and introspection
Quick Start
- Define Schema (SDL)
schema.graphql
type User { id: ID! name: String! email: String! posts: [Post!]! }
type Post { id: ID! title: String! content: String! author: User! publishedAt: DateTime }
type Query { user(id: ID!): User users: [User!]! post(id: ID!): Post }
type Mutation { createPost(title: String!, content: String!, authorId: ID!): Post! updatePost(id: ID!, title: String, content: String): Post! deletePost(id: ID!): Boolean! }
- Write Resolvers (TypeScript + Apollo Server)
import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; import { readFileSync } from 'fs';
// Load schema const typeDefs = readFileSync('./schema.graphql', 'utf-8');
// Mock data const users = [ { id: '1', name: 'Alice', email: 'alice@example.com' }, { id: '2', name: 'Bob', email: 'bob@example.com' }, ];
const posts = [ { id: '1', title: 'GraphQL Intro', content: 'Learning GraphQL...', authorId: '1' }, { id: '2', title: 'Apollo Server', content: 'Building APIs...', authorId: '1' }, ];
// Resolvers const resolvers = { Query: { user: (, { id }) => users.find(u => u.id === id), users: () => users, post: (, { id }) => posts.find(p => p.id === id), },
Mutation: { createPost: (_, { title, content, authorId }) => { const post = { id: String(posts.length + 1), title, content, authorId, }; posts.push(post); return post; },
updatePost: (_, { id, title, content }) => {
const post = posts.find(p => p.id === id);
if (!post) throw new Error('Post not found');
if (title) post.title = title;
if (content) post.content = content;
return post;
},
deletePost: (_, { id }) => {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return false;
posts.splice(index, 1);
return true;
},
},
User: { posts: (user) => posts.filter(p => p.authorId === user.id), },
Post: { author: (post) => users.find(u => u.id === post.authorId), }, };
// Create server const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server, {
listen: { port: 4000 },
}).then(({ url }) => {
console.log(🚀 Server ready at ${url});
});
- Query Data (Client)
// Using Apollo Client import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
const client = new ApolloClient({ uri: 'http://localhost:4000', cache: new InMemoryCache(), });
// Query
const GET_USER = gql query GetUser($id: ID!) { user(id: $id) { id name email posts { id title publishedAt } } };
const { data } = await client.query({ query: GET_USER, variables: { id: '1' }, });
// Mutation
const CREATE_POST = gql mutation CreatePost($title: String!, $content: String!, $authorId: ID!) { createPost(title: $title, content: $content, authorId: $authorId) { id title content } };
const { data: postData } = await client.mutate({ mutation: CREATE_POST, variables: { title: 'New Post', content: 'Hello GraphQL!', authorId: '1', }, });
Core Concepts
GraphQL Fundamentals
-
Schema-First Design: Define API contract with Schema Definition Language (SDL)
-
Type Safety: Strongly-typed schema enforced at runtime and build-time
-
Single Endpoint: All queries and mutations go through one URL (e.g., /graphql )
-
Client-Specified Queries: Clients request exactly what they need
-
Hierarchical Data: Queries mirror the shape of returned data
-
Introspection: Schema is self-documenting and queryable
Operations
Query - Read data (GET-like)
query GetUser { user(id: "1") { name } }
Mutation - Modify data (POST/PUT/DELETE-like)
mutation CreateUser { createUser(name: "Alice", email: "alice@example.com") { id name } }
Subscription - Real-time updates (WebSocket)
subscription OnPostCreated { postCreated { id title author { name } } }
Fields and Arguments
type Query {
Field with arguments
user(id: ID!): User users(limit: Int = 10, offset: Int = 0): [User!]!
Search with multiple arguments
searchPosts( query: String! category: String limit: Int = 20 ): [Post!]! }
Schema Definition Language (SDL)
Basic Type Definition
type User { id: ID! # Non-null ID scalar name: String! # Non-null String email: String! age: Int # Nullable Int isActive: Boolean! posts: [Post!]! # Non-null list of non-null Posts profile: Profile # Nullable object type }
type Profile { bio: String avatarUrl: String website: String }
type Post { id: ID! title: String! content: String! author: User! tags: [String!] # Non-null list, nullable elements publishedAt: DateTime }
Input Types (for mutations)
input CreateUserInput { name: String! email: String! age: Int }
input UpdateUserInput { name: String email: String age: Int }
type Mutation { createUser(input: CreateUserInput!): User! updateUser(id: ID!, input: UpdateUserInput!): User! }
Interfaces
interface Node { id: ID! createdAt: DateTime! updatedAt: DateTime! }
type User implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! name: String! email: String! }
type Post implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! title: String! content: String! }
type Query { node(id: ID!): Node # Can return User or Post }
Unions
union SearchResult = User | Post | Comment
type Query { search(query: String!): [SearchResult!]! }
Client query with fragments
query Search { search(query: "graphql") { ... on User { name email } ... on Post { title content } ... on Comment { text author { name } } } }
Enums
enum Role { ADMIN MODERATOR USER GUEST }
enum PostStatus { DRAFT PUBLISHED ARCHIVED }
type User { id: ID! name: String! role: Role! }
type Post { id: ID! title: String! status: PostStatus! }
Type System
Scalar Types
Built-in scalars
scalar Int # Signed 32-bit integer scalar Float # Signed double-precision floating-point scalar String # UTF-8 character sequence scalar Boolean # true or false scalar ID # Unique identifier (serialized as String)
Custom scalars
scalar DateTime # ISO 8601 timestamp scalar Email # Email address scalar URL # Valid URL scalar JSON # Arbitrary JSON scalar Upload # File upload
Custom Scalar Implementation
// DateTime scalar (TypeScript) import { GraphQLScalarType, Kind } from 'graphql';
const DateTimeScalar = new GraphQLScalarType({ name: 'DateTime', description: 'ISO 8601 DateTime',
// Serialize to client (output) serialize(value: Date) { return value.toISOString(); },
// Parse from client (input) parseValue(value: string) { return new Date(value); },
// Parse from query literal parseLiteral(ast) { if (ast.kind === Kind.STRING) { return new Date(ast.value); } return null; }, });
// Add to resolvers const resolvers = { DateTime: DateTimeScalar, // ... other resolvers };
Non-Null and Lists
type User { name: String! # Non-null String email: String # Nullable String
tags: [String!]! # Non-null list of non-null Strings friends: [User!] # Nullable list of non-null Users posts: [Post]! # Non-null list of nullable Posts comments: [Comment] # Nullable list of nullable Comments }
Queries and Mutations
Query Variables
// Define query with variables
const GET_USER = gql query GetUser($id: ID!, $includePosts: Boolean = false) { user(id: $id) { id name email posts @include(if: $includePosts) { id title } } };
// Execute with variables const { data } = await client.query({ query: GET_USER, variables: { id: '1', includePosts: true, }, });
Aliases
query {
Fetch same field with different arguments
user1: user(id: "1") { name } user2: user(id: "2") { name }
Alias for clarity
currentUser: me { id name } }
Fragments
Define reusable fragment
fragment UserFields on User { id name email createdAt }
fragment PostSummary on Post { id title publishedAt author { ...UserFields } }
Use fragments in query
query { user(id: "1") { ...UserFields posts { ...PostSummary } } }
Directives
Built-in directives
query GetUser($id: ID!, $withPosts: Boolean!, $skipEmail: Boolean!) { user(id: $id) { name email @skip(if: $skipEmail) posts @include(if: $withPosts) { title } } }
Custom directive definition
directive @auth(requires: Role = USER) on FIELD_DEFINITION
type Query { users: [User!]! @auth(requires: ADMIN) me: User! @auth }
Mutations Best Practices
Single mutation with input type
type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload! deletePost(id: ID!): DeletePostPayload! }
input CreatePostInput { title: String! content: String! categoryId: ID! tags: [String!] }
Payload pattern for mutations
type CreatePostPayload { post: Post # Created resource userErrors: [UserError!]! # Client errors success: Boolean! }
type UserError { field: String! # Which field caused error message: String! # Human-readable message }
Resolvers and DataLoaders
Resolver Signature
type Resolver<TParent, TArgs, TContext, TResult> = ( parent: TParent, // Parent object args: TArgs, // Field arguments context: TContext, // Shared context (auth, db, etc.) info: GraphQLResolveInfo // Query metadata ) => TResult | Promise<TResult>;
Basic Resolvers
const resolvers = { Query: { user: async (_, { id }, { db }) => { return db.users.findById(id); },
users: async (_, { limit = 10, offset = 0 }, { db }) => {
return db.users.findMany({ limit, offset });
},
},
Mutation: { createUser: async (_, { input }, { db, userId }) => { if (!userId) { throw new Error('Authentication required'); }
const user = await db.users.create(input);
return { user, userErrors: [], success: true };
},
},
User: { // Field resolver - only called if client requests 'posts' posts: async (user, _, { db }) => { return db.posts.findByAuthorId(user.id); },
// Computed field
fullName: (user) => {
return `${user.firstName} ${user.lastName}`;
},
}, };
The N+1 Problem
// ❌ BAD - N+1 queries const resolvers = { Query: { users: () => db.users.findMany(), // 1 query }, User: { // Called for EACH user - N queries! posts: (user) => db.posts.findByAuthorId(user.id), }, };
// Querying 100 users = 1 + 100 = 101 database queries!
DataLoader Solution
import DataLoader from 'dataloader';
// Batch function - receives array of keys async function batchLoadPosts(authorIds: string[]) { const posts = await db.posts.findByAuthorIds(authorIds);
// Group by author ID const postsByAuthor = authorIds.map(authorId => posts.filter(post => post.authorId === authorId) );
return postsByAuthor; }
// Create context with loaders function createContext({ req }) { return { db, userId: req.userId, loaders: { posts: new DataLoader(batchLoadPosts), }, }; }
// ✅ GOOD - Batched queries const resolvers = { Query: { users: () => db.users.findMany(), // 1 query }, User: { // Uses DataLoader - batches all requests into 1 query! posts: (user, _, { loaders }) => { return loaders.posts.load(user.id); }, }, };
// Querying 100 users = 1 + 1 = 2 database queries!
Advanced DataLoader Patterns
// DataLoader with caching const userLoader = new DataLoader( async (ids) => { const users = await db.users.findByIds(ids); return ids.map(id => users.find(u => u.id === id)); }, { cache: true, // Enable caching (default) maxBatchSize: 100, // Limit batch size batchScheduleFn: (cb) => setTimeout(cb, 10), // Debounce batching } );
// Cache manipulation userLoader.clear(id); // Clear single key userLoader.clearAll(); // Clear entire cache userLoader.prime(id, user); // Prime cache with value
Subscriptions
WebSocket Setup (Apollo Server)
import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { makeExecutableSchema } from '@graphql-tools/schema'; import express from 'express';
const app = express(); const httpServer = createServer(app);
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket server for subscriptions const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', });
const serverCleanup = useServer({ schema }, wsServer);
const server = new ApolloServer({ schema, plugins: [ ApolloServerPluginDrainHttpServer({ httpServer }), { async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); }, }; }, }, ], });
await server.start(); app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);
Subscription Schema
type Subscription { postCreated: Post! postUpdated(id: ID!): Post! messageAdded(channelId: ID!): Message! userStatusChanged(userId: ID!): UserStatus! }
type Message { id: ID! text: String! author: User! channelId: ID! createdAt: DateTime! }
enum UserStatus { ONLINE OFFLINE AWAY }
Subscription Resolvers (PubSub)
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = { Mutation: { createPost: async (_, { input }, { db }) => { const post = await db.posts.create(input);
// Publish to subscribers
pubsub.publish('POST_CREATED', { postCreated: post });
return { post, success: true, userErrors: [] };
},
sendMessage: async (_, { channelId, text }, { db, userId }) => {
const message = await db.messages.create({
channelId,
text,
authorId: userId,
});
pubsub.publish(`MESSAGE_${channelId}`, {
messageAdded: message,
});
return message;
},
},
Subscription: { postCreated: { subscribe: () => pubsub.asyncIterator(['POST_CREATED']), },
postUpdated: {
subscribe: (_, { id }) => pubsub.asyncIterator([`POST_UPDATED_${id}`]),
},
messageAdded: {
subscribe: (_, { channelId }) => {
return pubsub.asyncIterator([`MESSAGE_${channelId}`]);
},
},
}, };
Client Subscriptions (Apollo Client)
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { getMainDefinition } from '@apollo/client/utilities'; import { createClient } from 'graphql-ws';
// HTTP link for queries and mutations const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql', });
// WebSocket link for subscriptions const wsLink = new GraphQLWsLink( createClient({ url: 'ws://localhost:4000/graphql', }) );
// Split based on operation type const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return ( definition.kind === 'OperationDefinition' && definition.operation === 'subscription' ); }, wsLink, httpLink );
const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache(), });
// Use subscription
const MESSAGES_SUBSCRIPTION = gql subscription OnMessageAdded($channelId: ID!) { messageAdded(channelId: $channelId) { id text author { name } createdAt } };
function ChatComponent({ channelId }) { const { data, loading } = useSubscription(MESSAGES_SUBSCRIPTION, { variables: { channelId }, });
if (loading) return <p>Loading...</p>;
return <div>New message: {data.messageAdded.text}</div>; }
Redis PubSub (Production)
import { RedisPubSub } from 'graphql-redis-subscriptions'; import Redis from 'ioredis';
const options = { host: 'localhost', port: 6379, retryStrategy: (times) => Math.min(times * 50, 2000), };
const pubsub = new RedisPubSub({ publisher: new Redis(options), subscriber: new Redis(options), });
// Use same as in-memory PubSub pubsub.publish('POST_CREATED', { postCreated: post }); pubsub.asyncIterator(['POST_CREATED']);
Error Handling
Error Types
import { GraphQLError } from 'graphql';
// Custom error classes class AuthenticationError extends GraphQLError { constructor(message: string) { super(message, { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 }, }, }); } }
class ForbiddenError extends GraphQLError { constructor(message: string) { super(message, { extensions: { code: 'FORBIDDEN', http: { status: 403 }, }, }); } }
class ValidationError extends GraphQLError { constructor(message: string, invalidFields: Record<string, string>) { super(message, { extensions: { code: 'BAD_USER_INPUT', invalidFields, }, }); } }
Throwing Errors in Resolvers
const resolvers = { Query: { user: async (_, { id }, { db, userId }) => { if (!userId) { throw new AuthenticationError('Must be logged in'); }
const user = await db.users.findById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND' },
});
}
return user;
},
},
Mutation: { createPost: async (_, { input }, { db, userId }) => { const errors: Record<string, string> = {};
if (!input.title || input.title.length < 3) {
errors.title = 'Title must be at least 3 characters';
}
if (!input.content) {
errors.content = 'Content is required';
}
if (Object.keys(errors).length > 0) {
throw new ValidationError('Invalid input', errors);
}
const post = await db.posts.create({ ...input, authorId: userId });
return { post, success: true, userErrors: [] };
},
}, };
Error Response Format
{ "errors": [ { "message": "User not found", "locations": [{ "line": 2, "column": 3 }], "path": ["user"], "extensions": { "code": "NOT_FOUND" } } ], "data": { "user": null } }
Field-Level Error Handling
// Nullable fields allow partial results type Query { user(id: ID!): User # null on error users: [User!]! # throws on error post(id: ID!): Post # null on error }
// Resolver can return null instead of throwing const resolvers = { Query: { user: async (_, { id }, { db }) => { try { return await db.users.findById(id); } catch (error) { console.error('Failed to fetch user:', error); return null; // Returns null instead of error } }, }, };
Schema Design Patterns
Relay Cursor Connections
type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String }
type PostEdge { node: Post! cursor: String! }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! }
type Query { posts( first: Int after: String last: Int before: String ): PostConnection! }
Relay Connection Resolver
import { fromGlobalId, toGlobalId } from 'graphql-relay';
function encodeCursor(id: string): string {
return Buffer.from(cursor:${id}).toString('base64');
}
function decodeCursor(cursor: string): string { return Buffer.from(cursor, 'base64').toString('utf-8').replace('cursor:', ''); }
const resolvers = { Query: { posts: async (_, { first = 10, after }, { db }) => { const startId = after ? decodeCursor(after) : null;
// Fetch first + 1 to determine hasNextPage
const posts = await db.posts.findMany({
where: startId ? { id: { gt: startId } } : {},
take: first + 1,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > first;
const nodes = hasNextPage ? posts.slice(0, -1) : posts;
const edges = nodes.map(node => ({
node,
cursor: encodeCursor(node.id),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: await db.posts.count(),
};
},
}, };
Offset Pagination (Simpler)
type PostsResponse { posts: [Post!]! total: Int! hasMore: Boolean! }
type Query { posts(limit: Int = 10, offset: Int = 0): PostsResponse! }
Global Object Identification (Relay)
interface Node { id: ID! # Global unique ID }
type User implements Node { id: ID! name: String! }
type Post implements Node { id: ID! title: String! }
type Query { node(id: ID!): Node }
const resolvers = { Query: { node: async (_, { id }, { db }) => { const { type, id: rawId } = fromGlobalId(id);
switch (type) {
case 'User':
return db.users.findById(rawId);
case 'Post':
return db.posts.findById(rawId);
default:
return null;
}
},
},
User: { id: (user) => toGlobalId('User', user.id), },
Post: { id: (post) => toGlobalId('Post', post.id), }, };
Server Implementations
Apollo Server (TypeScript)
import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({ typeDefs, resolvers, introspection: process.env.NODE_ENV !== 'production', plugins: [ // Custom plugin { async requestDidStart() { return { async willSendResponse({ response }) { console.log('Response:', response); }, }; }, }, ], });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 }, context: async ({ req }) => ({ token: req.headers.authorization, db: database, }), });
GraphQL Yoga (Modern Alternative)
import { createYoga, createSchema } from 'graphql-yoga'; import { createServer } from 'node:http';
const yoga = createYoga({ schema: createSchema({ typeDefs, resolvers, }), graphiql: true, context: ({ request }) => ({ userId: request.headers.get('x-user-id'), }), });
const server = createServer(yoga); server.listen(4000, () => { console.log('Server on http://localhost:4000/graphql'); });
Graphene (Python/Django)
import graphene from graphene_django import DjangoObjectType from .models import User, Post
class UserType(DjangoObjectType): class Meta: model = User fields = 'all'
class PostType(DjangoObjectType): class Meta: model = Post fields = 'all'
class Query(graphene.ObjectType): users = graphene.List(UserType) user = graphene.Field(UserType, id=graphene.ID(required=True)) posts = graphene.List(PostType)
def resolve_users(self, info):
return User.objects.all()
def resolve_user(self, info, id):
return User.objects.get(pk=id)
def resolve_posts(self, info):
return Post.objects.all()
class CreateUser(graphene.Mutation): class Arguments: name = graphene.String(required=True) email = graphene.String(required=True)
user = graphene.Field(UserType)
success = graphene.Boolean()
def mutate(self, info, name, email):
user = User.objects.create(name=name, email=email)
return CreateUser(user=user, success=True)
class Mutation(graphene.ObjectType): create_user = CreateUser.Field()
schema = graphene.Schema(query=Query, mutation=Mutation)
Strawberry (Python, Modern)
import strawberry from typing import List, Optional from datetime import datetime
@strawberry.type class User: id: strawberry.ID name: str email: str created_at: datetime
@strawberry.type class Post: id: strawberry.ID title: str content: str author_id: strawberry.ID
@strawberry.input class CreateUserInput: name: str email: str
@strawberry.type class Query: @strawberry.field def users(self) -> List[User]: return User.objects.all()
@strawberry.field
def user(self, id: strawberry.ID) -> Optional[User]:
return User.objects.get(pk=id)
@strawberry.type class Mutation: @strawberry.mutation def create_user(self, input: CreateUserInput) -> User: return User.objects.create(**input.dict)
schema = strawberry.Schema(query=Query, mutation=Mutation)
FastAPI integration
from strawberry.fastapi import GraphQLRouter
app = FastAPI() app.include_router(GraphQLRouter(schema), prefix="/graphql")
Client Integrations
Apollo Client (React)
import { ApolloClient, InMemoryCache, ApolloProvider, useQuery, useMutation } from '@apollo/client';
const client = new ApolloClient({ uri: 'http://localhost:4000/graphql', cache: new InMemoryCache(), });
function App() { return ( <ApolloProvider client={client}> <UserList /> </ApolloProvider> ); }
function UserList() { const { loading, error, data, refetch } = useQuery(GET_USERS); const [createUser] = useMutation(CREATE_USER, { refetchQueries: [{ query: GET_USERS }], });
if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error.message}</p>;
return ( <div> {data.users.map(user => ( <div key={user.id}>{user.name}</div> ))} <button onClick={() => createUser({ variables: { name: 'New User' } })}> Add User </button> </div> ); }
urql (Lightweight Alternative)
import { createClient, Provider, useQuery, useMutation } from 'urql';
const client = createClient({ url: 'http://localhost:4000/graphql', });
function App() { return ( <Provider value={client}> <UserList /> </Provider> ); }
function UserList() { const [result, reexecuteQuery] = useQuery({ query: GET_USERS }); const [, createUser] = useMutation(CREATE_USER);
const { data, fetching, error } = result;
if (fetching) return <p>Loading...</p>; if (error) return <p>Error: {error.message}</p>;
return ( <div> {data.users.map(user => ( <div key={user.id}>{user.name}</div> ))} </div> ); }
graphql-request (Minimal)
import { GraphQLClient, gql } from 'graphql-request';
const client = new GraphQLClient('http://localhost:4000/graphql');
async function fetchUsers() {
const query = gql query { users { id name } } ;
const data = await client.request(query); return data.users; }
async function createUser(name: string) {
const mutation = gql mutation CreateUser($name: String!) { createUser(name: $name) { id name } } ;
const data = await client.request(mutation, { name }); return data.createUser; }
TanStack Query + GraphQL
import { useQuery, useMutation, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { GraphQLClient } from 'graphql-request';
const graphQLClient = new GraphQLClient('http://localhost:4000/graphql');
function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async () => { const { users } = await graphQLClient.request(GET_USERS); return users; }, }); }
function useCreateUser() { const queryClient = useQueryClient();
return useMutation({ mutationFn: async (name: string) => { const { createUser } = await graphQLClient.request(CREATE_USER, { name }); return createUser; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, }); }
TypeScript Code Generation
GraphQL Code Generator Setup
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
codegen.yml
schema: http://localhost:4000/graphql documents: 'src/**/*.graphql' generates: src/generated/graphql.ts: plugins: - typescript - typescript-operations - typescript-react-apollo config: withHooks: true withComponent: false skipTypename: false enumsAsTypes: true
Generated Types
// src/queries/users.graphql // query GetUsers { // users { // id // name // email // } // }
// Generated types export type GetUsersQuery = { __typename?: 'Query'; users: Array<{ __typename?: 'User'; id: string; name: string; email: string; }>; };
export function useGetUsersQuery( baseOptions?: Apollo.QueryHookOptions<GetUsersQuery, GetUsersQueryVariables> ) { return Apollo.useQuery<GetUsersQuery, GetUsersQueryVariables>( GetUsersDocument, baseOptions ); }
Usage with Generated Types
import { useGetUsersQuery, useCreateUserMutation } from './generated/graphql';
function UserList() { const { data, loading, error } = useGetUsersQuery(); const [createUser] = useCreateUserMutation();
// Fully typed! const users = data?.users; // Type: User[] | undefined }
Authentication and Authorization
Context-Based Auth
import jwt from 'jsonwebtoken';
async function createContext({ req }) { const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) { return { userId: null, db }; }
try { const { userId } = jwt.verify(token, process.env.JWT_SECRET); return { userId, db }; } catch (error) { return { userId: null, db }; } }
const server = new ApolloServer({ typeDefs, resolvers, });
await startStandaloneServer(server, { context: createContext, });
Resolver-Level Auth
const resolvers = { Query: { me: (_, __, { userId }) => { if (!userId) { throw new AuthenticationError('Not authenticated'); } return db.users.findById(userId); },
users: (_, __, { userId, db }) => {
if (!userId) {
throw new AuthenticationError('Not authenticated');
}
const user = db.users.findById(userId);
if (user.role !== 'ADMIN') {
throw new ForbiddenError('Admin access required');
}
return db.users.findMany();
},
}, };
Directive-Based Auth
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
// Schema with directive const typeDefs = gql` directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { ADMIN USER }
type Query { users: [User!]! @auth(requires: ADMIN) me: User! @auth } `;
// Directive transformer function authDirectiveTransformer(schema, directiveName = 'auth') { return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.userId) {
throw new AuthenticationError('Not authenticated');
}
if (requires) {
const user = await context.db.users.findById(context.userId);
if (user.role !== requires) {
throw new ForbiddenError(`${requires} role required`);
}
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
}); }
let schema = makeExecutableSchema({ typeDefs, resolvers }); schema = authDirectiveTransformer(schema);
Performance Optimization
Query Complexity Analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ createComplexityLimitRule(1000, { scalarCost: 1, objectCost: 2, listFactor: 10, }), ], });
Persistent Queries (APQ)
import { ApolloServer } from '@apollo/server'; import { ApolloServerPluginInlineTraceDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({ typeDefs, resolvers, persistedQueries: { cache: new Map(), // Or Redis }, });
// Client sends hash instead of full query // Reduces payload size by ~80%
Response Caching
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({ typeDefs, resolvers, plugins: [ responseCachePlugin({ sessionId: (context) => context.userId || null, shouldReadFromCache: (context) => !context.userId, // Cache only public queries }), ], });
// Schema directive for cache control
const typeDefs = gql type Query { posts: [Post!]! @cacheControl(maxAge: 60) user(id: ID!): User @cacheControl(maxAge: 30) };
Field-Level Caching
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const cache = new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 100, // 100 MB ttl: 300, // 5 minutes });
const resolvers = {
Query: {
user: async (_, { id }, { cache, db }) => {
const cacheKey = user:${id};
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const user = await db.users.findById(id);
await cache.set(cacheKey, JSON.stringify(user), { ttl: 60 });
return user;
},
}, };
File Uploads
Schema
scalar Upload
type Mutation { uploadFile(file: Upload!): File! uploadMultiple(files: [Upload!]!): [File!]! }
type File { filename: String! mimetype: String! encoding: String! url: String! }
Server (graphql-upload)
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { GraphQLUpload } from 'graphql-upload/GraphQLUpload.mjs'; import fs from 'fs'; import path from 'path';
app.use('/graphql', graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }));
const resolvers = { Upload: GraphQLUpload,
Mutation: { uploadFile: async (_, { file }) => { const { createReadStream, filename, mimetype, encoding } = await file;
const stream = createReadStream();
const uploadPath = path.join(__dirname, 'uploads', filename);
await new Promise((resolve, reject) => {
stream
.pipe(fs.createWriteStream(uploadPath))
.on('finish', resolve)
.on('error', reject);
});
return {
filename,
mimetype,
encoding,
url: `/uploads/${filename}`,
};
},
}, };
Client (Apollo Client)
import { useMutation } from '@apollo/client';
const UPLOAD_FILE = gql mutation UploadFile($file: Upload!) { uploadFile(file: $file) { filename url } };
function FileUpload() { const [uploadFile] = useMutation(UPLOAD_FILE);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return;
uploadFile({ variables: { file } });
};
return <input type="file" onChange={handleFileChange} />; }
Testing
Unit Testing Resolvers
import { describe, it, expect, vi } from 'vitest';
describe('User Resolvers', () => { it('should fetch user by ID', async () => { const mockDb = { users: { findById: vi.fn().mockResolvedValue({ id: '1', name: 'Alice', email: 'alice@example.com', }), }, };
const result = await resolvers.Query.user(
null,
{ id: '1' },
{ db: mockDb, userId: '1' },
{} as any
);
expect(result.name).toBe('Alice');
expect(mockDb.users.findById).toHaveBeenCalledWith('1');
});
it('should throw error when not authenticated', async () => { await expect( resolvers.Query.me(null, {}, { userId: null }, {} as any) ).rejects.toThrow('Not authenticated'); }); });
Integration Testing (Apollo Server)
import { ApolloServer } from '@apollo/server'; import assert from 'assert';
it('fetches users', async () => { const server = new ApolloServer({ typeDefs, resolvers });
const response = await server.executeOperation({ query: 'query { users { id name } }', });
assert(response.body.kind === 'single'); expect(response.body.singleResult.errors).toBeUndefined(); expect(response.body.singleResult.data?.users).toHaveLength(2); });
it('creates user', async () => {
const response = await server.executeOperation({
query: mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { user { id name } success } } ,
variables: {
input: { name: 'Charlie', email: 'charlie@example.com' },
},
});
assert(response.body.kind === 'single'); expect(response.body.singleResult.data?.createUser.success).toBe(true); });
E2E Testing (Supertest)
import request from 'supertest'; import { app } from './server';
describe('GraphQL API', () => { it('should query users', async () => { const response = await request(app) .post('/graphql') .send({ query: '{ users { id name } }', }) .expect(200);
expect(response.body.data.users).toBeDefined();
});
it('should require authentication', async () => { const response = await request(app) .post('/graphql') .send({ query: '{ me { id } }', }) .expect(200);
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
}); });
Production Patterns
Schema Stitching
import { stitchSchemas } from '@graphql-tools/stitch';
const userSchema = makeExecutableSchema({ typeDefs: userTypeDefs, resolvers: userResolvers }); const postSchema = makeExecutableSchema({ typeDefs: postTypeDefs, resolvers: postResolvers });
const schema = stitchSchemas({ subschemas: [ { schema: userSchema }, { schema: postSchema }, ], });
Apollo Federation
// User service import { buildSubgraphSchema } from '@apollo/subgraph';
const typeDefs = gql type User @key(fields: "id") { id: ID! name: String! email: String! };
const resolvers = { User: { __resolveReference(user, { db }) { return db.users.findById(user.id); }, }, };
const schema = buildSubgraphSchema({ typeDefs, resolvers });
// Post service const typeDefs = gql` extend type User @key(fields: "id") { id: ID! @external posts: [Post!]! }
type Post @key(fields: "id") { id: ID! title: String! authorId: ID! } `;
// Gateway import { ApolloGateway } from '@apollo/gateway';
const gateway = new ApolloGateway({ supergraphSdl: readFileSync('./supergraph.graphql', 'utf-8'), });
const server = new ApolloServer({ gateway });
Rate Limiting
import { GraphQLRateLimitDirective } from 'graphql-rate-limit-directive';
const typeDefs = gql` directive @rateLimit( limit: Int = 10 duration: Int = 60 ) on FIELD_DEFINITION
type Query { users: [User!]! @rateLimit(limit: 100, duration: 60) search(query: String!): [Result!]! @rateLimit(limit: 10, duration: 60) } `;
let schema = makeExecutableSchema({ typeDefs, resolvers }); schema = GraphQLRateLimitDirective()(schema);
Monitoring (Apollo Studio)
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
const server = new ApolloServer({ typeDefs, resolvers, plugins: [ ApolloServerPluginUsageReporting({ sendVariableValues: { all: true }, sendHeaders: { all: true }, }), ], });
Framework Integration
Next.js App Router
// app/api/graphql/route.ts import { ApolloServer } from '@apollo/server'; import { startServerAndCreateNextHandler } from '@as-integrations/next';
const server = new ApolloServer({ typeDefs, resolvers, });
const handler = startServerAndCreateNextHandler(server);
export { handler as GET, handler as POST };
Next.js with Apollo Client (SSR)
// lib/apollo-client.ts import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc';
export const { getClient } = registerApolloClient(() => { return new ApolloClient({ cache: new InMemoryCache(), link: new HttpLink({ uri: 'http://localhost:4000/graphql', }), }); });
// app/users/page.tsx import { getClient } from '@/lib/apollo-client'; import { gql } from '@apollo/client';
export default async function UsersPage() {
const { data } = await getClient().query({
query: gql query GetUsers { users { id name } } ,
});
return ( <div> {data.users.map(user => ( <div key={user.id}>{user.name}</div> ))} </div> ); }
FastAPI + Strawberry
from fastapi import FastAPI from strawberry.fastapi import GraphQLRouter
app = FastAPI()
graphql_app = GraphQLRouter(schema) app.include_router(graphql_app, prefix="/graphql")
if name == "main": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)
Comparison with REST and tRPC
GraphQL vs REST
Feature GraphQL REST
Endpoints Single endpoint Multiple endpoints
Data Fetching Client specifies fields Server determines response
Over-fetching No Yes (extra fields)
Under-fetching No Yes (multiple requests)
Versioning Not needed Required (v1, v2)
Caching Complex Simple (HTTP caching)
Type System Built-in External (OpenAPI)
Real-time Subscriptions SSE/WebSocket
GraphQL vs tRPC
Feature GraphQL tRPC
Type Safety Codegen required Native TypeScript
Language Support Any TypeScript only
Client-Server Coupling Loose Tight
Schema SDL required Inferred from code
Learning Curve Steep Gentle
Tooling Extensive Growing
Use Case Public APIs, Mobile Full-stack TypeScript
Migration Strategies
REST to GraphQL (Gradual)
// 1. Wrap existing REST endpoints
const resolvers = {
Query: {
user: async (_, { id }) => {
const response = await fetch(https://api.example.com/users/${id});
return response.json();
},
},
};
// 2. Add GraphQL layer alongside REST app.use('/api/rest', restRouter); app.use('/graphql', graphqlMiddleware);
// 3. Migrate clients incrementally // 4. Deprecate REST endpoints when ready
Adding GraphQL to Existing App
// Express + GraphQL import express from 'express'; import { expressMiddleware } from '@apollo/server/express4';
const app = express();
// Existing routes app.use('/api', existingApiRouter);
// Add GraphQL app.use('/graphql', express.json(), expressMiddleware(server));
Best Practices
Schema Design
-
Use semantic field names (createdAt , not created_at )
-
Prefer specific types over generic JSON
-
Use enums for fixed value sets
-
Design for client use cases, not database structure
-
Use input types for complex mutations
-
Implement pagination for lists
-
Follow Relay specification for connections
Resolver Patterns
-
Keep resolvers thin, delegate to service layer
-
Use DataLoader for all database fetches
-
Validate inputs in resolvers, not database layer
-
Return errors in payload, not just exceptions
-
Use context for shared dependencies (db, auth, loaders)
Error Handling
-
Use custom error types with error codes
-
Return field-level errors for mutations
-
Log errors server-side, sanitize for clients
-
Use nullable fields to allow partial results
-
Don't expose internal implementation details
Performance
-
Always use DataLoader to prevent N+1
-
Implement query complexity limits
-
Cache frequently accessed data
-
Use persisted queries in production
-
Monitor slow queries and optimize
-
Batch mutations when possible
Security
-
Implement authentication and authorization
-
Validate all inputs
-
Use query depth limiting
-
Implement rate limiting per user
-
Disable introspection in production (optional)
-
Sanitize error messages
Testing
-
Test resolvers in isolation
-
Mock external dependencies
-
Test error conditions
-
Integration test critical flows
-
E2E test with real client
Documentation
-
Write clear field descriptions
-
Document deprecations with @deprecated
-
Provide usage examples in schema comments
-
Keep schema documentation up-to-date
Summary
GraphQL provides a powerful, flexible API layer with strong typing, efficient data fetching, and excellent developer experience. Key advantages include:
-
Client Control: Fetch exactly what you need
-
Type Safety: Schema-first design with introspection
-
Single Endpoint: Simplified API surface
-
Real-time: Built-in subscription support
-
Tooling: Excellent ecosystem (Apollo, Relay, codegen)
Trade-offs to Consider:
-
More complex than REST for simple CRUD
-
Caching requires more thought than HTTP caching
-
Learning curve for teams new to GraphQL
-
Query complexity can impact performance
Best For:
-
Mobile apps needing bandwidth efficiency
-
Complex frontends with varied data needs
-
Microservices aggregation
-
Real-time applications
-
Multi-platform clients (web, mobile, IoT)
Start Simple:
-
Define schema for core entities
-
Write resolvers with DataLoader
-
Add authentication/authorization
-
Implement error handling
-
Optimize with caching
-
Add subscriptions if needed
-
Monitor and iterate
GraphQL shines when API flexibility and developer experience are priorities. Combined with TypeScript code generation, it provides end-to-end type safety from database to UI.