GraphQL Production
Comprehensive guide to production GraphQL APIs with schema design, performance optimization, security, and federation
Table of Contents
- 1. GraphQL Fundamentals
- 2. Schema Design Best Practices
- 3. Resolver Patterns
- 4. DataLoader & N+1 Problem
- 5. Apollo Server Setup
- 6. Authentication & Authorization
- 7. Error Handling
- 8. Input Validation
- 9. Performance Optimization
- 10. Caching Strategies
- 11. Real-time with Subscriptions
- 12. GraphQL Federation
- 13. Security Best Practices
- 14. Monitoring & Observability
- 15. Testing GraphQL APIs
1. GraphQL Fundamentals
GraphQL vs REST
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints (/users, /posts) | Single endpoint (/graphql) |
| Data Fetching | Over-fetching or under-fetching | Request exactly what you need |
| Versioning | URL versioning (/api/v1/users) | Schema evolution, no versioning |
| Type System | Optional (OpenAPI/Swagger) | Strong typing built-in |
| Documentation | Manual or generated | Self-documenting (introspection) |
| Caching | HTTP caching (easy) | Requires custom logic |
Core Concepts
# Schema Definition Language (SDL)
# Object Types
type User {
id: ID! # Non-null ID
name: String! # Non-null String
email: String!
age: Int
posts: [Post!]! # Non-null array of non-null Posts
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
published: Boolean!
}
# Input Types (for mutations)
input CreateUserInput {
name: String!
email: String!
age: Int
}
input UpdateUserInput {
name: String
email: String
age: Int
}
# Enums
enum UserRole {
ADMIN
USER
GUEST
}
# Interfaces
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
# Unions
union SearchResult = User | Post | Comment
# Root Types
type Query {
# Fetch single user
user(id: ID!): User
# Fetch all users with pagination
users(
first: Int = 10
after: String
orderBy: UserOrderBy
): UserConnection!
# Search across types
search(query: String!): [SearchResult!]!
}
type Mutation {
# Create user
createUser(input: CreateUserInput!): User!
# Update user
updateUser(id: ID!, input: UpdateUserInput!): User!
# Delete user
deleteUser(id: ID!): Boolean!
}
type Subscription {
# Subscribe to new posts
postCreated: Post!
# Subscribe to user updates
userUpdated(id: ID!): User!
}
# Custom Scalars
scalar DateTime
scalar Email
scalar URL
# Directives
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION
GraphQL Operations
# Query
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
}
}
}
# Query with Fragments
fragment UserFields on User {
id
name
email
}
query GetUsers {
users {
...UserFields
posts {
id
title
}
}
}
# Mutation
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
# Variables
{
"id": "1",
"input": {
"name": "John Doe",
"email": "[email protected]"
}
}
# Aliases
query {
user1: user(id: "1") {
name
}
user2: user(id: "2") {
name
}
}
# Inline Fragments (for unions)
query Search($query: String!) {
search(query: $query) {
... on User {
name
email
}
... on Post {
title
content
}
... on Comment {
text
}
}
}
GraphQL Request Flow
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT REQUEST │
│ query { user(id: "1") { name, posts { title } } } │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌────────────────────────┐
│ GraphQL Parser │
│ - Parse query string │
│ - Build AST │
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ Query Validation │
│ - Check syntax │
│ - Validate types │
│ - Check auth │
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ Query Execution │
│ - Resolve fields │
│ - Call resolvers │
│ - Parallel execution │
└───────────┬────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ user() │ │ posts() │ │ DataLoader │
│ resolver │ │ resolver │ │ (batching) │
└──────┬───────┘ └────┬─────┘ └──────┬───────┘
│ │ │
└──────────────┴────────────────┘
│
▼
┌──────────────────────────┐
│ Data Sources │
│ - Database │
│ - REST APIs │
│ - Microservices │
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ Format Response │
│ { "data": { ... } } │
└──────────────────────────┘
Pro Tip: Use fragments to avoid repeating field selections and make queries more maintainable. Fragment colocation keeps component data requirements close to the component.
2. Schema Design Best Practices
Naming Conventions
# Good Naming Conventions
# Types: PascalCase
type User {}
type UserProfile {}
# Fields: camelCase
type User {
firstName: String!
lastName: String!
emailAddress: String!
}
# Enums: UPPER_SNAKE_CASE
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
}
# Input Types: suffix with "Input"
input CreateUserInput {
name: String!
email: String!
}
# Connections: suffix with "Connection"
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
# Edges: suffix with "Edge"
type UserEdge {
node: User!
cursor: String!
}
# Mutations: verb + noun
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
# Mutation Payloads: verb + noun + "Payload"
type CreateUserPayload {
user: User
errors: [Error!]
}
type UpdateUserPayload {
user: User
errors: [Error!]
}
type DeleteUserPayload {
deletedId: ID
success: Boolean!
}
Pagination Patterns
# Cursor-Based Pagination (Relay-style, Recommended)
type Query {
users(
first: Int # Forward pagination
after: String # Cursor
last: Int # Backward pagination
before: String # Cursor
): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Usage
query {
users(first: 10, after: "cursor123") {
edges {
node {
id
name
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# Offset-Based Pagination (Simpler, but less efficient)
type Query {
users(
limit: Int = 10
offset: Int = 0
): UserList!
}
type UserList {
items: [User!]!
total: Int!
hasMore: Boolean!
}
# Usage
query {
users(limit: 10, offset: 0) {
items {
id
name
}
total
hasMore
}
}
Error Design
# Union Error Pattern (Recommended)
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}
union CreateUserResult = CreateUserSuccess | CreateUserError
type CreateUserSuccess {
user: User!
}
type CreateUserError {
message: String!
code: ErrorCode!
field: String
}
enum ErrorCode {
VALIDATION_ERROR
DUPLICATE_EMAIL
UNAUTHORIZED
NOT_FOUND
}
# Usage
mutation {
createUser(input: { name: "John", email: "[email protected]" }) {
... on CreateUserSuccess {
user {
id
name
}
}
... on CreateUserError {
message
code
field
}
}
}
# Errors Field Pattern
type CreateUserPayload {
user: User
errors: [UserError!]
}
type UserError {
message: String!
path: [String!]
extensions: ErrorExtensions
}
type ErrorExtensions {
code: String!
field: String
}
Nullability Best Practices
# Use Non-Null (!) Carefully
# Good: Field always exists
type User {
id: ID! # User always has an ID
name: String! # User always has a name
email: String! # User always has an email
}
# Good: Optional fields are nullable
type User {
id: ID!
name: String!
bio: String # Bio is optional
website: String # Website is optional
}
# Good: Non-null array with non-null items
type User {
id: ID!
posts: [Post!]! # Array always exists, items never null
}
# Bad: Makes schema brittle
type User {
id: ID!
posts: [Post!]!
comments: [Comment!]!
followers: [User!]!
}
# Better: Allow graceful degradation
type User {
id: ID!
posts: [Post!] # Can return null if error
comments: [Comment!]
followers: [User!]
}
# Best: Use connections for lists
type User {
id: ID!
posts(first: Int, after: String): PostConnection
}
Common Pitfall: Using too many non-null fields makes your schema brittle. If a single field fails, the entire parent becomes null. Use non-null only for truly required fields.
3. Resolver Patterns
Basic Resolvers
/**
* Resolver Signature
*
* (parent, args, context, info) => result
*
* - parent: Result from parent resolver
* - args: Arguments passed to field
* - context: Shared across all resolvers (user, db, etc.)
* - info: Query AST and schema info
*/
import { GraphQLResolveInfo } from 'graphql';
interface Context {
user: User | null;
db: Database;
dataSources: DataSources;
}
const resolvers = {
Query: {
// Fetch single user
user: async (
_parent: unknown,
args: { id: string },
context: Context,
_info: GraphQLResolveInfo
) => {
return context.db.user.findUnique({
where: { id: args.id },
});
},
// Fetch all users
users: async (_parent, _args, context: Context) => {
return context.db.user.findMany();
},
// Authenticated query
me: async (_parent, _args, context: Context) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return context.user;
},
},
Mutation: {
createUser: async (
_parent: unknown,
args: { input: CreateUserInput },
context: Context
) => {
const { name, email } = args.input;
// Validate
if (!email.includes('@')) {
throw new Error('Invalid email');
}
// Create user
const user = await context.db.user.create({
data: { name, email },
});
return user;
},
updateUser: async (
_parent: unknown,
args: { id: string; input: UpdateUserInput },
context: Context
) => {
return context.db.user.update({
where: { id: args.id },
data: args.input,
});
},
deleteUser: async (
_parent: unknown,
args: { id: string },
context: Context
) => {
await context.db.user.delete({
where: { id: args.id },
});
return true;
},
},
User: {
// Field resolver for computed fields
fullName: (parent: User) => {
return `${parent.firstName} ${parent.lastName}`;
},
// Field resolver with database query
posts: async (parent: User, _args, context: Context) => {
return context.db.post.findMany({
where: { authorId: parent.id },
});
},
// Field resolver with DataLoader (N+1 prevention)
posts: async (parent: User, _args, context: Context) => {
return context.dataSources.postLoader.load(parent.id);
},
},
Post: {
// Resolver for related type
author: async (parent: Post, _args, context: Context) => {
return context.db.user.findUnique({
where: { id: parent.authorId },
});
},
// With DataLoader
author: async (parent: Post, _args, context: Context) => {
return context.dataSources.userLoader.load(parent.authorId);
},
},
};
Resolver Composition
/**
* Resolver Middleware Pattern
*/
type ResolverFn = (parent: any, args: any, context: Context, info: any) => any;
// Auth middleware
const withAuth = (resolver: ResolverFn) => {
return (parent: any, args: any, context: Context, info: any) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return resolver(parent, args, context, info);
};
};
// Admin middleware
const withAdmin = (resolver: ResolverFn) => {
return (parent: any, args: any, context: Context, info: any) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new Error('Not authorized');
}
return resolver(parent, args, context, info);
};
};
// Compose middleware
const compose = (...middlewares: any[]) => {
return (resolver: ResolverFn) => {
return middlewares.reduceRight(
(acc, middleware) => middleware(acc),
resolver
);
};
};
// Usage
const resolvers = {
Query: {
me: withAuth(async (_parent, _args, context: Context) => {
return context.user;
}),
users: compose(withAuth, withAdmin)(
async (_parent, _args, context: Context) => {
return context.db.user.findMany();
}
),
},
};
/**
* Resolver Directives
*/
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirective(directiveName: string) {
return (schema: GraphQLSchema) => {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
const { requires } = authDirective;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new Error('Not authenticated');
}
if (requires && context.user.role !== requires) {
throw new Error('Not authorized');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
};
}
// Schema with directive
const typeDefs = `
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
type Query {
me: User @auth
users: [User!]! @auth(requires: ADMIN)
}
`;
// Apply directive
const schema = authDirective('auth')(makeExecutableSchema({ typeDefs, resolvers }));
Batch Resolvers
/**
* Batch Multiple Queries
*/
const resolvers = {
Query: {
// Instead of multiple separate queries
node: async (_parent, args: { id: string }, context: Context) => {
// Resolve any type by ID
return context.dataSources.nodeLoader.load(args.id);
},
nodes: async (_parent, args: { ids: string[] }, context: Context) => {
// Batch load multiple nodes
return context.dataSources.nodeLoader.loadMany(args.ids);
},
},
};
// Schema
const typeDefs = `
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Post implements Node {
id: ID!
title: String!
}
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}
`;
// Usage - single query instead of multiple
query {
nodes(ids: ["user:1", "post:2", "user:3"]) {
id
... on User {
name
}
... on Post {
title
}
}
}
Pro Tip: Use resolver composition to keep resolvers DRY. Extract common logic (auth, logging, error handling) into reusable middleware functions.
4. DataLoader & N+1 Problem
The N+1 Problem
/**
* N+1 Problem Example
*/
// Query
query {
users { # 1 query
id
name
posts { # N queries (one per user)
title
}
}
}
// BAD: N+1 queries
const resolvers = {
Query: {
users: async (_parent, _args, context: Context) => {
// 1 query to get all users
return context.db.user.findMany();
},
},
User: {
posts: async (parent: User, _args, context: Context) => {
// N queries (one for each user)
return context.db.post.findMany({
where: { authorId: parent.id },
});
},
},
};
/**
* SOLUTION: DataLoader
*
* DataLoader provides:
* - Batching: Combine multiple requests into one
* - Caching: Per-request memoization
*/
DataLoader Implementation
/**
* Basic DataLoader
*/
import DataLoader from 'dataloader';
// Create user loader
const createUserLoader = (db: Database) => {
return new DataLoader(async (ids: readonly string[]) => {
// Single batch query for all requested IDs
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
// Return users in same order as requested IDs
const userMap = new Map(users.map(user => [user.id, user]));
return ids.map(id => userMap.get(id) || null);
});
};
// Create posts-by-author loader
const createPostsByAuthorLoader = (db: Database) => {
return new DataLoader(async (authorIds: readonly string[]) => {
// Single batch query
const posts = await db.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
// Group posts by author ID
const postsByAuthor = new Map();
for (const post of posts) {
const existing = postsByAuthor.get(post.authorId) || [];
postsByAuthor.set(post.authorId, [...existing, post]);
}
// Return in same order as requested IDs
return authorIds.map(id => postsByAuthor.get(id) || []);
});
};
/**
* Context Setup
*/
interface Context {
user: User | null;
db: Database;
loaders: {
user: DataLoader;
postsByAuthor: DataLoader;
commentsByPost: DataLoader;
};
}
// Create new loaders per request
const createContext = async ({ req }: { req: Request }): Promise => {
const user = await authenticate(req);
return {
user,
db,
loaders: {
user: createUserLoader(db),
postsByAuthor: createPostsByAuthorLoader(db),
commentsByPost: createCommentsByPostLoader(db),
},
};
};
/**
* Using DataLoaders in Resolvers
*/
const resolvers = {
Query: {
users: async (_parent, _args, context: Context) => {
return context.db.user.findMany();
},
},
User: {
posts: async (parent: User, _args, context: Context) => {
// DataLoader batches and caches
return context.loaders.postsByAuthor.load(parent.id);
},
},
Post: {
author: async (parent: Post, _args, context: Context) => {
// DataLoader batches and caches
return context.loaders.user.load(parent.authorId);
},
comments: async (parent: Post, _args, context: Context) => {
return context.loaders.commentsByPost.load(parent.id);
},
},
Comment: {
author: async (parent: Comment, _args, context: Context) => {
return context.loaders.user.load(parent.authorId);
},
},
};
/**
* Advanced: DataLoader with Options
*/
const createUserLoader = (db: Database) => {
return new DataLoader(
async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map(user => [user.id, user]));
return ids.map(id => userMap.get(id) || null);
},
{
// Options
batch: true, // Enable batching (default: true)
cache: true, // Enable caching (default: true)
maxBatchSize: 100, // Max items per batch
batchScheduleFn: (cb) => setTimeout(cb, 10), // Delay batching
}
);
};
/**
* Custom Cache Key
*/
const createPostLoader = (db: Database) => {
return new DataLoader<{ id: string; includeComments: boolean }, Post>(
async (keys) => {
// Complex batch query
const ids = keys.map(k => k.id);
const posts = await db.post.findMany({
where: { id: { in: ids } },
include: {
comments: keys[0].includeComments,
},
});
const postMap = new Map(posts.map(post => [post.id, post]));
return keys.map(key => postMap.get(key.id) || null);
},
{
// Custom cache key function
cacheKeyFn: (key) => `${key.id}:${key.includeComments}`,
}
);
};
DataLoader Best Practices
/**
* 1. Always create new loaders per request
*/
// BAD: Shared loader across requests (leaks data)
const userLoader = createUserLoader(db);
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => ({
loaders: { user: userLoader }, // WRONG!
}),
});
// GOOD: New loader per request
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => ({
loaders: {
user: createUserLoader(db), // Correct!
},
}),
});
/**
* 2. Prime the cache
*/
const resolvers = {
Query: {
users: async (_parent, _args, context: Context) => {
const users = await context.db.user.findMany();
// Prime the loader cache
users.forEach(user => {
context.loaders.user.prime(user.id, user);
});
return users;
},
},
};
/**
* 3. Clear cache when mutating
*/
const resolvers = {
Mutation: {
updateUser: async (_parent, args, context: Context) => {
const user = await context.db.user.update({
where: { id: args.id },
data: args.input,
});
// Clear cache for this user
context.loaders.user.clear(user.id);
// Or prime with new data
context.loaders.user.prime(user.id, user);
return user;
},
},
};
/**
* 4. Handle errors in batch function
*/
const createUserLoader = (db: Database) => {
return new DataLoader(async (ids) => {
try {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map(user => [user.id, user]));
return ids.map(id => {
const user = userMap.get(id);
if (!user) {
return new Error(`User ${id} not found`);
}
return user;
});
} catch (error) {
// Return same error for all IDs
return ids.map(() => error);
}
});
};
Common Pitfall: Reusing DataLoaders across requests causes data leakage between users. Always create new loaders in the context function for each request.
5. Apollo Server Setup
Apollo Server 4
/**
* Apollo Server 4 with Express
*/
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { json } from 'body-parser';
// Type definitions
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
}
`;
// Resolvers
const resolvers = {
Query: {
users: async (_parent, _args, context) => {
return context.db.user.findMany();
},
user: async (_parent, args, context) => {
return context.db.user.findUnique({
where: { id: args.id },
});
},
},
Mutation: {
createUser: async (_parent, args, context) => {
return context.db.user.create({
data: {
name: args.name,
email: args.email,
},
});
},
},
};
// Context interface
interface Context {
user: User | null;
db: Database;
loaders: Loaders;
}
// Create context
const createContext = async ({ req }: { req: Request }): Promise => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return {
user,
db,
loaders: createLoaders(db),
};
};
// Create Express app
const app = express();
const httpServer = http.createServer(app);
// Create Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
// Graceful shutdown
ApolloServerPluginDrainHttpServer({ httpServer }),
// Disable introspection and playground in production
process.env.NODE_ENV === 'production' && {
async serverWillStart() {
return {
async drainServer() {},
async renderLandingPage() {
return { html: 'GraphQL API
' };
},
};
},
},
].filter(Boolean),
// Limit query depth
validationRules: [depthLimit(10)],
// Format errors
formatError: (error) => {
console.error(error);
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
}
return error;
},
});
// Start server
await server.start();
// Apply middleware
app.use(
'/graphql',
cors(),
json(),
expressMiddleware(server, {
context: createContext,
})
);
// Start HTTP server
await new Promise((resolve) => {
httpServer.listen({ port: 4000 }, resolve);
});
console.log(`🚀 Server ready at http://localhost:4000/graphql`);
Apollo Server Plugins
/**
* Custom Plugins
*/
import { ApolloServerPlugin } from '@apollo/server';
// Logging plugin
const loggingPlugin: ApolloServerPlugin = {
async requestDidStart(requestContext) {
const start = Date.now();
return {
async willSendResponse(requestContext) {
const elapsed = Date.now() - start;
console.log(`Operation: ${requestContext.operationName} (${elapsed}ms)`);
},
async didEncounterErrors(requestContext) {
console.error('Errors:', requestContext.errors);
},
};
},
};
// Complexity plugin
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
const complexityPlugin: ApolloServerPlugin = {
async requestDidStart() {
return {
async didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
const maxComplexity = 1000;
if (complexity > maxComplexity) {
throw new GraphQLError(
`Query too complex: ${complexity}. Maximum allowed: ${maxComplexity}`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } }
);
}
},
};
},
};
// Response caching plugin
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const cachePlugin = responseCachePlugin({
sessionId: (requestContext) => {
return requestContext.request.http?.headers.get('sessionid') || null;
},
shouldReadFromCache: (requestContext) => {
// Only cache queries
return requestContext.request.query?.includes('query');
},
shouldWriteToCache: (requestContext) => {
return !requestContext.contextValue.user; // Only cache public data
},
});
// Apply plugins
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
loggingPlugin,
complexityPlugin,
cachePlugin,
],
});
Schema Building
/**
* Code-First Approach with TypeGraphQL
*/
import { ObjectType, Field, ID, Resolver, Query, Arg, Mutation } from 'type-graphql';
@ObjectType()
class User {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field()
email: string;
@Field(() => [Post])
posts: Post[];
}
@ObjectType()
class Post {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
content: string;
@Field(() => User)
author: User;
}
@Resolver(User)
class UserResolver {
@Query(() => [User])
async users(@Ctx() context: Context) {
return context.db.user.findMany();
}
@Query(() => User, { nullable: true })
async user(@Arg('id') id: string, @Ctx() context: Context) {
return context.db.user.findUnique({
where: { id },
});
}
@Mutation(() => User)
async createUser(
@Arg('name') name: string,
@Arg('email') email: string,
@Ctx() context: Context
) {
return context.db.user.create({
data: { name, email },
});
}
}
// Build schema
import { buildSchema } from 'type-graphql';
const schema = await buildSchema({
resolvers: [UserResolver],
validate: true,
});
const server = new ApolloServer({
schema,
});
Pro Tip: Use plugins for cross-cutting concerns (logging, caching, authentication). Keep plugins simple and composable.
6. Authentication & Authorization
JWT Authentication
/**
* JWT Authentication
*/
import { sign, verify } from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
// Create token
function createToken(user: User): string {
return sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// Verify token
function verifyToken(token: string): TokenPayload | null {
try {
return verify(token, JWT_SECRET) as TokenPayload;
} catch (error) {
return null;
}
}
// Context with auth
const createContext = async ({ req }: { req: Request }): Promise => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return { user: null, db, loaders: createLoaders(db) };
}
const payload = verifyToken(token);
if (!payload) {
return { user: null, db, loaders: createLoaders(db) };
}
const user = await db.user.findUnique({
where: { id: payload.userId },
});
return {
user,
db,
loaders: createLoaders(db),
};
};
/**
* Login Mutation
*/
const resolvers = {
Mutation: {
login: async (_parent, args: { email: string; password: string }) => {
// Find user
const user = await db.user.findUnique({
where: { email: args.email },
});
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Verify password
const valid = await verifyPassword(args.password, user.password);
if (!valid) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Create token
const token = createToken(user);
return {
token,
user,
};
},
register: async (_parent, args: { name: string; email: string; password: string }) => {
// Check if user exists
const existing = await db.user.findUnique({
where: { email: args.email },
});
if (existing) {
throw new GraphQLError('Email already in use', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
// Hash password
const hashedPassword = await hashPassword(args.password);
// Create user
const user = await db.user.create({
data: {
name: args.name,
email: args.email,
password: hashedPassword,
},
});
// Create token
const token = createToken(user);
return {
token,
user,
};
},
},
};
// Schema
const typeDefs = `
type AuthPayload {
token: String!
user: User!
}
type Mutation {
login(email: String!, password: String!): AuthPayload!
register(name: String!, email: String!, password: String!): AuthPayload!
}
`;
Authorization
/**
* Authorization Helpers
*/
import { GraphQLError } from 'graphql';
// Require authentication
function requireAuth(context: Context): User {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.user;
}
// Require specific role
function requireRole(context: Context, role: UserRole): User {
const user = requireAuth(context);
if (user.role !== role) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
// Check ownership
function requireOwnership(context: Context, resourceOwnerId: string): User {
const user = requireAuth(context);
if (user.id !== resourceOwnerId && user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
/**
* Using Authorization
*/
const resolvers = {
Query: {
// Public query
users: async () => {
return db.user.findMany();
},
// Requires authentication
me: async (_parent, _args, context: Context) => {
const user = requireAuth(context);
return user;
},
// Requires admin role
allUsers: async (_parent, _args, context: Context) => {
requireRole(context, 'ADMIN');
return db.user.findMany();
},
},
Mutation: {
// Requires ownership
updateUser: async (_parent, args: { id: string; input: UpdateUserInput }, context: Context) => {
requireOwnership(context, args.id);
return db.user.update({
where: { id: args.id },
data: args.input,
});
},
// Admin only
deleteUser: async (_parent, args: { id: string }, context: Context) => {
requireRole(context, 'ADMIN');
await db.user.delete({
where: { id: args.id },
});
return true;
},
},
User: {
// Field-level authorization
email: (parent: User, _args, context: Context) => {
// Only show email to owner or admin
if (context.user?.id === parent.id || context.user?.role === 'ADMIN') {
return parent.email;
}
return null;
},
},
};
/**
* Directive-Based Authorization
*/
const typeDefs = `
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
enum UserRole {
USER
ADMIN
}
type Query {
users: [User!]!
me: User @auth
allUsers: [User!]! @auth(requires: ADMIN)
}
type User {
id: ID!
name: String!
email: String @auth # Only authenticated users can see
}
`;
Session-Based Auth
/**
* Session-Based Authentication
*/
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
// Redis client
const redisClient = createClient({
url: process.env.REDIS_URL,
});
await redisClient.connect();
// Session middleware
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
);
// Context with session
const createContext = async ({ req }: { req: Request }): Promise => {
const userId = req.session.userId;
const user = userId
? await db.user.findUnique({ where: { id: userId } })
: null;
return {
user,
db,
loaders: createLoaders(db),
session: req.session,
};
};
// Login mutation
const resolvers = {
Mutation: {
login: async (
_parent,
args: { email: string; password: string },
context: Context
) => {
const user = await db.user.findUnique({
where: { email: args.email },
});
if (!user || !(await verifyPassword(args.password, user.password))) {
throw new GraphQLError('Invalid credentials');
}
// Set session
context.session.userId = user.id;
return user;
},
logout: async (_parent, _args, context: Context) => {
// Destroy session
await new Promise((resolve) => context.session.destroy(resolve));
return true;
},
},
};
Pro Tip: Use JWT for stateless authentication (microservices, mobile apps) and sessions for web applications where you need server-side control (instant revocation).
7. Error Handling
GraphQL Error Codes
/**
* Standard Error Codes
*/
import { GraphQLError } from 'graphql';
// Authentication error
throw new GraphQLError('Not authenticated', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
// Authorization error
throw new GraphQLError('Not authorized', {
extensions: {
code: 'FORBIDDEN',
http: { status: 403 },
},
});
// Bad user input
throw new GraphQLError('Invalid email format', {
extensions: {
code: 'BAD_USER_INPUT',
argumentName: 'email',
http: { status: 400 },
},
});
// Not found
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
resourceType: 'User',
resourceId: '123',
http: { status: 404 },
},
});
// Internal error
throw new GraphQLError('Internal server error', {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
http: { status: 500 },
},
});
/**
* Custom Error Classes
*/
class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
}
class AuthorizationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: 'FORBIDDEN',
http: { status: 403 },
},
});
}
}
class ValidationError extends GraphQLError {
constructor(message: string, field?: string) {
super(message, {
extensions: {
code: 'BAD_USER_INPUT',
field,
http: { status: 400 },
},
});
}
}
class NotFoundError extends GraphQLError {
constructor(resourceType: string, resourceId: string) {
super(`${resourceType} not found`, {
extensions: {
code: 'NOT_FOUND',
resourceType,
resourceId,
http: { status: 404 },
},
});
}
}
// Usage
const resolvers = {
Query: {
user: async (_parent, args: { id: string }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('Must be logged in');
}
const user = await context.db.user.findUnique({
where: { id: args.id },
});
if (!user) {
throw new NotFoundError('User', args.id);
}
if (user.id !== context.user.id && context.user.role !== 'ADMIN') {
throw new AuthorizationError('Cannot view other users');
}
return user;
},
},
};
Error Formatting
/**
* Format Errors
*/
import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Unwrap original error
const originalError = unwrapResolverError(error);
// Log internal errors
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
console.error('Internal error:', originalError);
// Don't expose internal details in production
if (process.env.NODE_ENV === 'production') {
return {
message: 'Internal server error',
extensions: {
code: 'INTERNAL_SERVER_ERROR',
},
};
}
}
// Log to error tracking service
if (shouldLogError(formattedError)) {
logToSentry(originalError);
}
return formattedError;
},
});
function shouldLogError(error: any): boolean {
const ignoreCodes = ['UNAUTHENTICATED', 'BAD_USER_INPUT', 'NOT_FOUND'];
return !ignoreCodes.includes(error.extensions?.code);
}
/**
* Validation Errors
*/
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().int().min(18, 'Must be at least 18 years old').optional(),
});
const resolvers = {
Mutation: {
createUser: async (_parent, args: { input: CreateUserInput }) => {
try {
// Validate input
const validated = createUserSchema.parse(args.input);
// Create user
return db.user.create({ data: validated });
} catch (error) {
if (error instanceof z.ZodError) {
// Format validation errors
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new GraphQLError('Validation failed', {
extensions: {
code: 'BAD_USER_INPUT',
validationErrors: errors,
},
});
}
throw error;
}
},
},
};
Partial Errors
/**
* Handle Partial Errors
*/
const resolvers = {
Query: {
users: async () => {
try {
return await db.user.findMany();
} catch (error) {
console.error('Failed to fetch users:', error);
// Return empty array instead of failing entire query
return [];
}
},
},
User: {
posts: async (parent: User, _args, context: Context) => {
try {
return await context.loaders.postsByAuthor.load(parent.id);
} catch (error) {
console.error(`Failed to fetch posts for user ${parent.id}:`, error);
// Return empty array for this field
return [];
}
},
},
};
/**
* GraphQL Response with Errors
*/
// Response can include both data and errors
{
"data": {
"users": [
{
"id": "1",
"name": "John",
"posts": null // This field failed
},
{
"id": "2",
"name": "Jane",
"posts": [{ "title": "Post 1" }]
}
]
},
"errors": [
{
"message": "Failed to fetch posts",
"path": ["users", 0, "posts"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR"
}
}
]
}
Common Pitfall: Don't expose internal error details in production. Always sanitize error messages and stack traces before sending to clients.
8. Input Validation
Zod Validation
/**
* Input Validation with Zod
*/
import { z } from 'zod';
// Define schemas
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).optional(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain number'),
});
const updateUserSchema = z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).optional(),
});
// Validation helper
function validate(schema: z.Schema, data: unknown): T {
try {
return schema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
const errors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new GraphQLError('Validation failed', {
extensions: {
code: 'BAD_USER_INPUT',
validationErrors: errors,
},
});
}
throw error;
}
}
// Use in resolvers
const resolvers = {
Mutation: {
createUser: async (_parent, args: { input: unknown }) => {
const input = validate(createUserSchema, args.input);
return db.user.create({
data: {
name: input.name,
email: input.email,
age: input.age,
password: await hashPassword(input.password),
},
});
},
updateUser: async (_parent, args: { id: string; input: unknown }) => {
const input = validate(updateUserSchema, args.input);
return db.user.update({
where: { id: args.id },
data: input,
});
},
},
};
Custom Scalars
/**
* Custom Scalar Types
*/
import { GraphQLScalarType, Kind } from 'graphql';
// Email scalar
const EmailScalar = new GraphQLScalarType({
name: 'Email',
description: 'Valid email address',
serialize(value: unknown): string {
if (typeof value !== 'string') {
throw new GraphQLError('Email must be a string');
}
if (!isValidEmail(value)) {
throw new GraphQLError('Invalid email format');
}
return value;
},
parseValue(value: unknown): string {
if (typeof value !== 'string') {
throw new GraphQLError('Email must be a string');
}
if (!isValidEmail(value)) {
throw new GraphQLError('Invalid email format');
}
return value;
},
parseLiteral(ast): string {
if (ast.kind !== Kind.STRING) {
throw new GraphQLError('Email must be a string');
}
if (!isValidEmail(ast.value)) {
throw new GraphQLError('Invalid email format');
}
return ast.value;
},
});
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// DateTime scalar
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO 8601 date time string',
serialize(value: unknown): string {
if (value instanceof Date) {
return value.toISOString();
}
throw new GraphQLError('DateTime must be a Date object');
},
parseValue(value: unknown): Date {
if (typeof value === 'string') {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new GraphQLError('Invalid DateTime format');
}
return date;
}
throw new GraphQLError('DateTime must be a string');
},
parseLiteral(ast): Date {
if (ast.kind === Kind.STRING) {
const date = new Date(ast.value);
if (isNaN(date.getTime())) {
throw new GraphQLError('Invalid DateTime format');
}
return date;
}
throw new GraphQLError('DateTime must be a string');
},
});
// URL scalar
const URLScalar = new GraphQLScalarType({
name: 'URL',
description: 'Valid URL string',
serialize(value: unknown): string {
if (typeof value !== 'string') {
throw new GraphQLError('URL must be a string');
}
try {
new URL(value);
return value;
} catch {
throw new GraphQLError('Invalid URL format');
}
},
parseValue(value: unknown): string {
if (typeof value !== 'string') {
throw new GraphQLError('URL must be a string');
}
try {
new URL(value);
return value;
} catch {
throw new GraphQLError('Invalid URL format');
}
},
parseLiteral(ast): string {
if (ast.kind !== Kind.STRING) {
throw new GraphQLError('URL must be a string');
}
try {
new URL(ast.value);
return ast.value;
} catch {
throw new GraphQLError('Invalid URL format');
}
},
});
// Use in resolvers
const resolvers = {
Email: EmailScalar,
DateTime: DateTimeScalar,
URL: URLScalar,
};
// Schema
const typeDefs = `
scalar Email
scalar DateTime
scalar URL
type User {
id: ID!
email: Email!
website: URL
createdAt: DateTime!
}
`;
Pro Tip: Use custom scalars for common patterns (Email, URL, DateTime) to move validation logic out of resolvers and into the type system.
9. Performance Optimization
Query Complexity
/**
* Limit Query Complexity
*/
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
import { separateOperations } from 'graphql';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
const maxComplexity = 1000;
if (complexity > maxComplexity) {
throw new GraphQLError(
`Query too complex: ${complexity}. Maximum: ${maxComplexity}`,
{ extensions: { code: 'QUERY_TOO_COMPLEX' } }
);
}
},
};
},
},
],
});
/**
* Custom Complexity Estimation
*/
const typeDefs = `
type Query {
users(first: Int!): [User!]! @complexity(multipliers: ["first"], value: 1)
user(id: ID!): User @complexity(value: 10)
}
type User {
id: ID!
posts(first: Int!): [Post!]! @complexity(multipliers: ["first"], value: 2)
}
directive @complexity(
value: Int!
multipliers: [String!]
) on FIELD_DEFINITION
`;
// Example complexity calculation:
// users(first: 100) { posts(first: 10) }
// = 1 * 100 + 2 * 10 * 100 = 2100
Query Depth Limiting
/**
* Limit Query Depth
*/
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)],
});
// This query is rejected (depth > 10):
query {
user { # depth 1
posts { # depth 2
author { # depth 3
posts { # depth 4
author { # depth 5
# ... continues
}
}
}
}
}
}
Persistent Queries
/**
* Automatic Persisted Queries (APQ)
*
* Benefits:
* - Reduce bandwidth
* - Improve cache hit rate
* - Enable query allowlisting
*/
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageGraphQLPlayground } from '@apollo/server-plugin-landing-page-graphql-playground';
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new Map(), // Or use Redis
},
});
/**
* Client sends query hash first
*/
// 1. Client sends SHA-256 hash of query
POST /graphql
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123..."
}
}
}
// 2. If not in cache, server returns PersistedQueryNotFound
{
"errors": [{
"message": "PersistedQueryNotFound",
"extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }
}]
}
// 3. Client sends full query with hash
POST /graphql
{
"query": "query { users { id name } }",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123..."
}
}
}
// 4. Server caches query and returns result
// 5. Future requests only need hash
Field-Level Caching
/**
* Cache Specific Fields
*/
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
sessionId: (requestContext) => {
return requestContext.request.http?.headers.get('session-id') || null;
},
}),
],
});
// Schema with cache hints
const typeDefs = `
type Query {
users: [User!]! @cacheControl(maxAge: 60)
posts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
}
type User {
id: ID!
name: String! @cacheControl(maxAge: 3600)
email: String! @cacheControl(maxAge: 0, scope: PRIVATE)
}
`;
/**
* Programmatic Cache Control
*/
const resolvers = {
Query: {
users: async (_parent, _args, context, info) => {
info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PUBLIC' });
return context.db.user.findMany();
},
},
User: {
email: (parent: User, _args, context, info) => {
// Don't cache private fields
info.cacheControl.setCacheHint({ maxAge: 0, scope: 'PRIVATE' });
return parent.email;
},
},
};
Pro Tip: Combine multiple optimization techniques: depth limiting (prevent deep queries), complexity analysis (prevent expensive queries), and APQ (reduce bandwidth). Monitor query performance to identify slow queries.
10. Caching Strategies
Redis Caching
/**
* Redis-Based Caching
*/
import { createClient } from 'redis';
const redis = createClient({
url: process.env.REDIS_URL,
});
await redis.connect();
/**
* Cache Wrapper
*/
async function cached(
key: string,
ttl: number,
fn: () => Promise
): Promise {
// Try to get from cache
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Execute function
const result = await fn();
// Store in cache
await redis.setEx(key, ttl, JSON.stringify(result));
return result;
}
/**
* Use in Resolvers
*/
const resolvers = {
Query: {
users: async () => {
return cached('users', 60, async () => {
return db.user.findMany();
});
},
user: async (_parent, args: { id: string }) => {
return cached(`user:${args.id}`, 300, async () => {
return db.user.findUnique({
where: { id: args.id },
});
});
},
},
Mutation: {
updateUser: async (_parent, args) => {
const user = await db.user.update({
where: { id: args.id },
data: args.input,
});
// Invalidate cache
await redis.del(`user:${user.id}`);
await redis.del('users');
return user;
},
},
};
DataLoader Caching
/**
* Multi-Layer Caching with DataLoader + Redis
*/
import DataLoader from 'dataloader';
const createUserLoader = (redis: Redis, db: Database) => {
return new DataLoader(
async (ids: readonly string[]) => {
// Try Redis first
const cacheKeys = ids.map(id => `user:${id}`);
const cached = await redis.mGet(cacheKeys);
const missing: string[] = [];
const results = new Map();
// Check which IDs are in cache
cached.forEach((value, index) => {
if (value) {
results.set(ids[index], JSON.parse(value));
} else {
missing.push(ids[index]);
}
});
// Fetch missing from database
if (missing.length > 0) {
const users = await db.user.findMany({
where: { id: { in: missing } },
});
// Store in Redis
const pipeline = redis.pipeline();
for (const user of users) {
results.set(user.id, user);
pipeline.setEx(`user:${user.id}`, 300, JSON.stringify(user));
}
await pipeline.exec();
}
// Return in correct order
return ids.map(id => results.get(id) || null);
},
{
cache: true, // DataLoader in-memory cache
}
);
};
/**
* Cache Invalidation
*/
const resolvers = {
Mutation: {
updateUser: async (_parent, args, context: Context) => {
const user = await context.db.user.update({
where: { id: args.id },
data: args.input,
});
// Clear DataLoader cache
context.loaders.user.clear(user.id);
// Clear Redis cache
await redis.del(`user:${user.id}`);
// Prime loader with new data
context.loaders.user.prime(user.id, user);
return user;
},
},
};
HTTP Caching
/**
* HTTP Caching Headers
*/
import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
// Calculate cache key
sessionId: (requestContext) => {
const userId = requestContext.contextValue.user?.id;
return userId || null;
},
// Set cache TTL
generateCacheKey: (requestContext) => {
const { query, variables } = requestContext.request;
return `${query}:${JSON.stringify(variables)}`;
},
// Should read from cache
shouldReadFromCache: (requestContext) => {
return requestContext.request.http?.method === 'GET';
},
// Should write to cache
shouldWriteToCache: (requestContext) => {
// Don't cache if errors
return !requestContext.errors;
},
}),
],
});
/**
* CDN Caching
*/
// Set Cache-Control headers based on query
const resolvers = {
Query: {
publicPosts: async (_parent, _args, context, info) => {
// Public data - cache for 5 minutes
info.cacheControl.setCacheHint({
maxAge: 300,
scope: 'PUBLIC',
});
return db.post.findMany({
where: { published: true },
});
},
myPosts: async (_parent, _args, context, info) => {
// Private data - don't cache
info.cacheControl.setCacheHint({
maxAge: 0,
scope: 'PRIVATE',
});
return db.post.findMany({
where: { authorId: context.user.id },
});
},
},
};
Common Pitfall: Don't cache personalized data in shared caches (CDN, Redis). Use cache scopes (PUBLIC vs PRIVATE) and session-based cache keys to prevent data leakage.
11. Real-time with Subscriptions
WebSocket Subscriptions
/**
* WebSocket Subscriptions with graphql-ws
*/
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import { PubSub } from 'graphql-subscriptions';
// PubSub for local development (use Redis in production)
const pubsub = new PubSub();
// Type definitions
const typeDefs = `
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Subscription {
postCreated: Post!
postUpdated(id: ID!): Post!
postDeleted: ID!
}
type Mutation {
createPost(title: String!, content: String!): Post!
updatePost(id: ID!, title: String, content: String): Post!
deletePost(id: ID!): Boolean!
}
`;
// Resolvers
const resolvers = {
Mutation: {
createPost: async (_parent, args, context: Context) => {
const post = await context.db.post.create({
data: {
title: args.title,
content: args.content,
authorId: context.user.id,
},
include: { author: true },
});
// Publish event
await pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
updatePost: async (_parent, args, context: Context) => {
const post = await context.db.post.update({
where: { id: args.id },
data: {
title: args.title,
content: args.content,
},
include: { author: true },
});
// Publish event
await pubsub.publish(`POST_UPDATED_${post.id}`, { postUpdated: post });
return post;
},
deletePost: async (_parent, args, context: Context) => {
await context.db.post.delete({
where: { id: args.id },
});
// Publish event
await pubsub.publish('POST_DELETED', { postDeleted: args.id });
return true;
},
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
},
postUpdated: {
subscribe: (_parent, args: { id: string }) => {
return pubsub.asyncIterator([`POST_UPDATED_${args.id}`]);
},
},
postDeleted: {
subscribe: () => pubsub.asyncIterator(['POST_DELETED']),
},
},
};
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Create Express app
const app = express();
const httpServer = http.createServer(app);
// WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// Setup subscription handler
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// Get auth token from connection params
const token = ctx.connectionParams?.authorization;
const user = token ? await verifyToken(token) : null;
return {
user,
db,
loaders: createLoaders(db),
};
},
onConnect: async (ctx) => {
console.log('Client connected');
},
onDisconnect: async (ctx) => {
console.log('Client disconnected');
},
},
wsServer
);
// Apollo Server
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use(
'/graphql',
cors(),
json(),
expressMiddleware(server, { context: createContext })
);
await new Promise((resolve) => {
httpServer.listen({ port: 4000 }, resolve);
});
console.log('🚀 Server ready at http://localhost:4000/graphql');
Redis PubSub
/**
* Redis PubSub for Distributed Systems
*/
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
// Redis PubSub
const pubsub = new RedisPubSub({
publisher: new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
retryStrategy: (times) => Math.min(times * 50, 2000),
}),
subscriber: new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
retryStrategy: (times) => Math.min(times * 50, 2000),
}),
});
// Use same resolvers as before
const resolvers = {
Mutation: {
createPost: async (_parent, args, context: Context) => {
const post = await context.db.post.create({
data: { title: args.title, content: args.content },
include: { author: true },
});
// Publish to Redis (works across multiple servers)
await pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
},
},
};
Subscription Filters
/**
* Filter Subscription Events
*/
const resolvers = {
Subscription: {
// Filter by category
postCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['POST_CREATED']),
(payload, variables) => {
// Only send posts matching category
return payload.postCreated.category === variables.category;
}
),
},
// Filter by user
commentCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['COMMENT_CREATED']),
(payload, variables, context) => {
// Only send to post author
return payload.commentCreated.post.authorId === context.user.id;
}
),
},
},
};
// Schema
const typeDefs = `
type Subscription {
postCreated(category: String!): Post!
commentCreated: Comment!
}
`;
// Client
subscription {
postCreated(category: "Technology") {
id
title
}
}
Pro Tip: Use Redis PubSub in production for horizontal scaling. Local PubSub only works with a single server instance. Always authenticate subscription connections.
12. GraphQL Federation
Federation Basics
/**
* GraphQL Federation (Apollo Federation 2)
*
* Benefits:
* - Split schema across multiple services
* - Independent deployment
* - Team autonomy
* - Shared types across services
*/
// User Service
import { ApolloServer } from '@apollo/server';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';
const typeDefs = gql`
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
`;
const resolvers = {
Query: {
user: (_parent, args: { id: string }) => {
return db.user.findUnique({ where: { id: args.id } });
},
users: () => {
return db.user.findMany();
},
},
User: {
__resolveReference: (reference: { id: string }) => {
return db.user.findUnique({ where: { id: reference.id } });
},
},
};
const userServer = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
// Post Service
const postTypeDefs = gql`
type Post @key(fields: "id") {
id: ID!
title: String!
content: String!
author: User!
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
type Query {
post(id: ID!): Post
posts: [Post!]!
}
`;
const postResolvers = {
Query: {
post: (_parent, args: { id: string }) => {
return db.post.findUnique({ where: { id: args.id } });
},
posts: () => {
return db.post.findMany();
},
},
Post: {
__resolveReference: (reference: { id: string }) => {
return db.post.findUnique({ where: { id: reference.id } });
},
author: (post: Post) => {
// Return reference to User type
return { __typename: 'User', id: post.authorId };
},
},
User: {
posts: (user: { id: string }) => {
return db.post.findMany({ where: { authorId: user.id } });
},
},
};
const postServer = new ApolloServer({
schema: buildSubgraphSchema({
typeDefs: postTypeDefs,
resolvers: postResolvers,
}),
});
Apollo Gateway
/**
* Apollo Gateway (Federated Router)
*/
import { ApolloServer } from '@apollo/server';
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://localhost:4001/graphql' },
{ name: 'posts', url: 'http://localhost:4002/graphql' },
{ name: 'comments', url: 'http://localhost:4003/graphql' },
],
}),
});
const server = new ApolloServer({
gateway,
});
await server.start();
// Now you can query across services:
query {
user(id: "1") { # From users service
name
posts { # From posts service
title
comments { # From comments service
text
}
}
}
}
Federation Patterns
/**
* Value Types (Shared Types)
*/
// Shared in multiple services
type Address {
street: String!
city: String!
country: String!
}
type User @key(fields: "id") {
id: ID!
name: String!
address: Address!
}
/**
* Entity Extension
*/
// User service owns User
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
// Order service extends User
extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
/**
* Computed Fields
*/
// User service
type User @key(fields: "id") {
id: ID!
firstName: String!
lastName: String!
}
// Profile service adds computed field
extend type User @key(fields: "id") {
id: ID! @external
firstName: String! @external
lastName: String! @external
fullName: String! @requires(fields: "firstName lastName")
}
const resolvers = {
User: {
fullName: (user: User) => {
return `${user.firstName} ${user.lastName}`;
},
},
};
/**
* Cross-Service References
*/
// Post service
type Post @key(fields: "id") {
id: ID!
title: String!
authorId: ID! @shareable
author: User! @provides(fields: "id")
}
const resolvers = {
Post: {
author: (post: Post) => {
// Return stub that gateway will resolve
return { __typename: 'User', id: post.authorId };
},
},
};
Common Pitfall: Don't create circular dependencies between subgraphs. Design clear ownership boundaries - each entity should have one owning service that manages its core fields.
13. Security Best Practices
Rate Limiting
/**
* Rate Limiting
*/
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redis = createClient({
url: process.env.REDIS_URL,
});
await redis.connect();
// Rate limit by IP
const limiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:',
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
errors: [{
message: 'Too many requests',
extensions: { code: 'RATE_LIMIT_EXCEEDED' },
}],
});
},
});
app.use('/graphql', limiter);
/**
* Per-User Rate Limiting
*/
const createUserLimiter = () => {
const limiters = new Map();
return async (userId: string, maxRequests: number, windowMs: number) => {
const now = Date.now();
const limiter = limiters.get(userId);
if (!limiter || now > limiter.resetAt) {
// Reset window
limiters.set(userId, {
count: 1,
resetAt: now + windowMs,
});
return true;
}
if (limiter.count >= maxRequests) {
return false;
}
limiter.count++;
return true;
};
};
const userLimiter = createUserLimiter();
// Use in resolvers
const resolvers = {
Mutation: {
createPost: async (_parent, _args, context: Context) => {
// 10 posts per hour
const allowed = await userLimiter(context.user.id, 10, 60 * 60 * 1000);
if (!allowed) {
throw new GraphQLError('Rate limit exceeded', {
extensions: { code: 'RATE_LIMIT_EXCEEDED' },
});
}
return context.db.post.create({ data: _args.input });
},
},
};
Query Allowlisting
/**
* Query Allowlisting (Persisted Queries)
*/
// Store of allowed queries
const allowedQueries = new Map([
['GetUser', 'query GetUser($id: ID!) { user(id: $id) { id name } }'],
['CreatePost', 'mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title } }'],
]);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart({ request }) {
// In production, only allow queries from allowlist
if (process.env.NODE_ENV === 'production') {
const { query, operationName } = request;
if (!operationName || !allowedQueries.has(operationName)) {
throw new GraphQLError('Query not allowed', {
extensions: { code: 'QUERY_NOT_ALLOWED' },
});
}
// Verify query matches
if (query !== allowedQueries.get(operationName)) {
throw new GraphQLError('Query mismatch', {
extensions: { code: 'QUERY_MISMATCH' },
});
}
}
return {};
},
},
],
});
Input Sanitization
/**
* Input Sanitization
*/
import DOMPurify from 'isomorphic-dompurify';
function sanitizeString(input: string): string {
// Remove HTML tags
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
}
function sanitizeInput>(input: T): T {
const sanitized: any = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string') {
sanitized[key] = sanitizeString(value);
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeInput(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
// Use in resolvers
const resolvers = {
Mutation: {
createPost: async (_parent, args: { input: CreatePostInput }) => {
// Sanitize input
const sanitized = sanitizeInput(args.input);
return db.post.create({
data: sanitized,
});
},
},
};
/**
* SQL Injection Prevention
*/
// BAD: String concatenation (vulnerable)
const users = await db.$queryRaw(
`SELECT * FROM users WHERE email = '${email}'`
);
// GOOD: Parameterized queries
const users = await db.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE email = ${email}`
);
CSRF Protection
/**
* CSRF Protection
*/
const server = new ApolloServer({
typeDefs,
resolvers,
csrfPrevention: true, // Enable CSRF protection
});
// Client must send one of:
// - Apollo-Require-Preflight: true header
// - X-Apollo-Operation-Name header
// - Content-Type: application/json
/**
* CORS Configuration
*/
import cors from 'cors';
app.use(
'/graphql',
cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}),
expressMiddleware(server, { context: createContext })
);
Pro Tip: Layer security measures: rate limiting (prevent abuse), query complexity (prevent expensive queries), allowlisting (prevent malicious queries), and input sanitization (prevent injection).
14. Monitoring & Observability
Apollo Studio
/**
* Apollo Studio Integration
*/
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginUsageReporting({
sendVariableValues: { all: true },
sendHeaders: { all: true },
sendErrors: { masked: true },
}),
],
});
// Set APOLLO_KEY and APOLLO_GRAPH_REF environment variables
Custom Metrics
/**
* Prometheus Metrics
*/
import { Counter, Histogram, register } from 'prom-client';
// Metrics
const queryCounter = new Counter({
name: 'graphql_queries_total',
help: 'Total number of GraphQL queries',
labelNames: ['operation', 'status'],
});
const queryDuration = new Histogram({
name: 'graphql_query_duration_seconds',
help: 'GraphQL query duration',
labelNames: ['operation'],
buckets: [0.001, 0.01, 0.1, 0.5, 1, 5],
});
const resolverDuration = new Histogram({
name: 'graphql_resolver_duration_seconds',
help: 'GraphQL resolver duration',
labelNames: ['type', 'field'],
buckets: [0.001, 0.01, 0.1, 0.5, 1],
});
// Plugin
const metricsPlugin: ApolloServerPlugin = {
async requestDidStart(requestContext) {
const start = Date.now();
const operation = requestContext.request.operationName || 'anonymous';
return {
async willSendResponse() {
const duration = (Date.now() - start) / 1000;
const status = requestContext.errors ? 'error' : 'success';
queryCounter.inc({ operation, status });
queryDuration.observe({ operation }, duration);
},
async executionDidStart() {
return {
willResolveField({ info }) {
const start = Date.now();
return () => {
const duration = (Date.now() - start) / 1000;
resolverDuration.observe(
{ type: info.parentType.name, field: info.fieldName },
duration
);
};
},
};
},
};
},
};
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
Tracing
/**
* OpenTelemetry Tracing
*/
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('graphql-server');
const tracingPlugin: ApolloServerPlugin = {
async requestDidStart({ request }) {
const span = tracer.startSpan('graphql.request', {
attributes: {
'graphql.operation.name': request.operationName,
'graphql.operation.type': request.query?.includes('mutation')
? 'mutation'
: 'query',
},
});
return {
async willSendResponse() {
span.end();
},
async executionDidStart() {
return {
willResolveField({ info }) {
const fieldSpan = tracer.startSpan('graphql.resolve', {
attributes: {
'graphql.field.name': info.fieldName,
'graphql.field.type': info.parentType.name,
},
});
return () => {
fieldSpan.end();
};
},
};
},
};
},
};
Pro Tip: Track resolver performance to identify slow fields. Use distributed tracing to debug performance issues across microservices in federated architectures.
15. Testing GraphQL APIs
Integration Tests
/**
* Integration Testing with Apollo Server
*/
import { ApolloServer } from '@apollo/server';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
describe('GraphQL API', () => {
let server: ApolloServer;
let db: Database;
beforeAll(async () => {
// Setup test database
db = await setupTestDatabase();
server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
});
afterAll(async () => {
await server.stop();
await db.$disconnect();
});
describe('Query.user', () => {
it('fetches user by ID', async () => {
// Create test user
const user = await db.user.create({
data: {
name: 'John Doe',
email: '[email protected]',
},
});
// Execute query
const result = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: user.id },
}, {
contextValue: {
user: null,
db,
loaders: createLoaders(db),
},
});
// Assert
expect(result.body.kind).toBe('single');
if (result.body.kind === 'single') {
expect(result.body.singleResult.errors).toBeUndefined();
expect(result.body.singleResult.data?.user).toEqual({
id: user.id,
name: 'John Doe',
email: '[email protected]',
});
}
});
it('returns null for non-existent user', async () => {
const result = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
}
}
`,
variables: { id: 'non-existent' },
}, {
contextValue: {
user: null,
db,
loaders: createLoaders(db),
},
});
expect(result.body.kind).toBe('single');
if (result.body.kind === 'single') {
expect(result.body.singleResult.data?.user).toBeNull();
}
});
});
describe('Mutation.createUser', () => {
it('creates new user', async () => {
const result = await server.executeOperation({
query: `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`,
variables: {
input: {
name: 'Jane Doe',
email: '[email protected]',
},
},
}, {
contextValue: {
user: null,
db,
loaders: createLoaders(db),
},
});
expect(result.body.kind).toBe('single');
if (result.body.kind === 'single') {
expect(result.body.singleResult.errors).toBeUndefined();
expect(result.body.singleResult.data?.createUser).toMatchObject({
name: 'Jane Doe',
email: '[email protected]',
});
}
});
it('rejects invalid email', async () => {
const result = await server.executeOperation({
query: `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
}
}
`,
variables: {
input: {
name: 'Test',
email: 'invalid-email',
},
},
}, {
contextValue: {
user: null,
db,
loaders: createLoaders(db),
},
});
expect(result.body.kind).toBe('single');
if (result.body.kind === 'single') {
expect(result.body.singleResult.errors).toBeDefined();
expect(result.body.singleResult.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT');
}
});
});
});
Resolver Unit Tests
/**
* Unit Testing Resolvers
*/
import { describe, it, expect, vi } from 'vitest';
describe('User resolvers', () => {
it('fetches user posts', async () => {
const mockDb = {
post: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'Post 1' },
{ id: '2', title: 'Post 2' },
]),
},
};
const parent = { id: 'user-1' };
const context = { db: mockDb, loaders: {} };
const result = await resolvers.User.posts(parent, {}, context, {} as any);
expect(mockDb.post.findMany).toHaveBeenCalledWith({
where: { authorId: 'user-1' },
});
expect(result).toHaveLength(2);
});
});
Schema Testing
/**
* Schema Validation Tests
*/
import { assertValidSchema, buildSchema } from 'graphql';
import { describe, it } from 'vitest';
describe('GraphQL Schema', () => {
it('is valid', () => {
const schema = buildSchema(typeDefs);
assertValidSchema(schema);
});
it('has Query type', () => {
const schema = buildSchema(typeDefs);
expect(schema.getQueryType()).toBeDefined();
});
it('has Mutation type', () => {
const schema = buildSchema(typeDefs);
expect(schema.getMutationType()).toBeDefined();
});
});
Common Pitfall: Don't test GraphQL queries as HTTP requests in unit tests. Use server.executeOperation() to test business logic without HTTP overhead.
Learning Path
Recommended Order:
- GraphQL Fundamentals - Understand queries, mutations, types
- Schema Design - Learn best practices for schema design
- Resolvers - Implement resolver logic
- DataLoader - Solve N+1 problem with batching
- Apollo Server - Set up production server
- Authentication - Implement auth and authorization
- Performance - Optimize with caching and complexity limits
- Subscriptions - Add real-time features
- Federation - Scale with microservices
- Security - Harden production API