Schema Design
TL;DR
Good GraphQL schema design is crucial for API usability, performance, and evolvability. Key principles include designing for the client's use cases, using nullable fields thoughtfully, implementing pagination with connections, and leveraging interfaces/unions for polymorphism. A well-designed schema is self-documenting and enables schema evolution without breaking changes.
Design Principles
Think in Graphs, Not Endpoints
┌─────────────────────────────────────────────────────────────────┐
│ REST vs GraphQL Thinking │
│ │
│ REST (Resource-oriented) GraphQL (Graph-oriented) │
│ │
│ /users type User { │
│ /users/:id id: ID! │
│ /users/:id/posts posts: [Post!]! │
│ /users/:id/followers followers: [User!]! │
│ /posts followersCount: Int! │
│ /posts/:id } │
│ /posts/:id/comments │
│ /posts/:id/author type Post { │
│ id: ID! │
│ Each endpoint = separate author: User! │
│ decision comments: [Comment!]! │
│ } │
│ │
│ Everything connected, │
│ traverse as needed │
└─────────────────────────────────────────────────────────────────┘Design for Use Cases
graphql
# BAD: Database-oriented schema
type User {
id: ID!
first_name: String! # Snake case from DB
last_name: String!
email_address: String!
created_at: DateTime!
updated_at: DateTime!
is_deleted: Boolean! # Internal field exposed
}
# GOOD: Client-oriented schema
type User {
id: ID!
firstName: String! # camelCase for clients
lastName: String!
# Computed field - what clients actually need
fullName: String!
email: String!
# Formatted for display
memberSince: String!
# Don't expose internal flags
# is_deleted stays internal
}
# Resolver provides computed fields
const resolvers = {
User: {
fullName: (user) => `${user.first_name} ${user.last_name}`,
memberSince: (user) => formatDate(user.created_at),
}
};Nullability
Non-Null by Default
graphql
# Every field should be non-null unless there's a reason
type User {
id: ID! # Always exists
name: String! # Required field
email: String! # Required field
# Nullable fields have clear reasons:
avatar: String # Optional, user may not have set
bio: String # Optional profile field
deletedAt: DateTime # Only set when deleted
# Lists: usually non-null list of non-null items
posts: [Post!]! # May be empty [], but never null
roles: [Role!]! # Same pattern
# Nullable in list only when semantically meaningful
previousEmployers: [Company]! # Some entries might be unknown
}When to Use Nullable
┌─────────────────────────────────────────────────────────────────┐
│ Nullability Decision Tree │
│ │
│ Can this field ever legitimately not have a value? │
│ │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ NULLABLE NON-NULL (!) │
│ │
│ Examples: │
│ │
│ NULLABLE NON-NULL │
│ • avatar (not all users) • id (always exists) │
│ • deletedAt (only if deleted) • name (required) │
│ • parentCategory (root=null) • createdAt (always set) │
│ • nickname (optional) • posts (empty list ok) │
│ • resolverMightFail • type (known entity type) │
│ │
│ IMPORTANT: Non-null fields that fail cause partial response │
│ nullability. If User.name fails, entire User becomes null. │
└─────────────────────────────────────────────────────────────────┘Error Propagation
graphql
type Query {
user(id: ID!): User # Nullable - can return null if not found
}
type User {
id: ID!
name: String! # Non-null - failure nullifies parent
email: String!
posts: [Post!]!
}
# If name resolver throws:
{
"data": {
"user": null # Entire user is null
},
"errors": [{
"message": "Failed to fetch name",
"path": ["user", "name"]
}]
}
# If posts resolver throws (for one post):
{
"data": {
"user": {
"id": "123",
"name": "Alice",
"posts": null # Entire posts array is null
}
},
"errors": [...]
}Pagination
Cursor-Based Pagination (Relay Connection Spec)
graphql
# Connection types for 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
}
# Query example
query GetUsers {
users(first: 10, after: "cursor123") {
edges {
node {
id
name
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}Implementation
python
import base64
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class PageInfo:
has_next_page: bool
has_previous_page: bool
start_cursor: Optional[str]
end_cursor: Optional[str]
def encode_cursor(id: str) -> str:
"""Encode ID as opaque cursor"""
return base64.b64encode(f"cursor:{id}".encode()).decode()
def decode_cursor(cursor: str) -> str:
"""Decode cursor to ID"""
decoded = base64.b64decode(cursor.encode()).decode()
return decoded.replace("cursor:", "")
async def resolve_users_connection(
_,
info,
first: int = None,
after: str = None,
last: int = None,
before: str = None
):
# Build query
query = {}
if after:
after_id = decode_cursor(after)
query["_id"] = {"$gt": after_id}
if before:
before_id = decode_cursor(before)
query["_id"] = {"$lt": before_id}
# Determine limit and direction
if first:
limit = first + 1 # Fetch one extra to check hasNextPage
sort_dir = 1
elif last:
limit = last + 1
sort_dir = -1
else:
limit = 11 # Default
sort_dir = 1
# Fetch data
users = await db.users.find(query).sort("_id", sort_dir).limit(limit).to_list()
# Check for more pages
has_more = len(users) > (first or last or 10)
if has_more:
users = users[:-1] # Remove extra item
# Reverse if using 'last'
if last:
users = list(reversed(users))
# Build edges
edges = [
{
"node": user,
"cursor": encode_cursor(str(user["_id"]))
}
for user in users
]
return {
"edges": edges,
"pageInfo": {
"hasNextPage": has_more if first else False,
"hasPreviousPage": has_more if last else False,
"startCursor": edges[0]["cursor"] if edges else None,
"endCursor": edges[-1]["cursor"] if edges else None,
},
"totalCount": await db.users.count_documents({})
}Offset vs Cursor Pagination
┌─────────────────────────────────────────────────────────────────┐
│ Pagination Comparison │
│ │
│ OFFSET-BASED (AVOID) │
│ users(limit: 10, offset: 20) │
│ │
│ Problems: │
│ • Expensive for large offsets (DB scans all rows) │
│ • Inconsistent with real-time data │
│ • Items can be skipped or duplicated when data changes │
│ │
│ CURSOR-BASED (RECOMMENDED) │
│ users(first: 10, after: "xyz") │
│ │
│ Benefits: │
│ • Consistent pagination (no skips/duplicates) │
│ • Efficient (DB uses index) │
│ • Works with real-time data │
│ • Opaque cursors hide implementation │
│ │
│ When offset is OK: │
│ • Small datasets │
│ • "Jump to page N" requirement │
│ • Admin dashboards with stable data │
└─────────────────────────────────────────────────────────────────┘Input Types
Designing Input Types
graphql
# Separate input types from output types
type User {
id: ID!
name: String!
email: String!
role: Role!
createdAt: DateTime!
}
# Input for creating
input CreateUserInput {
name: String!
email: String!
password: String! # Only on create
role: Role = USER # Default value
}
# Input for updating (all fields optional)
input UpdateUserInput {
name: String
email: String
role: Role
}
# Input for filtering/querying
input UserFilter {
nameContains: String
email: String
role: Role
createdAfter: DateTime
createdBefore: DateTime
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
type Query {
users(filter: UserFilter, first: Int, after: String): UserConnection!
}Input Validation
graphql
# Use custom scalars for validated inputs
scalar Email # Validated email format
scalar URL # Validated URL format
scalar PositiveInt # Must be > 0
scalar NonEmptyString # Must have content
input CreateUserInput {
name: NonEmptyString!
email: Email!
website: URL
age: PositiveInt
}
# Or use input unions for complex validation (newer spec)
input CreatePostInput {
title: String! @constraint(minLength: 1, maxLength: 200)
content: String! @constraint(minLength: 10)
tags: [String!]! @constraint(maxItems: 10)
}Interfaces and Unions
When to Use Interfaces
graphql
# Interface: types share common fields
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
interface Content {
id: ID!
title: String!
body: String!
author: User!
}
# Types implement interfaces
type Article implements Node & Timestamped & Content {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
body: String!
author: User!
# Article-specific fields
category: Category!
readingTime: Int!
}
type BlogPost implements Node & Timestamped & Content {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
body: String!
author: User!
# BlogPost-specific fields
tags: [String!]!
comments: [Comment!]!
}
# Query can return interface type
type Query {
node(id: ID!): Node
content(id: ID!): Content
feed: [Content!]!
}When to Use Unions
graphql
# Union: types with NO shared fields
union SearchResult = User | Post | Comment | Tag
type Query {
search(query: String!): [SearchResult!]!
}
# Must use inline fragments to query
query Search {
search(query: "graphql") {
... on User {
id
name
avatar
}
... on Post {
id
title
excerpt
}
... on Comment {
id
text
}
... on Tag {
name
postCount
}
}
}
# Another example: notification types
union Notification =
| NewFollowerNotification
| PostLikedNotification
| CommentNotification
| MentionNotification
type NewFollowerNotification {
id: ID!
follower: User!
createdAt: DateTime!
}
type PostLikedNotification {
id: ID!
post: Post!
likedBy: User!
createdAt: DateTime!
}Interface vs Union Decision
┌─────────────────────────────────────────────────────────────────┐
│ Interface vs Union │
│ │
│ Use INTERFACE when: │
│ • Types share common fields │
│ • You want to query common fields without fragments │
│ • Types are conceptually similar (is-a relationship) │
│ • Example: Article, BlogPost are both Content │
│ │
│ Use UNION when: │
│ • Types have no shared fields │
│ • Types are conceptually different │
│ • Grouping is for convenience, not inheritance │
│ • Example: SearchResult can be User, Post, or Tag │
│ │
│ Key difference: │
│ • Interface: "These types ARE this thing" │
│ • Union: "Result could be ANY OF these things" │
└─────────────────────────────────────────────────────────────────┘Naming Conventions
Types and Fields
graphql
# Types: PascalCase
type UserProfile { }
type BlogPost { }
type OrderItem { }
# Fields: camelCase
type User {
firstName: String!
lastName: String!
emailAddress: String!
createdAt: DateTime!
}
# Enums: SCREAMING_SNAKE_CASE values
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
# Input types: PascalCase with Input suffix
input CreateUserInput { }
input UpdatePostInput { }
input UserFilterInput { }
# Connection types: PascalCase with Connection/Edge suffix
type UserConnection { }
type UserEdge { }Mutations
graphql
# Verb + Noun pattern
type Mutation {
# CRUD operations
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
# Domain actions
publishPost(id: ID!): Post!
archivePost(id: ID!): Post!
followUser(userId: ID!): User!
unfollowUser(userId: ID!): User!
# Batch operations
deleteUsers(ids: [ID!]!): Int!
assignTagsToPosts(postIds: [ID!]!, tagIds: [ID!]!): [Post!]!
}
# Mutation response pattern (recommended)
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: ErrorCode!
}Schema Evolution
Adding Fields (Non-Breaking)
graphql
# Original schema
type User {
id: ID!
name: String!
email: String!
}
# Add new field - non-breaking change
type User {
id: ID!
name: String!
email: String!
avatar: String # New nullable field
createdAt: DateTime! # New non-null field (with resolver default)
}Deprecating Fields
graphql
type User {
id: ID!
name: String!
# Deprecated field - still works but marked for removal
fullName: String! @deprecated(reason: "Use `name` instead")
# New preferred field
displayName: String!
# Deprecated with migration path
email: String! @deprecated(
reason: "Use `primaryEmail` for main email or `emails` for all"
)
primaryEmail: String!
emails: [Email!]!
}
# Clients see deprecation in introspection
# Tooling (like GraphiQL) shows warningsBreaking Changes (Avoid)
┌─────────────────────────────────────────────────────────────────┐
│ Breaking vs Non-Breaking Changes │
│ │
│ NON-BREAKING (SAFE) │
│ ✓ Adding new types │
│ ✓ Adding new fields to existing types │
│ ✓ Adding new enum values │
│ ✓ Adding new arguments with defaults │
│ ✓ Deprecating fields (they still work) │
│ ✓ Making nullable field non-null (if always had value) │
│ │
│ BREAKING (AVOID) │
│ ✗ Removing types │
│ ✗ Removing fields │
│ ✗ Removing enum values │
│ ✗ Renaming types or fields │
│ ✗ Changing field types │
│ ✗ Making non-null field nullable │
│ ✗ Adding required arguments without defaults │
│ │
│ If breaking change needed: │
│ 1. Add new field with new behavior │
│ 2. Deprecate old field │
│ 3. Monitor usage until deprecated field unused │
│ 4. Remove in future version │
└─────────────────────────────────────────────────────────────────┘Documentation
Self-Documenting Schema
graphql
"""
A registered user of the platform.
Users can create posts, follow other users, and interact with content.
"""
type User {
"Unique identifier for the user"
id: ID!
"Display name shown on the user's profile"
name: String!
"""
Primary email address for the user.
Used for authentication and notifications.
"""
email: String!
"""
URL to the user's avatar image.
Returns null if user hasn't set an avatar.
Recommended size: 200x200 pixels.
"""
avatar: String
"""
Posts authored by this user.
Ordered by creation date, newest first.
"""
posts(
"Maximum number of posts to return"
first: Int = 10
"Cursor for pagination"
after: String
): PostConnection!
"ISO 8601 timestamp of when the user registered"
createdAt: DateTime!
}
"""
Represents a blog post created by a user.
Posts can be in draft or published state.
"""
type Post {
id: ID!
"Title of the post (max 200 characters)"
title: String!
"Main content in Markdown format"
content: String!
"Current publication status"
status: PostStatus!
"The user who authored this post"
author: User!
}
"""
Publication status of a post.
"""
enum PostStatus {
"Post is being edited and not publicly visible"
DRAFT
"Post is published and publicly visible"
PUBLISHED
"Post was published but has been archived"
ARCHIVED
}Common Patterns
Node Interface (Relay Global Object Identification)
graphql
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}
# All fetchable types implement Node
type User implements Node {
id: ID!
# ...
}
type Post implements Node {
id: ID!
# ...
}
# IDs are globally unique (often base64 encoded type:id)
# "User:123" -> "VXNlcjoxMjM="Viewer Pattern
graphql
type Query {
# All user-specific data under viewer
viewer: Viewer
}
type Viewer {
id: ID!
user: User!
# User's data
notifications(first: Int): NotificationConnection!
feed(first: Int): PostConnection!
drafts: [Post!]!
# User's permissions
canCreatePost: Boolean!
canModerate: Boolean!
}
# Query
query MyFeed {
viewer {
user {
name
}
feed(first: 20) {
edges {
node {
title
}
}
}
}
}Action-Specific Return Types
graphql
type Mutation {
followUser(userId: ID!): FollowUserPayload!
}
type FollowUserPayload {
"The user who initiated the follow"
follower: User!
"The user who was followed"
followee: User!
"Updated follower count for the followee"
followeeFollowerCount: Int!
}
# Client gets all relevant updated data in one response
mutation Follow {
followUser(userId: "456") {
followee {
id
name
}
followeeFollowerCount
}
}Best Practices Checklist
Naming:
□ Types in PascalCase
□ Fields in camelCase
□ Enums in SCREAMING_SNAKE_CASE
□ Input types suffixed with Input
□ Mutations follow verb + noun pattern
Nullability:
□ Non-null by default, nullable only when meaningful
□ Lists are [Type!]! (non-null list of non-null items)
□ Consider error propagation behavior
Pagination:
□ Use cursor-based pagination (Relay Connection spec)
□ Include pageInfo and totalCount
□ Provide sensible defaults for first/last
Types:
□ Design for client use cases, not database schema
□ Add computed/derived fields where helpful
□ Use interfaces for shared fields
□ Use unions for heterogeneous results
Evolution:
□ Never remove fields, deprecate first
□ Add new fields instead of changing existing
□ Document deprecation reason and migration path
□ Monitor field usage before removal
Documentation:
□ Add descriptions to all types and fields
□ Document arguments and their defaults
□ Include examples where helpful
□ Keep descriptions up to date