| name | graphql-api-design |
| description | GraphQL schema design, type systems, resolver patterns, DataLoader optimization, pagination, subscriptions, and query complexity management. Use when building GraphQL APIs, designing schemas, migrating from REST, or optimizing query performance. |
| version | 1.0.0 |
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
Int
Float
Boolean
ID
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar Money
Type definitions:
type User {
id: ID!
email: String!
name: String!
phone: String
posts: [Post!]!
tags: [String!]
createdAt: DateTime!
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
interface Node {
id: ID!
createdAt: DateTime!
}
type Post implements Node {
id: ID
DateTime
String
String
PostStatus
SearchResult User Post Comment
CreateUserInput
String
String
String
ProfileInput
ProfileInput
String
URL
Schema Organization
Modular Schema Structure
Organize schema across multiple files:
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!
}
type Post {
id: ID!
title String
String
User
PostStatus
extend
post ID Post
posts Int, String PostConnection
extend
createPost CreatePostInput CreatePostPayload
Queries and Filtering
Root Query Structure
type Query {
user(id: ID!): User
post(id: ID!): Post
users(
first: Int = 20
after: String
filter: UserFilter
sort: UserSort
): UserConnection!
search(query: String!): [SearchResult!]!
}
input UserFilter {
status: UserStatus
email: String
createdAfter: DateTime
}
input UserSort {
field: UserSortField CREATED_AT
SortDirection DESC
UserSortField
CREATED_AT
UPDATED_AT
NAME
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!
}
{
users( ,
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:
if not input.get("title"):
return {
"post": None,
"errors": [{"field": "title", "message": "Title required"}],
"success": False
}
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":
}
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,
: edges[][] edges ,
: edges[-][] edges
}
}
() -> :
loader = info.context[][]
loader.load(user[])
N+1 Query Prevention with DataLoaders
The N+1 problem: Fetching related data one-by-one instead of batching
Problem example:
for user in users:
user.posts = await fetch_posts_for_user(user.id)
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)
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 [posts_by_user.get(uid, []) for uid in user_ids]
def create_context():
return {
"loaders": {
"posts_by_user": PostsByUserLoader()
}
}
@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[])
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
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_size = complexity_args.get("first", 10)
complexity *= list_size
return complexity
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!
}
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)
type User {
name: String!
}
type User {
name: String!
email: String
}
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
@deprecated for 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
Additional Resources
For detailed guidance, see:
references/schema-patterns.md - Complete schema design patterns
references/resolver-patterns.md - Resolver and DataLoader patterns
references/subscriptions-guide.md - Real-time subscription patterns