graphql-api-design
SKILL.md
GraphQL API Design
Master GraphQL schema design and implementation to build flexible, efficient APIs that clients love.
When to Use This Skill
- Designing GraphQL schemas and type systems
- Building GraphQL resolvers and queries
- Implementing mutations with proper error handling
- Optimizing query performance with DataLoaders
- Implementing real-time features with subscriptions
- Preventing N+1 queries and query complexity attacks
- Migrating from REST APIs to GraphQL
- Setting up pagination and filtering strategies
GraphQL Design Fundamentals
Schema-First Development
Design your GraphQL schema BEFORE writing resolvers:
- Define Types: Represent your domain model
- Define Queries: Read operations for fetching data
- Define Mutations: Write operations for modifying data
- Define Subscriptions: Real-time updates
- Implement Resolvers: Connect schema to data sources
Benefits:
- Clear contract between client and server
- Introspection documentation for free
- Type safety across the entire stack
- Schema can be evolved gradually
Core Types
Basic scalar types:
String # Text
Int # 32-bit integer
Float # Floating point
Boolean # True/False
ID # Unique identifier
# Custom scalars
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar Money
Type definitions:
# Object type
type User {
id: ID! # Non-null ID
email: String! # Required string
name: String!
phone: String # Optional string
posts: [Post!]! # Non-null array of non-null posts
tags: [String!] # Nullable array of non-null strings
createdAt: DateTime!
}
# Enum for fixed set of values
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# Interface for shared fields
interface Node {
id: ID!
createdAt: DateTime!
}
# Implementation of interface
type Post implements Node {
id: ID!
createdAt: DateTime!
title: String!
content: String!
status: PostStatus!
}
# Union for multiple return types
union SearchResult = User | Post | Comment
# Input type for mutations
input CreateUserInput {
email: String!
name: String!
password: String!
profileInput: ProfileInput
}
input ProfileInput {
bio: String
avatar: URL
}
Schema Organization
Modular Schema Structure
Organize schema across multiple files:
# user.graphql
type User {
id: ID!
email: String!
name: String!
posts(first: Int, after: String): PostConnection!
}
extend type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
}
extend type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
# post.graphql
type Post {
id: ID!
title: String!
content: String!
author: User!
status: PostStatus!
}
extend type Query {
post(id: ID!): Post
posts(first: Int, after: String): PostConnection!
}
extend type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
Queries and Filtering
Root Query Structure
type Query {
# Single resource
user(id: ID!): User
post(id: ID!): Post
# Collections
users(
first: Int = 20
after: String
filter: UserFilter
sort: UserSort
): UserConnection!
# Search
search(query: String!): [SearchResult!]!
}
input UserFilter {
status: UserStatus
email: String
createdAfter: DateTime
}
input UserSort {
field: UserSortField = CREATED_AT
direction: SortDirection = DESC
}
enum UserSortField {
CREATED_AT
UPDATED_AT
NAME
}
enum SortDirection {
ASC
DESC
}
Pagination Patterns
1. Relay Cursor Pagination (Recommended)
Best for: Infinite scroll, real-time data, consistent results
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
# Usage
{
users(first: 10, after: "cursor123") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
2. Offset Pagination (Simpler)
Best for: Traditional pagination UI
type UserList {
items: [User!]!
total: Int!
page: Int!
pageSize: Int!
pages: Int!
}
type Query {
users(page: Int = 1, pageSize: Int = 20): UserList!
}
Mutations and Error Handling
Input/Payload Pattern
Always use input types and return structured payloads:
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}
type CreatePostPayload {
post: Post
errors: [Error!]
success: Boolean!
}
type Error {
field: String
message: String!
code: ErrorCode!
}
enum ErrorCode {
VALIDATION_ERROR
UNAUTHORIZED
NOT_FOUND
INTERNAL_ERROR
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
Implementation (Python/Ariadne):
@mutation.field("createPost")
async def resolve_create_post(obj, info, input: dict) -> dict:
try:
# Validate input
if not input.get("title"):
return {
"post": None,
"errors": [{"field": "title", "message": "Title required"}],
"success": False
}
# Create post
post = await create_post(
title=input["title"],
content=input["content"],
tags=input.get("tags", [])
)
return {
"post": post,
"errors": [],
"success": True
}
except Exception as e:
return {
"post": None,
"errors": [{"message": str(e), "code": "INTERNAL_ERROR"}],
"success": False
}
Batch Mutations
input BatchCreateUserInput {
users: [CreateUserInput!]!
}
type BatchCreateUserPayload {
results: [CreateUserResult!]!
successCount: Int!
errorCount: Int!
}
type CreateUserResult {
user: User
errors: [Error!]
index: Int!
}
type Mutation {
batchCreateUsers(input: BatchCreateUserInput!): BatchCreateUserPayload!
}
Resolver Implementation
Basic Resolvers
from ariadne import QueryType, ObjectType, MutationType
query = QueryType()
user_type = ObjectType("User")
mutation = MutationType()
@query.field("user")
async def resolve_user(obj, info, id: str) -> dict:
"""Resolve single user by ID."""
return await fetch_user_by_id(id)
@query.field("users")
async def resolve_users(obj, info, first: int = 20, after: str = None) -> dict:
"""Resolve paginated user list."""
offset = decode_cursor(after) if after else 0
users = await fetch_users(limit=first + 1, offset=offset)
has_next = len(users) > first
if has_next:
users = users[:first]
edges = [
{"node": user, "cursor": encode_cursor(offset + i)}
for i, user in enumerate(users)
]
return {
"edges": edges,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": offset > 0,
"startCursor": edges[0]["cursor"] if edges else None,
"endCursor": edges[-1]["cursor"] if edges else None
}
}
@user_type.field("posts")
async def resolve_user_posts(user: dict, info, first: int = 20) -> dict:
"""Resolve user's posts (with DataLoader to prevent N+1)."""
loader = info.context["loaders"]["posts_by_user"]
return await loader.load(user["id"])
N+1 Query Prevention with DataLoaders
The N+1 problem: Fetching related data one-by-one instead of batching
Problem example:
# This creates N+1 queries!
for user in users:
user.posts = await fetch_posts_for_user(user.id) # N queries!
Solution with DataLoader:
from aiodataloader import DataLoader
class PostsByUserLoader(DataLoader):
"""Batch load posts for multiple users."""
async def batch_load_fn(self, user_ids: list) -> list:
"""Load posts for multiple users in ONE query."""
posts = await fetch_posts_by_user_ids(user_ids)
# Group posts by user_id
posts_by_user = {}
for post in posts:
user_id = post["user_id"]
if user_id not in posts_by_user:
posts_by_user[user_id] = []
posts_by_user[user_id].append(post)
# Return in input order
return [posts_by_user.get(uid, []) for uid in user_ids]
# Setup context with loaders
def create_context():
return {
"loaders": {
"posts_by_user": PostsByUserLoader()
}
}
# Use in resolver
@user_type.field("posts")
async def resolve_user_posts(user: dict, info) -> list:
loader = info.context["loaders"]["posts_by_user"]
return await loader.load(user["id"])
Query Complexity and Security
Depth Limiting
Prevent excessively nested queries:
def depth_limit_validator(max_depth: int):
def validate_depth(context, node, ancestors):
depth = len(ancestors)
if depth > max_depth:
raise GraphQLError(
f"Query depth {depth} exceeds max {max_depth}"
)
return validate_depth
# Usage in schema validation
from graphql import validate
depth_validator = depth_limit_validator(10)
errors = validate(schema, parsed_query, [depth_validator])
Query Complexity Analysis
Limit query complexity to prevent expensive operations:
def calculate_complexity(field_nodes, type_info, complexity_args):
"""Calculate complexity score for a query."""
complexity = 1
if type_info.type and isinstance(type_info.type, GraphQLList):
# List fields multiply complexity
list_size = complexity_args.get("first", 10)
complexity *= list_size
return complexity
# Usage
from graphql import validate
complexity_validator = QueryComplexityValidator(max_complexity=1000)
errors = validate(schema, parsed_query, [complexity_validator])
Subscriptions for Real-Time Updates
type Subscription {
postAdded: Post!
postUpdated(postId: ID!): Post!
userStatusChanged(userId: ID!): UserStatus!
}
type UserStatus {
userId: ID!
online: Boolean!
lastSeen: DateTime!
}
# Client usage
subscription {
postAdded {
id
title
author { name }
}
}
Implementation:
subscription = SubscriptionType()
@subscription.source("postAdded")
async def post_added_generator(obj, info):
"""Subscribe to new posts."""
async for post in info.context["pubsub"].subscribe("posts"):
yield post
@subscription.field("postAdded")
def post_added_resolver(post, info):
return post
Custom Scalars
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar Money
type User {
email: Email!
website: URL
createdAt: DateTime!
metadata: JSON
}
type Product {
price: Money!
}
Directives
Built-in Directives
type User {
name: String!
email: String! @deprecated(reason: "Use emails field")
emails: [String!]!
privateData: String @include(if: $isOwner)
}
query GetUser($isOwner: Boolean!) {
user(id: "123") {
name
privateData @include(if: $isOwner)
}
}
Custom Directives
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
USER
ADMIN
MODERATOR
}
type Mutation {
deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
}
Schema Versioning
Field Deprecation
type User {
name: String! @deprecated(reason: "Use firstName and lastName")
firstName: String!
lastName: String!
}
Schema Evolution (Backward Compatible)
# v1
type User {
name: String!
}
# v2 - Add optional field
type User {
name: String!
email: String
}
# v3 - Deprecate old field
type User {
name: String! @deprecated(reason: "Use firstName/lastName")
firstName: String!
lastName: String!
email: String
}
Best Practices Summary
- Nullable by Default: Make fields nullable initially, mark as non-null when guaranteed
- Input Types: Always use input types for mutations (never raw arguments)
- Payload Pattern: Return errors within mutation payloads
- Cursor Pagination: Use for infinite scroll, offset for simple cases
- DataLoaders: Prevent N+1 queries with batch loading
- Naming: camelCase for fields, PascalCase for types
- Deprecation: Use
@deprecatedfor backward compatibility - Query Limits: Enforce depth and complexity limits
- Custom Scalars: Model domain types (Email, DateTime)
- Documentation: Document schema fields with descriptions
Common Pitfalls to Avoid
- Using nullable for fields that should always exist
- Forgetting to batch load related data (N+1 queries)
- Over-nesting schemas (design flat hierarchies)
- Not limiting query complexity (vulnerable to attacks)
- Removing fields instead of deprecating
- Tight coupling between schema and database schema
- Missing error handling in mutations
- Not implementing pagination
Cross-Skill References
- rest-api-design skill - For REST comparison
- api-architecture skill - For security, versioning, monitoring
- api-testing skill - For testing GraphQL queries and mutations
Reference files for this skill are planned for a future release.
Weekly Installs
3
Repository
karchtho/my-cla…ketplaceFirst Seen
Mar 3, 2026
Security Audits
Installed on
mcpjam3
claude-code3
replit3
junie3
windsurf3
zencoder3