| name | lang-graphql-dev |
| description | Foundational GraphQL patterns covering schema design, queries, mutations, subscriptions, and resolvers. Use when building or consuming GraphQL APIs. This is the entry point for GraphQL development. |
GraphQL Fundamentals
Foundational GraphQL patterns covering schema definition, queries, mutations, subscriptions, resolvers, and API design best practices. Use this skill when building GraphQL APIs, consuming GraphQL endpoints, or designing data graph architectures.
Skill Hierarchy
lang-graphql-dev (foundational, this skill)
├── graphql-federation (multi-service graphs)
├── graphql-optimization (query performance, n+1, dataloaders)
└── graphql-security (auth, rate limiting, depth limiting)
This skill covers:
- Schema definition (types, fields, scalars, enums)
- Query operations and variables
- Mutations and input types
- Subscriptions for real-time updates
- Resolver implementation patterns
- Interfaces and unions for polymorphism
- Custom directives
- Error handling strategies
- API design best practices
Quick Reference
| Pattern | GraphQL Syntax |
|---|---|
| Object Type | type User { id: ID! name: String! } |
| Query Field | type Query { user(id: ID!): User } |
| Mutation | type Mutation { createUser(input: CreateUserInput!): User! } |
| Subscription | type Subscription { userCreated: User! } |
| Non-Null | field: String! (cannot be null) |
| List | users: [User!]! (non-null list of non-null users) |
| Input Type | input CreateUserInput { name: String! email: String! } |
| Enum | enum Role { ADMIN USER GUEST } |
| Interface | interface Node { id: ID! } |
| Union | union SearchResult = User | Post | Comment |
| Directive | field: String @deprecated(reason: "Use newField") |
| Fragment | fragment UserFields on User { id name } |
| Alias | admin: user(id: "1") { name } |
| Variable | query GetUser($id: ID!) { user(id: $id) } |
Schema Definition Language (SDL)
Object Types
Object types represent entities in your API with named fields:
# Basic object type
type User {
id: ID!
name: String!
email: String!
age: Int
isActive: Boolean!
createdAt: DateTime!
}
# Type with relationships
type Post {
id: ID!
title: String!
content: String!
author: User! # Single relationship
comments: [Comment!]! # List relationship
tags: [String!]! # List of scalars
publishedAt: DateTime
}
# Nested object type
type Comment {
id: ID!
text: String!
author: User!
post: Post!
replies: [Comment!]! # Self-referential
createdAt: DateTime!
}
Scalar Types
Built-in scalars and custom scalar definitions:
# Built-in scalars
# Int: 32-bit signed integer
# Float: signed double-precision floating-point
# String: UTF-8 character sequence
# Boolean: true or false
# ID: unique identifier (serialized as String)
# Custom scalar declarations
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar Upload
# Usage in types
type Event {
id: ID!
name: String!
startTime: DateTime!
endTime: DateTime!
website: URL
metadata: JSON
}
type User {
id: ID!
email: Email!
avatar: URL
}
Enums
Enumeration types with fixed set of values:
# Basic enum
enum Role {
ADMIN
USER
GUEST
}
# Enum with descriptions
enum PostStatus {
"""
Draft state - not visible to public
"""
DRAFT
"""
Published and visible to all users
"""
PUBLISHED
"""
Archived - read-only access
"""
ARCHIVED
}
# Enum in type
type User {
id: ID!
name: String!
role: Role!
status: UserStatus!
}
enum UserStatus {
ACTIVE
SUSPENDED
DEACTIVATED
}
Input Types
Input types for mutation and query arguments:
# Basic input type
input CreateUserInput {
name: String!
email: String!
age: Int
role: Role!
}
# Nested input type
input CreatePostInput {
title: String!
content: String!
authorId: ID!
tags: [String!]!
metadata: PostMetadataInput
}
input PostMetadataInput {
category: String
featured: Boolean
seoKeywords: [String!]
}
# Update input (all fields optional)
input UpdateUserInput {
name: String
email: String
age: Int
role: Role
}
# Filter input for queries
input UserFilterInput {
role: Role
isActive: Boolean
createdAfter: DateTime
search: String
}
# Pagination input
input PaginationInput {
limit: Int = 10
offset: Int = 0
}
input CursorPaginationInput {
first: Int
after: String
last: Int
before: String
}
Interfaces
Interfaces define common fields shared by multiple types:
# Basic interface
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
# Types implementing interface
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!
author: User!
}
# Multiple interfaces
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
interface Authored {
author: User!
}
type Article implements Node & Timestamped & Authored {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
author: User!
title: String!
body: String!
}
# Querying with interfaces
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node!]!
# Returns any type implementing Node
search(query: String!): [Node!]!
}
Unions
Unions represent values that could be one of several types:
# Basic union
union SearchResult = User | Post | Comment
# Union in query
type Query {
search(query: String!): [SearchResult!]!
}
# Querying unions (requires inline fragments)
# query {
# search(query: "graphql") {
# ... on User {
# id
# name
# email
# }
# ... on Post {
# id
# title
# author { name }
# }
# ... on Comment {
# id
# text
# author { name }
# }
# }
# }
# Error handling with unions
union CreateUserResult = User | ValidationError | DuplicateEmailError
type ValidationError {
message: String!
fields: [String!]!
}
type DuplicateEmailError {
message: String!
email: String!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}
# Media type union
union Media = Photo | Video | Audio
type Photo {
id: ID!
url: URL!
width: Int!
height: Int!
}
type Video {
id: ID!
url: URL!
duration: Int!
thumbnail: URL!
}
type Audio {
id: ID!
url: URL!
duration: Int!
}
Directives
Built-in and custom directives:
# Built-in directives
# @deprecated - mark fields as deprecated
type User {
id: ID!
name: String!
username: String! @deprecated(reason: "Use name instead")
email: String!
}
# @skip - conditionally exclude field
# @include - conditionally include field
# query GetUser($id: ID!, $withEmail: Boolean!) {
# user(id: $id) {
# id
# name
# email @include(if: $withEmail)
# }
# }
# Custom directive definitions
directive @auth(
requires: Role = USER
) on OBJECT | FIELD_DEFINITION
directive @rateLimit(
limit: Int!
duration: Int!
) on FIELD_DEFINITION
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
) on FIELD_DEFINITION | OBJECT
enum CacheControlScope {
PUBLIC
PRIVATE
}
# Using custom directives
type Query {
publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
me: User! @auth(requires: USER)
adminUsers: [User!]! @auth(requires: ADMIN)
search(query: String!): [SearchResult!]!
@rateLimit(limit: 100, duration: 60)
}
# Field-level directive
type User @auth(requires: USER) {
id: ID!
name: String!
email: String! @auth(requires: ADMIN)
posts: [Post!]!
}
Root Operation Types
Query Type
Read-only operations:
type Query {
# Single entity by ID
user(id: ID!): User
post(id: ID!): Post
# List with optional filtering
users(
filter: UserFilterInput
limit: Int = 10
offset: Int = 0
): [User!]!
# Search operations
searchUsers(query: String!): [User!]!
searchPosts(query: String!): [Post!]!
search(query: String!): [SearchResult!]!
# Nested queries
userPosts(userId: ID!, limit: Int = 10): [Post!]!
postComments(postId: ID!): [Comment!]!
# Aggregations
userCount: Int!
postCount(authorId: ID): Int!
# Current user
me: User
}
Mutation Type
Write operations that modify data:
type Mutation {
# Create operations
createUser(input: CreateUserInput!): User!
createPost(input: CreatePostInput!): Post!
createComment(input: CreateCommentInput!): Comment!
# Update operations
updateUser(id: ID!, input: UpdateUserInput!): User!
updatePost(id: ID!, input: UpdatePostInput!): Post!
# Delete operations
deleteUser(id: ID!): DeleteResult!
deletePost(id: ID!): DeleteResult!
# Batch operations
deleteUsers(ids: [ID!]!): BatchDeleteResult!
updateUserRoles(updates: [UserRoleUpdate!]!): [User!]!
# Complex mutations
publishPost(id: ID!): Post!
likePost(postId: ID!): Post!
followUser(userId: ID!): User!
# File upload
uploadAvatar(file: Upload!): User!
}
type DeleteResult {
success: Boolean!
id: ID!
}
type BatchDeleteResult {
success: Boolean!
deletedCount: Int!
deletedIds: [ID!]!
}
input UserRoleUpdate {
userId: ID!
role: Role!
}
Subscription Type
Real-time event streams:
type Subscription {
# Entity created events
userCreated: User!
postCreated: Post!
commentCreated(postId: ID): Comment!
# Entity updated events
userUpdated(id: ID!): User!
postUpdated(id: ID!): Post!
# Entity deleted events
userDeleted: ID!
postDeleted: ID!
# Custom events
messageReceived(channelId: ID!): Message!
notificationReceived: Notification!
# Filtered subscriptions
postsInCategory(category: String!): Post!
userActivity(userId: ID!): ActivityEvent!
}
type Message {
id: ID!
channelId: ID!
author: User!
text: String!
createdAt: DateTime!
}
type Notification {
id: ID!
type: NotificationType!
title: String!
body: String!
createdAt: DateTime!
}
enum NotificationType {
MENTION
LIKE
COMMENT
FOLLOW
}
union ActivityEvent = PostCreated | PostLiked | CommentCreated
type PostCreated {
post: Post!
}
type PostLiked {
post: Post!
user: User!
}
type CommentCreated {
comment: Comment!
}
Query Operations
Basic Queries
Simple field selection:
# Fetch single user
query GetUser {
user(id: "123") {
id
name
email
}
}
# Fetch list of users
query GetUsers {
users(limit: 10) {
id
name
email
}
}
# Nested fields
query GetUserWithPosts {
user(id: "123") {
id
name
posts {
id
title
publishedAt
}
}
}
# Multiple root fields
query GetDashboardData {
me {
id
name
}
recentPosts(limit: 5) {
id
title
}
notifications {
id
title
}
}
Query Variables
Parameterized queries for reusability:
# Query with variables
query GetUser($userId: ID!, $postLimit: Int = 5) {
user(id: $userId) {
id
name
email
posts(limit: $postLimit) {
id
title
}
}
}
# Variables JSON
{
"userId": "123",
"postLimit": 10
}
# Optional variables with defaults
query GetPosts($limit: Int = 10, $offset: Int = 0) {
posts(limit: $limit, offset: $offset) {
id
title
}
}
# Variables with input types
query SearchUsers($filter: UserFilterInput!) {
users(filter: $filter) {
id
name
email
}
}
# Variables JSON
{
"filter": {
"role": "ADMIN",
"isActive": true
}
}
# Non-null variables
query CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
}
}
Aliases
Rename fields in response:
query GetMultipleUsers {
admin: user(id: "1") {
id
name
}
regularUser: user(id: "2") {
id
name
}
}
# Response shape:
# {
# "data": {
# "admin": { "id": "1", "name": "Admin User" },
# "regularUser": { "id": "2", "name": "Regular User" }
# }
# }
query GetUserStats {
allUsers: userCount
activeUsers: userCount(filter: { isActive: true })
adminUsers: userCount(filter: { role: ADMIN })
}
Fragments
Reusable field selections:
# Named fragment
fragment UserFields on User {
id
name
email
createdAt
}
query GetUsers {
users {
...UserFields
}
}
query GetUser($id: ID!) {
user(id: $id) {
...UserFields
posts {
id
title
}
}
}
# Nested fragments
fragment PostPreview on Post {
id
title
publishedAt
author {
...UserFields
}
}
fragment CommentFields on Comment {
id
text
author {
...UserFields
}
}
query GetPost($id: ID!) {
post(id: $id) {
...PostPreview
content
comments {
...CommentFields
}
}
}
# Inline fragments for unions
query Search($query: String!) {
search(query: $query) {
... on User {
id
name
email
}
... on Post {
id
title
author { name }
}
... on Comment {
id
text
}
}
}
# Inline fragments for interfaces
query GetNodes($ids: [ID!]!) {
nodes(ids: $ids) {
id
... on User {
name
email
}
... on Post {
title
content
}
}
}
Directives in Queries
Conditional field inclusion:
query GetUser($id: ID!, $withEmail: Boolean!, $withPosts: Boolean!) {
user(id: $id) {
id
name
email @include(if: $withEmail)
posts @include(if: $withPosts) {
id
title
}
}
}
query GetUsers($skipArchived: Boolean!) {
users {
id
name
archivedAt @skip(if: $skipArchived)
}
}
# Combining directives
query GetUserProfile(
$id: ID!
$withEmail: Boolean!
$skipAvatar: Boolean!
) {
user(id: $id) {
id
name
email @include(if: $withEmail)
avatar @skip(if: $skipAvatar)
}
}
Mutation Operations
Basic Mutations
Create, update, and delete operations:
# Create mutation
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
createdAt
}
}
# Variables
{
"input": {
"name": "John Doe",
"email": "john@example.com",
"role": "USER"
}
}
# Update mutation
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
updatedAt
}
}
# Variables
{
"id": "123",
"input": {
"name": "Jane Doe"
}
}
# Delete mutation
mutation DeleteUser($id: ID!) {
deleteUser(id: $id) {
success
id
}
}
# Variables
{
"id": "123"
}
Multiple Mutations
Execute multiple mutations in sequence:
mutation CreateUserAndPost(
$userInput: CreateUserInput!
$postInput: CreatePostInput!
) {
createUser(input: $userInput) {
id
name
}
createPost(input: $postInput) {
id
title
author { name }
}
}
# Mutations with aliases
mutation BatchUpdate {
user1: updateUser(id: "1", input: { name: "User One" }) {
id
name
}
user2: updateUser(id: "2", input: { name: "User Two" }) {
id
name
}
}
Optimistic Response Pattern
Return updated data after mutation:
mutation LikePost($postId: ID!) {
likePost(postId: $postId) {
id
title
likeCount
likedBy {
id
name
}
# Return full post data for cache update
author {
id
name
}
createdAt
}
}
mutation FollowUser($userId: ID!) {
followUser(userId: $userId) {
id
name
followerCount
isFollowedByMe
}
}
Subscription Operations
Basic Subscriptions
Subscribe to real-time events:
subscription OnUserCreated {
userCreated {
id
name
email
createdAt
}
}
subscription OnPostUpdated($postId: ID!) {
postUpdated(id: $postId) {
id
title
content
updatedAt
}
}
subscription OnMessageReceived($channelId: ID!) {
messageReceived(channelId: $channelId) {
id
text
author {
id
name
}
createdAt
}
}
Subscription with Fragments
Reuse fragments in subscriptions:
fragment MessageFields on Message {
id
text
author {
id
name
avatar
}
createdAt
}
subscription OnNewMessage($channelId: ID!) {
messageReceived(channelId: $channelId) {
...MessageFields
}
}
query GetMessages($channelId: ID!) {
messages(channelId: $channelId) {
...MessageFields
}
}
Resolver Patterns
Resolvers are functions that populate data for fields in your schema.
Basic Resolvers (JavaScript/TypeScript)
// Type resolvers
const resolvers = {
Query: {
// (parent, args, context, info) => result
user: async (parent, { id }, context) => {
return await context.db.users.findById(id);
},
users: async (parent, { filter, limit, offset }, context) => {
return await context.db.users.find(filter, { limit, offset });
},
me: async (parent, args, context) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return context.user;
}
},
Mutation: {
createUser: async (parent, { input }, context) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new Error('Not authorized');
}
return await context.db.users.create(input);
},
updateUser: async (parent, { id, input }, context) => {
return await context.db.users.update(id, input);
},
deleteUser: async (parent, { id }, context) => {
const deleted = await context.db.users.delete(id);
return { success: true, id };
}
},
Subscription: {
userCreated: {
subscribe: (parent, args, context) => {
return context.pubsub.asyncIterator(['USER_CREATED']);
}
},
postUpdated: {
subscribe: (parent, { id }, context) => {
return context.pubsub.asyncIterator([`POST_UPDATED_${id}`]);
}
}
}
};
Field Resolvers
Resolve nested fields:
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
return await context.db.users.findById(id);
}
},
User: {
// Resolve posts for a user
posts: async (user, { limit }, context) => {
return await context.db.posts.findByAuthorId(user.id, { limit });
},
// Computed field
fullName: (user) => {
return `${user.firstName} ${user.lastName}`;
},
// Resolve from different data source
profile: async (user, args, context) => {
return await context.profileAPI.get(user.profileId);
},
// Permission-based field
email: (user, args, context) => {
if (context.user?.id === user.id || context.user?.role === 'ADMIN') {
return user.email;
}
return null;
}
},
Post: {
author: async (post, args, context) => {
// Use DataLoader to prevent N+1 queries
return await context.loaders.userLoader.load(post.authorId);
},
comments: async (post, args, context) => {
return await context.db.comments.findByPostId(post.id);
},
likeCount: async (post, args, context) => {
return await context.db.likes.countByPostId(post.id);
}
}
};
Interface & Union Resolvers
Resolve type for interfaces and unions:
const resolvers = {
Query: {
search: async (parent, { query }, context) => {
const users = await context.db.users.search(query);
const posts = await context.db.posts.search(query);
const comments = await context.db.comments.search(query);
return [...users, ...posts, ...comments];
},
node: async (parent, { id }, context) => {
// Determine type from ID format or lookup
const type = getTypeFromId(id);
if (type === 'User') {
return await context.db.users.findById(id);
} else if (type === 'Post') {
return await context.db.posts.findById(id);
}
// ... etc
}
},
// Union type resolver
SearchResult: {
__resolveType(obj, context, info) {
if (obj.email) {
return 'User';
}
if (obj.title) {
return 'Post';
}
if (obj.text) {
return 'Comment';
}
return null;
}
},
// Interface type resolver
Node: {
__resolveType(obj, context, info) {
if (obj.email) {
return 'User';
}
if (obj.title) {
return 'Post';
}
// Use __typename if available
return obj.__typename;
}
},
// Alternative: add __typename in field resolvers
User: {
__typename: 'User',
// ... other fields
},
Post: {
__typename: 'Post',
// ... other fields
}
};
DataLoader Pattern (N+1 Prevention)
Batch and cache data loading:
import DataLoader from 'dataloader';
// Create loaders
function createLoaders(db) {
return {
userLoader: new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
// Return in same order as input
return userIds.map(id =>
users.find(user => user.id === id)
);
}),
postLoader: new DataLoader(async (postIds) => {
const posts = await db.posts.findByIds(postIds);
return postIds.map(id =>
posts.find(post => post.id === id)
);
}),
// Batch load posts by author
postsByAuthorLoader: new DataLoader(async (authorIds) => {
const posts = await db.posts.findByAuthorIds(authorIds);
return authorIds.map(authorId =>
posts.filter(post => post.authorId === authorId)
);
})
};
}
// Use in context
const context = ({ req }) => ({
user: req.user,
db,
loaders: createLoaders(db)
});
// Use in resolvers
const resolvers = {
Post: {
author: async (post, args, context) => {
// Batches multiple author requests
return await context.loaders.userLoader.load(post.authorId);
}
},
User: {
posts: async (user, args, context) => {
// Batches multiple posts-by-author requests
return await context.loaders.postsByAuthorLoader.load(user.id);
}
}
};
Error Handling
GraphQL Errors
Standard error handling:
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
const user = await context.db.users.findById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'USER_NOT_FOUND',
userId: id
}
});
}
return user;
}
},
Mutation: {
createUser: async (parent, { input }, context) => {
// Validation error
if (!input.email.includes('@')) {
throw new GraphQLError('Invalid email format', {
extensions: {
code: 'BAD_USER_INPUT',
field: 'email'
}
});
}
// Authorization error
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: {
code: 'UNAUTHENTICATED'
}
});
}
if (context.user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: {
code: 'FORBIDDEN'
}
});
}
try {
return await context.db.users.create(input);
} catch (error) {
if (error.code === 'DUPLICATE_EMAIL') {
throw new GraphQLError('Email already exists', {
extensions: {
code: 'DUPLICATE_EMAIL',
email: input.email
}
});
}
throw error;
}
}
}
};
Error Response Format
GraphQL error response structure:
{
"errors": [
{
"message": "User not found",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": ["user"],
"extensions": {
"code": "USER_NOT_FOUND",
"userId": "123"
}
}
],
"data": {
"user": null
}
}
Union-Based Error Handling
Type-safe errors using unions:
type User {
id: ID!
name: String!
email: String!
}
type ValidationError {
message: String!
fields: [String!]!
}
type NotFoundError {
message: String!
resourceId: ID!
}
type AuthenticationError {
message: String!
}
union CreateUserResult = User | ValidationError | AuthenticationError
union GetUserResult = User | NotFoundError | AuthenticationError
type Query {
user(id: ID!): GetUserResult!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
if (!context.user) {
return {
__typename: 'AuthenticationError',
message: 'Not authenticated'
};
}
const user = await context.db.users.findById(id);
if (!user) {
return {
__typename: 'NotFoundError',
message: 'User not found',
resourceId: id
};
}
return {
__typename: 'User',
...user
};
}
},
GetUserResult: {
__resolveType(obj) {
return obj.__typename;
}
},
CreateUserResult: {
__resolveType(obj) {
return obj.__typename;
}
}
};
Query with union errors:
query GetUser($id: ID!) {
user(id: $id) {
... on User {
id
name
email
}
... on NotFoundError {
message
resourceId
}
... on AuthenticationError {
message
}
}
}
Pagination Patterns
Offset-Based Pagination
Simple offset/limit pagination:
type Query {
users(limit: Int = 10, offset: Int = 0): UserConnection!
}
type UserConnection {
items: [User!]!
totalCount: Int!
hasMore: Boolean!
}
const resolvers = {
Query: {
users: async (parent, { limit, offset }, context) => {
const items = await context.db.users.find({}, { limit, offset });
const totalCount = await context.db.users.count();
const hasMore = offset + limit < totalCount;
return { items, totalCount, hasMore };
}
}
};
Cursor-Based Pagination (Relay)
Relay-style cursor pagination:
type Query {
users(
first: Int
after: String
last: Int
before: String
): 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
}
import { cursorToOffset, offsetToCursor } from './pagination';
const resolvers = {
Query: {
users: async (parent, args, context) => {
const { first, after, last, before } = args;
let offset = 0;
let limit = first || last || 10;
if (after) {
offset = cursorToOffset(after) + 1;
}
const items = await context.db.users.find({}, { limit, offset });
const totalCount = await context.db.users.count();
const edges = items.map((node, index) => ({
node,
cursor: offsetToCursor(offset + index)
}));
const startCursor = edges.length > 0 ? edges[0].cursor : null;
const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
return {
edges,
pageInfo: {
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0,
startCursor,
endCursor
},
totalCount
};
}
}
};
// Cursor utilities
function offsetToCursor(offset) {
return Buffer.from(`cursor:${offset}`).toString('base64');
}
function cursorToOffset(cursor) {
return parseInt(Buffer.from(cursor, 'base64').toString().split(':')[1]);
}
Best Practices
Schema Design
- Use Non-Null (!) wisely - Required fields should be non-null
- Input types for mutations - Always use input types, not multiple arguments
- Consistent naming - Use camelCase for fields, PascalCase for types
- Descriptive names -
createUsernotaddU,userIdnotuid - Versioning via new fields - Add new fields instead of changing existing ones
- Connection pattern for lists - Use edges/nodes for paginated lists
- Single source of truth - One field per concept, use aliases for different views
# Good
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
input CreateUserInput {
name: String!
email: String!
age: Int
}
# Avoid
type Mutation {
createUser(name: String!, email: String!, age: Int): User!
updateUser(id: ID!, name: String, email: String, age: Int): User!
}
Query Design
- Request only needed fields - Avoid over-fetching
- Use fragments - DRY principle for repeated field sets
- Leverage aliases - Fetch same field with different arguments
- Batch queries - Multiple root fields in one request
- Named operations - Always name queries and mutations
# Good
query GetDashboard {
me {
...UserFields
}
recentPosts(limit: 5) {
...PostFields
}
}
fragment UserFields on User {
id
name
email
}
fragment PostFields on Post {
id
title
publishedAt
}
# Avoid
query {
me {
id
name
email
posts {
id
title
content
comments {
id
text
author {
id
name
}
}
}
}
}
Resolver Best Practices
- Context for request-scoped data - User, loaders, services
- Use DataLoaders - Prevent N+1 queries
- Throw GraphQLError - Consistent error handling
- Validate in resolvers - Don't rely only on schema validation
- Keep resolvers thin - Business logic in service layer
- Async/await - Consistent async pattern
// Good
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
return await context.services.users.getById(id);
}
},
User: {
posts: async (user, args, context) => {
return await context.loaders.postsByAuthor.load(user.id);
}
}
};
// Avoid
const resolvers = {
Query: {
user: (parent, { id }) => {
// Direct database access, no context
return db.users.findById(id);
}
},
User: {
posts: (user) => {
// N+1 query problem
return db.posts.findByAuthorId(user.id);
}
}
};
Security Best Practices
- Query depth limiting - Prevent deeply nested queries
- Query complexity analysis - Assign costs to fields
- Rate limiting - Per-user or per-IP limits
- Disable introspection in production - Hide schema from attackers
- Validate input - Check all user input
- Authentication in context - Check auth before resolvers run
- Field-level authorization - Control access to sensitive fields
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // Max depth of 5
createComplexityLimitRule(1000) // Max complexity of 1000
],
introspection: process.env.NODE_ENV !== 'production'
});
Performance Optimization
- DataLoader for batching - Batch database queries
- Caching - Response caching, persisted queries
- Field-level caching - Cache expensive field resolvers
- Pagination - Always paginate lists
- Query whitelisting - Only allow known queries in production
- Database query optimization - Use indexes, avoid N+1
- Response compression - Enable GZIP compression
Common Patterns
Relay Global Object Identification
Standardized ID pattern:
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
}
// Encode type into ID
function toGlobalId(type, id) {
return Buffer.from(`${type}:${id}`).toString('base64');
}
function fromGlobalId(globalId) {
const [type, id] = Buffer.from(globalId, 'base64').toString().split(':');
return { type, id };
}
const resolvers = {
Query: {
node: async (parent, { id }, context) => {
const { type, id: localId } = fromGlobalId(id);
if (type === 'User') {
return await context.db.users.findById(localId);
} else if (type === 'Post') {
return await context.db.posts.findById(localId);
}
return null;
}
}
};
File Upload
File upload using multipart request:
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): User!
uploadFiles(files: [Upload!]!): [File!]!
}
type File {
filename: String!
mimetype: String!
encoding: String!
url: String!
}
import { GraphQLUpload } from 'graphql-upload';
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadAvatar: async (parent, { file }, context) => {
const { createReadStream, filename, mimetype } = await file;
const stream = createReadStream();
const url = await context.storage.upload(stream, filename);
return await context.db.users.update(context.user.id, {
avatar: url
});
},
uploadFiles: async (parent, { files }, context) => {
const uploadedFiles = [];
for (const file of files) {
const { createReadStream, filename, mimetype, encoding } = await file;
const stream = createReadStream();
const url = await context.storage.upload(stream, filename);
uploadedFiles.push({
filename,
mimetype,
encoding,
url
});
}
return uploadedFiles;
}
}
};
Batch Mutations
Efficient bulk operations:
type Mutation {
createUsers(inputs: [CreateUserInput!]!): [User!]!
updateUsers(updates: [UpdateUserInput!]!): [User!]!
deleteUsers(ids: [ID!]!): BatchDeleteResult!
}
input UpdateUserInput {
id: ID!
name: String
email: String
}
type BatchDeleteResult {
success: Boolean!
deletedCount: Int!
deletedIds: [ID!]!
}
const resolvers = {
Mutation: {
createUsers: async (parent, { inputs }, context) => {
return await context.db.users.createMany(inputs);
},
updateUsers: async (parent, { updates }, context) => {
const promises = updates.map(({ id, ...input }) =>
context.db.users.update(id, input)
);
return await Promise.all(promises);
},
deleteUsers: async (parent, { ids }, context) => {
const deletedIds = await context.db.users.deleteMany(ids);
return {
success: true,
deletedCount: deletedIds.length,
deletedIds
};
}
}
};
Troubleshooting
Common Issues
Query not returning data
- Check resolver return value
- Verify field names match schema
- Check for errors in GraphQL response
- Validate variables are passed correctly
N+1 Query Problem
- Symptom: Many database queries for related data
- Solution: Use DataLoader to batch queries
- Example: Loading authors for 100 posts creates 101 queries without batching
Type Resolution Errors
- Interface/union types need
__resolveType - Return
__typenamefield from resolvers - Verify type names match schema exactly
Authentication Errors
- Check context.user is populated
- Verify auth middleware runs before GraphQL
- Use GraphQLError with appropriate code
Subscription not receiving updates
- Verify pubsub.publish() is called
- Check subscription filter matches
- Ensure WebSocket connection is established
References
Official Documentation
Tools & Libraries
- Apollo Server - GraphQL server for Node.js
- GraphQL Yoga - Fully-featured GraphQL server
- DataLoader - Batching and caching library
- GraphQL Code Generator - Generate types from schema
- GraphQL Inspector - Schema validation and comparison
Related Skills
graphql-federation- Multi-service GraphQL architecturesgraphql-optimization- Advanced performance patternsgraphql-security- Authentication, authorization, and rate limitingapi-design- General API design principleslang-typescript-library-dev- TypeScript for type-safe GraphQL
When to use this skill: Building GraphQL APIs, designing data graphs, implementing resolvers, consuming GraphQL endpoints, or learning GraphQL fundamentals.
Skill maintenance: Update when GraphQL specification changes, new directives are added, or best practices evolve.